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..5731313f --- /dev/null +++ b/site/assets/js/modules/forms/phone-number.js @@ -0,0 +1,78 @@ +/* + * 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'; + +/** + * Checks whether the input contains only supported phone-number characters. + * + * Allowed: digits, parentheses, hyphens, and spaces. + * + * @param {string} value phone-number value to check + * @return {boolean} true when the value contains only allowed characters + */ +export function isValidPhoneNumberInput(value) { + return /^[0-9\s()-]+$/.test(value); +} + +/** + * 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..334feb05 --- /dev/null +++ b/site/assets/js/modules/paygate/purchases.js @@ -0,0 +1,234 @@ +/* + * 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 product data returned for a checkout order. + * + * @typedef {Object} PaygateProduct + * @property {string} name product display name + * @property {string} description product description shown on checkout + * @property {number|string} netPrice product price before VAT + * @property {string} currency product currency code + */ + +/** + * Paygate response for the `place-order` endpoint. + * + * @typedef {Object} PlaceOrderResponse + * @property {string} orderId created paygate order ID + * @property {PaygateProduct|null} product product data for the new order + */ + +/** + * 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 {number|string} netPrice price before VAT + * @property {number|string} vatRate vat rate as a decimal fraction + * @property {number|string} vatAmount VAT amount for the order + * @property {number|string} total total price including VAT + * @property {string} currency order currency code + */ + +/** + * 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 + */ + +/** + * 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 {string} [phoneNumber] optional normalized phone number including + * country code + */ + +/** + * 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 preferred payment redirect URL + * @property {string} redirectUrl alternative payment redirect URL + * @property {string} url alternative response URL field + * @property {string} link alternative response link field + */ + +/** + * 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 {*|null} body parsed response body, if any + * @property {string} message human-readable error message + */ + +/** + * Paygate purchase endpoint methods used by checkout. + * + * @typedef {Object} PaygatePurchaseClient + * @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) { + const baseUrl = normalizeServerUrl(serverUrl); + + return { + placeOrder(productId) { + return postJson(`${baseUrl}/purchases/place-order`, {productId}); + }, + calculateCharges(payload) { + return postJson(`${baseUrl}/purchases/calculate-charges`, payload); + }, + submitBillingInfo(payload) { + return postJson(`${baseUrl}/purchases/submit-billing-info`, payload); + } + }; +} + +/** + * Removes trailing slashes from the configured Paygate server URL. + * + * @param {string} url raw configured Paygate server URL + * @return {string} server URL without trailing slashes + */ +function normalizeServerUrl(url) { + return String(url || '').replace(/\/+$/, ''); +} + +/** + * Sends a JSON POST request and returns the parsed response body. + * + * @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, + message: errorMessage(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') || ''; + + if (response.status === 204) { + return null; + } + + try { + return contentType.includes('application/json') + ? await response.json() + : await response.text(); + } catch (ignored) { + return null; + } +} + +/** + * Extracts a human-readable error message from a parsed response body. + * + * @param {*} body parsed response body + * @return {string} message text when present, otherwise an empty string + */ +function errorMessage(body) { + if (!body) { + return ''; + } + + return typeof body === 'string' ? body : body.message || ''; +} 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..350726d7 --- /dev/null +++ b/site/assets/js/pages/checkout/charge-controller.js @@ -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. + */ + +'use strict'; + +/** + * @typedef {import('js/modules/paygate/purchases').PaygatePurchaseClient} PaygatePurchaseClient + * @typedef {import('js/pages/checkout/view-controller').CheckoutViewController} CheckoutViewController + */ + +/** + * Delay 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(): string|null} options.getOrderId returns the current Paygate order ID + * @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.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, + getOrderId, + getBuyerCountryCode, + getVatId, + onVatIdError, + logApiError +}) { + let chargeRequestId = 0; + let chargesRequestTimer = null; + let pendingChargesKey = ''; + let pendingChargesPromise = null; + let chargesReadyKey = ''; + + /** + * Recalculates charges when order, country, and VAT ID are available. + * + * @return {Promise} resolves when charges are updated, skipped, or handled as an error + */ + async function requestIfReady() { + const requestKey = getRequestKey(); + const reusableRequest = getReusableRequest(requestKey); + + if (!requestKey) { + invalidate(); + return; + } + + if (reusableRequest) { + return reusableRequest; + } + + return startChargesRequest(requestKey); + } + + /** + * Cancels delayed VAT recalculation and requests charges immediately. + * + * @return {Promise} resolves when the immediate request flow finishes + */ + function flush() { + clearScheduledRequest(); + return requestIfReady(); + } + + /** + * Debounces charge recalculation while the user types a VAT ID. + */ + function schedule() { + clearScheduledRequest(); + chargesRequestTimer = window.setTimeout(() => { + chargesRequestTimer = null; + requestIfReady(); + }, vatIdInputDelay); + } + + /** + * Clears charge state and ignores older responses. + */ + function invalidate() { + clearScheduledRequest(); + chargeRequestId += 1; + pendingChargesKey = ''; + pendingChargesPromise = null; + chargesReadyKey = ''; + updateSubmitState(); + } + + /** + * Checks whether a delayed VAT request is currently scheduled. + * + * @return {boolean} true when a delayed request timer is active + */ + function hasScheduledRequest() { + return Boolean(chargesRequestTimer); + } + + /** + * Checks whether charges are calculated for the form's current country and VAT ID. + * + * @return {boolean} true when the latest charges match the current form state + */ + function hasCurrentCharges() { + const requestKey = getRequestKey(); + return Boolean(requestKey) && requestKey === chargesReadyKey; + } + + /** + * Returns a finished or in-flight request promise that can be reused. + * + * @param {string} requestKey joined order:country:VAT key + * @return {Promise|null} reusable promise for the same inputs, if any + */ + function getReusableRequest(requestKey) { + if (requestKey === chargesReadyKey) { + return Promise.resolve(); + } + + return requestKey === pendingChargesKey && pendingChargesPromise + ? pendingChargesPromise + : null; + } + + /** + * Starts a new charge calculation request for the current checkout inputs. + * + * @param {string} requestKey joined order:country:VAT key + * @return {Promise} resolves when the request lifecycle is finished + */ + function startChargesRequest(requestKey) { + const requestId = ++chargeRequestId; + + pendingChargesKey = requestKey; + chargesReadyKey = ''; + updateSubmitState(); + pendingChargesPromise = purchaseClient + .calculateCharges(createRequestPayload(requestKey)) + .then(response => handleRequestSuccess(requestId, requestKey, response)) + .catch(error => handleRequestError(requestId, error)) + .finally(() => finishRequest(requestId)); + + return pendingChargesPromise; + } + + /** + * Enables checkout submission only when the current charge calculation is ready. + */ + function updateSubmitState() { + view.setSubmitDisabled(view.isFormHidden() || !hasCurrentCharges()); + } + + /** + * Builds the Paygate calculate-charges payload for the current request key. + * + * @param {string} requestKey joined order:country:VAT key + * @return {{orderId: string, buyerCountryCode: string, vatId: string}} request payload + */ + function createRequestPayload(requestKey) { + const [orderId, buyerCountryCode, vatId] = requestKey.split(':'); + + return { + orderId, + buyerCountryCode, + vatId + }; + } + + /** + * Builds the cache key for the current charge calculation inputs. + * + * @return {string} joined order:country:VAT key, + * or empty string when calculation cannot run yet + */ + function getRequestKey() { + const orderId = getOrderId(); + const buyerCountryCode = getBuyerCountryCode(); + const vatId = getVatId(); + + return orderId && buyerCountryCode && vatId + ? [orderId, buyerCountryCode, vatId].join(':') + : ''; + } + + /** + * Applies a successful charge calculation response if it is still current. + * + * @param {number} requestId internal request sequence number + * @param {string} requestKey joined order:country:VAT key + * @param {Object} response paygate charge calculation response + */ + function handleRequestSuccess(requestId, requestKey, response) { + if (requestId !== chargeRequestId) { + return; + } + + chargesReadyKey = requestKey; + view.updateCharges(response); + updateSubmitState(); + } + + /** + * Handles a failed charge calculation response. + * + * @param {number} requestId internal request sequence number + * @param {Object} error error response + */ + function handleRequestError(requestId, error) { + const isCurrentRequest = requestId === chargeRequestId; + const isVatError = isVatErrorResponse(error); + + if (!isVatError) { + view.showErrorModal(); + } + + if (!isCurrentRequest) { + logApiError(error); + return; + } + + chargesReadyKey = ''; + updateSubmitState(); + + if (isVatError) { + onVatIdError(error.body.reason); + } + + logApiError(error); + } + + /** + * Clears the tracked in-flight request when the current request finishes. + * + * @param {number} requestId internal request sequence number + */ + function finishRequest(requestId) { + if (requestId !== chargeRequestId) { + return; + } + + pendingChargesKey = ''; + pendingChargesPromise = null; + } + + /** + * Clears the delayed VAT recalculation timer when one is active. + */ + function clearScheduledRequest() { + if (!chargesRequestTimer) { + return; + } + + window.clearTimeout(chargesRequestTimer); + chargesRequestTimer = null; + } + + /** + * Checks whether a calculation error should is related to the VAT ID. + * + * @param {Object} error error response + * @return {boolean} true when response contains a VAT ID verification failure reason + */ + function isVatErrorResponse(error) { + return error.status === 422 && + error.body && + /^VAT_ID_/.test(error.body.reason || ''); + } + + return { + flush, + hasCurrentCharges, + hasScheduledRequest, + invalidate, + requestIfReady, + schedule, + updateSubmitState + }; +} 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..e83c34e5 --- /dev/null +++ b/site/assets/js/pages/checkout/form-controller.js @@ -0,0 +1,416 @@ +/* + * 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 { + isValidPhoneNumberInput, + normalizePhoneNumber, + sanitizePhoneNumberInput +} from 'js/modules/forms/phone-number'; + +/** + * @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(string): SubmitBillingInfoRequest} + * buildSubmitBillingInfoRequest builds the billing-info payload for Paygate + * @property {function(): void} handlePhoneClick + * focuses the phone-country selector when the field wrapper is clicked + * @property {function(JQuery.Event): void} handlePhoneNumberBeforeInput + * blocks unsupported phone input characters + * @property {function(): void} handlePhoneNumberFocus + * focuses the phone-country selector before number entry + * @property {function(): void} sanitizePhoneNumberValue + * normalizes phone text after edits + * @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}) { + /** + * 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.'; + } else if (field.id === 'checkout-phone' && value && !isValidPhoneNumberInput(value)) { + message = 'Use digits, spaces, parentheses, or hyphens only.'; + } else if (field.id === 'checkout-phone' && value && !dom.$phoneCountryCode.val()) { + message = 'Choose a phone country code.'; + } + + 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)); + } + + /** + * 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 phoneCode = dom.$phoneCountryCode.val(); + const countryCode = countryCodeFromPhoneCode(phoneCode); + + 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; + } + + const phoneCode = euCountryPhoneCodes[dom.$country.val()] || ''; + dom.$phoneCountryCode.val(phoneCode); + updatePhoneCountryDisplay(); + } + + /** + * Mirrors the selected phone country into the custom visible phone field. + */ + function updatePhoneCountryDisplay() { + const selected = dom.$phoneCountryCode.find(':selected'); + const flag = selected.data('flag') || ''; + const code = selected.data('code') || ''; + const hasPhoneCountry = Boolean(dom.$phoneCountryCode.val()); + + dom.$phoneFlag.text(flag); + dom.$phoneDialCode.text(code); + dom.$phone.attr('data-phone-country-selected', hasPhoneCountry ? 'true' : 'false'); + dom.$phoneNumber.prop('disabled', !hasPhoneCountry); + + if (!hasPhoneCountry) { + clearPhoneNumber(); + } + } + + /** + * Moves focus to the invisible native select that backs the phone country picker. + */ + function focusPhoneCountrySelector() { + dom.$phoneCountryCode.trigger('focus'); + } + + /** + * Handles clicks on the custom phone field wrapper. + */ + function handlePhoneClick() { + if (!dom.$phoneCountryCode.val()) { + focusPhoneCountrySelector(); + } + } + + /** + * Handles focus on the phone number input. + */ + function handlePhoneNumberFocus() { + if (!dom.$phoneCountryCode.val()) { + focusPhoneCountrySelector(); + } + } + + /** + * Prevents unsupported phone symbols from being typed into the phone field. + * + * @param {JQuery.Event} event phone number beforeinput event + */ + function handlePhoneNumberBeforeInput(event) { + const originalEvent = event.originalEvent; + + if (originalEvent && originalEvent.data && !isValidPhoneNumberInput(originalEvent.data)) { + event.preventDefault(); + } + } + + /** + * Sanitizes the phone number input after user edits. + */ + function sanitizePhoneNumberValue() { + const value = dom.$phoneNumber.val(); + const sanitized = sanitizePhoneNumberInput(value); + + if (value !== sanitized) { + dom.$phoneNumber.val(sanitized); + } + } + + /** + * 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); + fieldContainer.classList.toggle('field-error', Boolean(message)); + errorElement.textContent = message || ''; + } + + /** + * 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 'VAT_ID_INVALID_FORMAT': + return 'Invalid VAT ID format. Example: EE1234567890.'; + case 'VAT_ID_COUNTRY_MISMATCH': + return 'The VAT ID country must match the selected billing country.'; + case 'VAT_ID_NON_EU_COUNTRY': + return 'Only European Union VAT ID is acceptable.'; + case 'VAT_ID_NOT_ACTIVE': + return 'This VAT ID is not active.'; + case 'VAT_ID_INVALID': + default: + return 'Enter a valid 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, + buildSubmitBillingInfoRequest, + handlePhoneClick, + handlePhoneNumberBeforeInput, + handlePhoneNumberFocus, + sanitizePhoneNumberValue, + 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..6a6ad5f6 --- /dev/null +++ b/site/assets/js/pages/checkout/index.js @@ -0,0 +1,254 @@ +/* + * 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 countryManuallySelected = false; + let phoneCountryManuallySelected = false; + const chargeController = createChargeController({ + purchaseClient, + view, + getOrderId: () => orderId, + getBuyerCountryCode: () => dom.$country.val(), + getVatId: () => (dom.$vatId.val() || '').trim(), + onVatIdError: formController.showVatIdError, + logApiError + }); + + if (!productId) { + chargeController.invalidate(); + view.showNotFoundView(); + return; + } + + dom.$form.prop('hidden', true); + formController.updatePhoneCountryDisplay(); + chargeController.updateSubmitState(); + placeOrder(); + 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(); + } + }); + + dom.$country.on('change', () => { + countryManuallySelected = true; + chargeController.invalidate(); + formController.applyPhoneCountryFromBillingCountry(phoneCountryManuallySelected); + formController.updateVatIdFieldState(); + chargeController.flush(); + }); + + dom.$phoneCountryCode.on('change', () => { + phoneCountryManuallySelected = true; + formController.updatePhoneCountryDisplay(); + + if (formController.applyBillingCountryFromPhoneCountry(countryManuallySelected)) { + chargeController.invalidate(); + formController.updateVatIdFieldState(); + chargeController.flush(); + } + }); + + dom.$phone.on('click', formController.handlePhoneClick); + dom.$phoneNumber.on('focus', formController.handlePhoneNumberFocus); + dom.$phoneNumber.on('beforeinput', formController.handlePhoneNumberBeforeInput); + dom.$phoneNumber.on('input', formController.sanitizePhoneNumberValue); + + dom.$vatId.on('input', () => { + chargeController.invalidate(); + chargeController.schedule(); + }); + + dom.$vatId.on('blur', () => { + if (chargeController.hasScheduledRequest()) { + chargeController.flush(); + } + }); + + dom.$form.on('submit', handleSubmit); + } + + /** + * Creates an order for the product from the current checkout URL. + * + * @return {Promise} resolves when the initial order load flow finishes + */ + async function placeOrder() { + view.setSummaryLoading(true); + + try { + const response = await purchaseClient.placeOrder(productId); + + if (!response.product) { + chargeController.invalidate(); + view.showNotFoundView(); + chargeController.updateSubmitState(); + return; + } + + orderId = response.orderId; + view.fillProductSummary(response.product); + view.setSummaryLoading(false); + dom.$form.prop('hidden', false); + chargeController.updateSubmitState(); + chargeController.requestIfReady(); + } catch (error) { + if (error.status === 404) { + chargeController.invalidate(); + view.showNotFoundView(); + chargeController.updateSubmitState(); + return; + } + + view.setSummaryLoading(false); + showServerErrorModal(error); + 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; + } + + if (!orderId) { + console.error('Order ID is not available yet.'); + return; + } + + await chargeController.requestIfReady(); + + if (!chargeController.hasCurrentCharges()) { + return; + } + + try { + const response = await purchaseClient.submitBillingInfo( + formController.buildSubmitBillingInfoRequest(orderId) + ); + const redirectUrl = response.paymentLink || + response.redirectUrl || + response.url || + response.link; + + if (redirectUrl) { + window.location = redirectUrl; + } else { + console.log('Billing info response:', response); + } + } catch (error) { + showServerErrorModal(error); + logApiError(error); + } + } + + /** + * Opens the generic checkout error modal for server-side request failures. + * + * @param {Object|Error} error request error to inspect + * @return {boolean} true when the error represents a server response + */ + function showServerErrorModal(error) { + if (error.status >= 500) { + return false; + } + + view.showErrorModal(); + return true; + } + + /** + * 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 || error.message || 'Request failed'}` + ); + } + } +); 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..2f742dc0 --- /dev/null +++ b/site/assets/js/pages/checkout/view-controller.js @@ -0,0 +1,205 @@ +/* + * 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) { + let currency = ''; + + /** + * 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; + } + + currency = product.currency || currency; + 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.netPrice, currency)); + dom.$vatLabel.text('VAT'); + dom.$vatValue.text(formatMoney(0, currency)); + dom.$totalValue.text(formatMoney(product.netPrice, currency)); + } + + /** + * 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; + + currency = response.currency || currency; + dom.$vatLabel.text(`VAT (${String(vatRatePercent)}%)`); + dom.$subtotalValue.text(formatMoney(response.netPrice, currency)); + dom.$vatValue.text(formatMoney(response.vatAmount, currency)); + dom.$totalValue.text(formatMoney(response.total, currency)); + } + + /** + * 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 an amount with its currency suffix when currency is known. + * + * @param {number|string} amount amount value returned by Paygate + * @param {string} valueCurrency currency code to append + * @return {string} formatted money value with optional currency suffix + */ + function formatMoney(amount, valueCurrency) { + const numericAmount = Number(amount); + const formattedAmount = Number.isNaN(numericAmount) ? amount : numericAmount.toFixed(2); + return valueCurrency ? `${formattedAmount} ${valueCurrency}` : formattedAmount; + } + + 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..23db9ee6 --- /dev/null +++ b/site/assets/scss/modules/_forms.scss @@ -0,0 +1,287 @@ +/*! + * 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-section { + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid rgba(black, .08); +} + +.form-title { + margin-bottom: 16px; + padding-top: 0; + color: $black; + font-size: 24px; + font-weight: 700; + line-height: 1.2; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 20px; +} + +.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-select { + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, rgba($gray-500, .9) 50%), + linear-gradient(135deg, rgba($gray-500, .9) 50%, transparent 50%); + background-position: + calc(100% - 18px) calc(50% - 2px), + calc(100% - 12px) calc(50% - 2px); + 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 rgba($gray-500, .9); + border-bottom: 2px solid rgba($gray-500, .9); + transform: translateY(-64%) 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; +} + +.form-actions { + margin-top: 20px; +} + +.form-submit-button { + min-width: 220px; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: none; + } +} + +@include breakpoint(lg-phone) { + .form-title { + font-size: 24px; + } + + .form-grid { + grid-template-columns: 1fr; + gap: 14px; + } + + .form-field-full { + grid-column: auto; + } +} + +@include breakpoint(sm-phone) { + .form-input { + padding: 12px 14px; + font-size: 15px; + } + + .phone-flag { + left: 15px; + font-size: 18px; + } + + .phone-chevron { + left: 40px; + } + + .form-submit-button { + width: 100%; + min-width: 0; + } +} 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..5933b666 --- /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. + */ + +body:has(.result-page) { + .wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + } + + .main { + flex: 1 0 auto; + display: flex; + flex-direction: column; + } + + .footer { + margin-top: auto; + } +} + +.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; + } +} + +@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..f525831e --- /dev/null +++ b/site/assets/scss/pages/_checkout.scss @@ -0,0 +1,389 @@ +/*! + * 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 { + overflow-x: hidden; + + .wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + } + + .main { + flex: 1 0 auto; + display: flex; + flex-direction: column; + } + + .footer { + margin-top: auto; + } + + .wrapper, + .main, + .footer, + #header { + overflow-x: hidden; + } +} + +.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); + overflow-x: hidden; + + @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%; + } +} + +.checkout-summary { + max-width: 100%; + min-width: 0; + margin-bottom: 8px; + + &[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: 17px; + 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 16px; + border-bottom: 0; + } + + .checkout-summary-row-divider { + border-bottom: 1px solid rgba($black, .12); + } + + .checkout-summary-row-total { + padding-top: 16px; + 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: 2px 0 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; + } + } + + .checkout-summary-product-description { + margin: -2px 0 10px; + color: rgba($black, .54); + font-size: 16px; + font-weight: 400; + line-height: 1.5; + + @include breakpoint(md-phone) { + font-size: 14px; + margin-bottom: 8px; + } + } + + .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 { + display: block; + padding: 10px 0 12px; + } + + .checkout-summary-label { + display: block; + min-width: 0; + margin-bottom: 2px; + } + + .checkout-summary-value { + display: block; + text-align: left; + } + } + + @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: 12px; + padding-top: 4px; + border-top: 0; +} + +#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..15b3cc7e --- /dev/null +++ b/site/layouts/404.html @@ -0,0 +1,30 @@ +{{ 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_tag" "h1" + "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 + ) + ) }} +
+
+
+
+
+ {{ partial "components/go-top-button.html" . }} +{{ 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..6e0a8c57 --- /dev/null +++ b/site/layouts/_partials/components/result-panel.html @@ -0,0 +1,63 @@ + + +{{ $title := .title }} +{{ $titleTag := .title_tag | default "h2" }} +{{ $mark := .mark }} +{{ $lines := .lines | default slice }} +{{ $action := .action }} + +
+ {{ with $mark }} + {{ $markText := .text | default .content | default "" }} + {{ $markClass := .icon_class | default "" }} + + {{ end }} + {{ if eq $titleTag "h1" }} +

{{ $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 }} +
+ +
+ {{ 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..fea13acf --- /dev/null +++ b/site/layouts/checkout-completed/single.html @@ -0,0 +1,29 @@ +{{ define "main" }} + {{ partial "components/navbar/navbar.html" . }} +
+
+
+
+
+ {{ partial "components/result-panel.html" (dict + "title" "Thank you for your purchase" + "title_tag" "h1" + "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 + ) + ) }} +
+
+
+
+
+ {{ partial "components/go-top-button.html" . }} +{{ end }} diff --git a/site/layouts/checkout/single.html b/site/layouts/checkout/single.html new file mode 100644 index 00000000..9a79cee4 --- /dev/null +++ b/site/layouts/checkout/single.html @@ -0,0 +1,279 @@ +{{ 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 }}