/** * UTM Order Display Helper * Displays order details in success modals using real API response structure. * * Real API response from GET /orders/{id}: * { * id: string, publicId: string, status: string, * clientDetails: { email, firstName, lastName }, * itemGroups: [{ * id: string, * productType: "DIGITAL"|"PHYSICAL", * shipmentDetails: { * strategyName: string, * strategyPayload: {}, * execution: { status: string, strategyPayload: { messageId? } | { trackingNumber? } } * }, * items: [{ id, name, units: [{ id, payload: {} }] }] * }] * } */ (function () { "use strict"; // Fill every element matching `selector` inside `root`. Webflow may duplicate fields // across collapsed accordions / instruction blocks — querySelector would only hit the first. function fillAll(root, selector, value) { if (!root) return; var nodes = root.querySelectorAll(selector); for (var i = 0; i < nodes.length; i++) { nodes[i].textContent = value; } } var OrderDisplay = { /** * Populate eSIM success modal with order details * @param {Object} orderDetails - Full order details from GET /orders/{id}/result * @param {string} simType - SIM type from UTM.orderState.simType */ displayESIMSuccess: function(orderDetails, simType) { var modal = document.querySelector("#modal-success-esim"); if (!modal) { return; } var client = orderDetails.clientDetails || {}; var itemGroup = (orderDetails.itemGroups || [])[0] || {}; var shipmentDetails = itemGroup.shipmentDetails || {}; var executionPayload = (shipmentDetails.execution || {}).strategyPayload || {}; // Message ID from CORE_EMAIL delivery execution var messageId = executionPayload.messageId || ""; // Order public ID or internal ID var orderId = orderDetails.publicId || orderDetails.id || ""; // Client info var fullName = [client.firstName, client.lastName].filter(Boolean).join(" "); // Units payload — eSIM data (QR, activation code, etc.) var item = ((itemGroup.items || [])[0]) || {}; var unit = ((item.units || [])[0]) || {}; var unitPayload = unit.payload || []; // Extract values from unitPayload array [{type, key, value}] var qrData = null; var phoneNumber = ""; var pin1 = ""; var pin2 = ""; var puk1 = ""; var puk2 = ""; var iccid = ""; if (Array.isArray(unitPayload)) { unitPayload.forEach(function(p) { if (!p) return; switch (p.key) { case "QR_CODE": qrData = p.value || null; break; case "PHONE_NUMBER": phoneNumber = p.value || ""; break; case "PIN1": pin1 = p.value || ""; break; case "PIN2": pin2 = p.value || ""; break; case "PUK1": puk1 = p.value || ""; break; case "PUK2": puk2 = p.value || ""; break; case "ICCID": iccid = p.value || ""; break; } }); } // Fill modal fields — fillAll handles duplicated bindings (same class in accordion + instructions). fillAll(modal, ".modal-esim_name", fullName); fillAll(modal, ".modal-esim_email", client.email || ""); fillAll(modal, ".modal-esim_order-id, .order-id", orderId); // messageId — confirmation that email was sent fillAll(modal, ".modal-esim_message-id, .esim-message-id", messageId); // SIM data fields (clear placeholders and set real values) fillAll(modal, ".modal-esim_number", phoneNumber); fillAll(modal, ".modal-esim_pin1", pin1); fillAll(modal, ".modal-esim_pin2", pin2); fillAll(modal, ".modal-esim_puk1", puk1); fillAll(modal, ".modal-esim_puk2", puk2); fillAll(modal, ".modal-esim_iccid", iccid); // Plan / price / type — from API response fillAll(modal, ".modal-esim_plan", item.name || ""); if (orderDetails.totalPrice != null) { fillAll(modal, ".modal-esim_price", String(orderDetails.totalPrice).replace(/\.0+$/, "") + " ₴"); } var t = (simType || "").toLowerCase(); var isPlasticType = t.indexOf("plastic") !== -1 || t.indexOf("new") !== -1 || t.indexOf("нова") !== -1; fillAll(modal, ".modal-esim_type-of-card, .modal-esim_type", isPlasticType ? "Пластикова SIM" : "eSIM"); // Wire up the install-eSIM button — OS-aware install URL. // iOS opens Settings via Apple's universal-link wrapper. // Android 13+ (Pixel) and One UI 5+ (Samsung) handle LPA: URIs natively. // Desktop has no native eSIM activation → button hidden, user scans the QR. var ua = navigator.userAgent || ""; var isIOS = /iPad|iPhone|iPod/.test(ua) || (ua.indexOf("Mac") !== -1 && navigator.maxTouchPoints > 1); var isAndroid = /Android/i.test(ua); var installUrl = ""; if (qrData) { if (isIOS) { installUrl = "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=" + encodeURIComponent(qrData); } else if (isAndroid) { // qrData arrives as LPA:1$$ — pass as-is. installUrl = qrData; } } var installBtn = modal.querySelector("#install-esim"); if (installBtn && installUrl) { installBtn.href = installUrl; // Android LPA: URI must NOT open in a new tab — needs same-frame navigation // for Settings intent to fire. Apple universal links work in either mode, // but keep them in new tab to preserve the success modal. if (isAndroid) { installBtn.removeAttribute("target"); installBtn.removeAttribute("rel"); } else { installBtn.target = "_blank"; installBtn.rel = "noopener noreferrer"; } installBtn.style.display = ""; } else if (installBtn) { installBtn.style.display = "none"; } // #guideLink lives inside the iOS guide block. Even on Android/desktop the user // may read the instruction here and tap the link from their iPhone — so it must always // resolve to the Apple universal link, regardless of the current device. var appleInstallUrl = qrData ? "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=" + encodeURIComponent(qrData) : ""; var guideLink = modal.querySelector("#guideLink"); if (guideLink && appleInstallUrl) { guideLink.href = appleInstallUrl; guideLink.target = "_blank"; guideLink.rel = "noopener noreferrer"; } // Generate QR code if (qrData) { var qrContainer = modal.querySelector("#esim-qr-container"); if (qrContainer) { qrContainer.innerHTML = ""; if (typeof QRCodeStyling !== "undefined") { var qrCode = new QRCodeStyling({ width: 300, height: 300, type: "svg", data: qrData, margin: 10, qrOptions: { errorCorrectionLevel: "M" }, dotsOptions: { color: "#000000", type: "square" }, cornersSquareOptions: { color: "#000000", type: "square" }, cornersDotOptions: { color: "#000000", type: "square" }, backgroundOptions: { color: "#ffffff" }, image: null }); qrCode.append(qrContainer); } else { } } else { } } }, /** * Populate Plastic SIM success modal with order details. * * Callable with `orderDetails = null` — in that case the modal is populated from * UTM.orderState.pendingSnapshot only. The caller typically paints with snapshot * first (for instant feedback after PAID), then re-calls with the API response * once /orders/{id} resolves. * * The Plastic success modal shares the .modal-esim_table-row layout with the * pre-payment summary in modals.js — fields are matched by their .modal-esim_table-label * text, mirroring updateSummary(). * * @param {Object|null} orderDetails - GET /orders/{id} response, or null * @param {string} simType - SIM type from UTM.orderState.simType */ displayPlasticSuccess: function(orderDetails, simType) { var modal = document.querySelector("#modal-plastic"); if (!modal) return; // Webflow templates have used both class names for this step: // - .modal-success (older) // - .is--payment-success (current, used by modalApi.open's isPaid branch) // Try current first so we never miss the rendered step on production. var successStep = modal.querySelector(".is--payment-success.is-active") || modal.querySelector(".modal-success.is-active") || modal.querySelector(".is--payment-success") || modal.querySelector(".modal-success") || modal; if (!successStep) return; var snap = (window.UTM && window.UTM.orderState && window.UTM.orderState.pendingSnapshot) || {}; var details = orderDetails || {}; var client = details.clientDetails || {}; var itemGroup = (details.itemGroups || [])[0] || {}; var shipmentDetails = itemGroup.shipmentDetails || {}; var executionPayload = (shipmentDetails.execution || {}).strategyPayload || {}; var item = ((itemGroup.items || [])[0]) || {}; var fullName = [client.firstName || snap.firstName, client.lastName || snap.lastName] .filter(Boolean).join(" "); var email = client.email || snap.email || ""; var orderId = details.publicId || details.id || ""; var trackingNumber = executionPayload.trackingNumber || ""; var planName = item.name || snap.planName || ""; var rawPrice = (details.totalPrice != null) ? details.totalPrice : snap.planPrice; var priceStr = (rawPrice != null && rawPrice !== "") ? String(rawPrice).replace(/\.0+$/, "") + " ₴" : ""; // Delivery: prefer API strategy, fall back to snapshot.deliveryType (pickup/courier). var strategyName = shipmentDetails.strategyName || (snap.deliveryType === "pickup" ? "NOVA_POST_BRANCH" : snap.deliveryType === "courier" ? "NOVA_POST_COURIER" : ""); var deliveryLabel = this.translateDeliveryType(strategyName); // The API's strategyPayload contains branchId (opaque) or the address string // we sent. For pickup we prefer the snapshot's human-readable branchName. var addressFromApi = (shipmentDetails.strategyPayload || {}).address || ""; var addressLine = (strategyName === "NOVA_POST_BRANCH") ? (snap.branchName || "") : (snap.address || addressFromApi || ""); // 1. Standard selectors (some Webflow templates use these directly). fillAll(successStep, ".modal-esim_name, .order-name", fullName); fillAll(successStep, ".modal-esim_email, .order-email", email); fillAll(successStep, ".order-number, .modal-esim_order-id", orderId); fillAll(successStep, ".order-tracking, .tracking-number", trackingNumber); fillAll(successStep, ".order-delivery-type", deliveryLabel); fillAll(successStep, ".modal-esim_plan", planName); if (priceStr) fillAll(successStep, ".modal-esim_price", priceStr); fillAll(successStep, ".modal-esim_type, .modal-esim_type-of-card", "Пластикова SIM"); // 2. Webflow summary-table rows. Layout is .modal-esim_table-row with a label // (.modal-esim_table-label) and a value cell (.modal-esim_table-item[1]). // Match the same label keywords updateSummary() uses to stay in sync. var rows = successStep.querySelectorAll(".modal-esim_table-row"); for (var i = 0; i < rows.length; i++) { var row = rows[i]; var labelEl = row.querySelector(".modal-esim_table-label"); var label = (labelEl && labelEl.textContent || "").trim(); var items = row.querySelectorAll(".modal-esim_table-item"); var valCell = (items[1] && items[1].querySelector("div")) || items[1]; if (!valCell) continue; if (label.includes("Замовник") || label.includes("Заказчик")) { valCell.textContent = fullName; } else if (label.includes("Електронна") || label.includes("Email") || label.includes("E-mail") || label.includes("пошта")) { valCell.textContent = email; } else if (label.includes("Тариф") || label.includes("План")) { valCell.textContent = planName; } else if (label.includes("Тип SIM") || label.includes("Тип сим")) { valCell.textContent = "Пластикова SIM"; } else if (label.includes("Місто") || label.includes("Город")) { valCell.textContent = snap.cityName || ""; } else if (label.includes("Доставка") || label.includes("Способ")) { valCell.textContent = deliveryLabel; } else if (label.includes("Адреса") || label.includes("Адрес")) { valCell.textContent = addressLine; row.style.display = addressLine ? "" : "none"; } else if (label.includes("відділення") || label.includes("Отделение")) { valCell.textContent = snap.branchName || ""; row.style.display = snap.branchName ? "" : "none"; } else if (label.includes("Загальна") || label.includes("вартість") || label.includes("Общая") || label.includes("стоимость")) { if (priceStr) valCell.textContent = priceStr; } else if (label.includes("Номер замовлення") || label.includes("Номер заказа")) { valCell.textContent = orderId; } else if (label.includes("ТТН") || label.includes("Tracking") || label.includes("Трекінг")) { valCell.textContent = trackingNumber; row.style.display = trackingNumber ? "" : "none"; } } }, /** * Main entry point — called from showOrderSuccess in modals. * @param {Object} orderDetails - Full order details from GET /orders/{id}/result * @param {string} simType - SIM type from UTM.orderState.simType (passed explicitly) */ displayOrderSuccess: function(orderDetails, simType) { if (!orderDetails) { return; } var sType = (simType || "").toLowerCase(); var isPlastic = sType.includes("plastic") || sType.includes("new") || sType.includes("нова") || sType === "p"; if (isPlastic) { this.displayPlasticSuccess(orderDetails, simType); } else { // Default to eSIM for "esim" or any unknown type this.displayESIMSuccess(orderDetails, simType); } }, /** * Translate delivery strategy name to Ukrainian * @param {string} strategyName - API strategyName: EMAIL | NOVA_POST_BRANCH | NOVA_POST_COURIER * @returns {string} */ translateDeliveryType: function(strategyName) { if (!strategyName) return ""; var translations = { "CORE_EMAIL": "Електронна пошта", "EMAIL": "Електронна пошта", "NOVA_POST_BRANCH": "Самовивіз з Нової Пошти", "NOVA_POST_COURIER": "Кур'єрська доставка Нової Пошти", // legacy UI values (used in modals updateSummary) "pickup": "Самовивіз з Нової Пошти", "courier": "Кур'єрська доставка Нової Пошти", "branch": "Самовивіз з Нової Пошти", "email": "Електронна пошта" }; return translations[strategyName] || strategyName; } }; // Export to global UTM namespace window.UTM = window.UTM || {}; window.UTM.OrderDisplay = OrderDisplay; })(); /** * UTM Order API Module * Handles order creation, payment flow, and SSE status tracking */ (function () { "use strict"; // ============================================ // Configuration & Setup // ============================================ function getApiBase() { if (typeof window !== 'undefined' && window.CONFIG && window.CONFIG.API_BASE) { return window.CONFIG.API_BASE; } if (typeof CONFIG !== 'undefined' && CONFIG.API_BASE) { return CONFIG.API_BASE; } return "https://api.utm.net.ua"; } // ============================================ // UUID v4 Generator // ============================================ function generateUUID() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0; var v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } // ============================================ // ============================================ // Error Code → Ukrainian Message Mapping // ============================================ // Actual error codes from API docs var ERROR_CODE_MAP = { "VALIDATION": "Помилка валідації даних.", "TOO_MANY_REQUESTS": "Ви робите забагато запитів. Спробуйте трохи пізніше.", "INTERNAL_SERVER_ERROR": "Внутрішня помилка сервера. Спробуйте ще раз.", "ORDERS_ORDER_NOT_FOUND": "Замовлення не знайдено.", "ORDERS_ORDER_PRODUCT_NOT_FOUND": "Обраний тариф не знайдено.", "ORDERS_ORDER_ITEM_UNAVAILABLE": "Товар тимчасово недоступний у вказаній кількості.", "ORDERS_ORDER_RESULT_EXPIRED": "Термін доступності результату замовлення вичерпано.", // SSE internal "SSE_PARSE_ERROR": "Помилка зв'язку з сервером." }; function mapErrorCode(code) { return (code && ERROR_CODE_MAP[code]) || null; } // ============================================ // Product SKU Mapping // ============================================ var PRODUCT_SKU_MAP = { // eSIM variants "eSIM_M": "SIM_ESI_INT_M", "eSIM_UNLIMITED": "SIM_ESI_INT_UNL", "eSIM_YEARLY": "SIM_ESI_ANN", // Plastic SIM variants "Plastic_M": "SIM_PLA_INT_M", "Plastic_UNLIMITED": "SIM_PLA_INT_UNL", "Plastic_YEARLY": "SIM_PLA_ANN" }; /** * Get product variant SKU based on simType and tariff plan */ function getProductVariantSku(simType, tariffPlan) { var normalizedSimType = (simType || "eSIM").toLowerCase().includes("esim") ? "eSIM" : "Plastic"; var planType = "M"; // default if (tariffPlan) { var plan = tariffPlan.toLowerCase(); if (plan.includes("безлім") || plan.includes("unlimited")) { planType = "UNLIMITED"; } else if (plan.includes("річн") || plan.includes("yearly") || plan.includes("year")) { planType = "YEARLY"; } else { planType = "M"; } } var sku = normalizedSimType + "_" + planType; return PRODUCT_SKU_MAP[sku] || "SIM_ESI_INT_M"; // fallback } // ============================================ // Order State Management // ============================================ var OrderAPI = { eventSource: null, /** * Create a new SIM order * @param {Object} orderData - Order data from the form * @returns {Promise} - Response with payment link */ createOrder: async function (orderData) { // Generate unique order ID on frontend var generatedId = generateUUID(); // Determine product variant SKU var productVariantSku = getProductVariantSku( orderData.simType, orderData.tariffPlan ); var isESIM = (orderData.simType || "").toLowerCase().includes("esim"); var phoneNumber = orderData.phone ? "+" + orderData.phone : ""; // Build request body according to API spec. // API requires phoneNumber inside clientDetails (Validation: // "clientDetails.phoneNumber should not be empty"). The Nova Post // strategies still expect a copy inside strategyPayload below — leaving // it only there triggers an API VALIDATION error. var requestBody = { generatedId: generatedId, productVariantSku: productVariantSku, clientDetails: { firstName: orderData.firstName || "", lastName: orderData.lastName || "", email: orderData.email || "", phoneNumber: phoneNumber }, meta: orderData.promocode ? { promocode: orderData.promocode } : {}, // Shipment details (filled below based on SIM type) shipmentDetails: null }; if (isESIM) { // eSIM delivered via email — no payload needed requestBody.shipmentDetails = { strategyName: "CORE_EMAIL", strategyPayload: {} }; } else if (orderData.delivery) { // Plastic SIM — Nova Post delivery var delivery = orderData.delivery; if (delivery.type === "courier") { requestBody.shipmentDetails = { strategyName: "NOVA_POST_COURIER", strategyPayload: { phoneNumber: phoneNumber, cityId: delivery.cityId || "", streetId: delivery.streetId || "", house: delivery.house || "" } }; } else if (delivery.type === "pickup") { requestBody.shipmentDetails = { strategyName: "NOVA_POST_BRANCH", strategyPayload: { phoneNumber: phoneNumber, branchId: delivery.branchId || "" } }; } } try { var response = await fetch(getApiBase() + "/orders/sims", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }); if (!response.ok) { // Rate limiting — show user-friendly Ukrainian message if (response.status === 429) { var rateLimitError = new Error("Ви робите забагато запитів. Спробуйте трохи пізніше."); rateLimitError.status = 429; rateLimitError.title = "Забагато запитів"; rateLimitError.details = {}; rateLimitError.validationErrors = []; throw rateLimitError; } var errorData = await response.json().catch(function() { return { message: "Помилка створення замовлення" }; }); // Validation errors structure: payload.fields[{name, code, message}] var validationFields = (errorData.payload && errorData.payload.fields) || []; var errorCode = errorData.code || null; var mappedMsg = mapErrorCode(errorCode); var error = new Error(mappedMsg || errorData.message || "Помилка створення замовлення: " + response.status); error.status = response.status; error.code = errorCode; error.title = errorData.title || null; error.details = errorData; error.validationErrors = validationFields; // [{name, code, message}] throw error; } var data = await response.json(); // Attach generatedId so processOrder can use it for /status and /result data.generatedId = generatedId; return data; } catch (error) { // ✅ FIX #7: Preserve error details for UI display if (!error.status) { error.status = 0; // Network error error.details = { message: error.message }; error.validationErrors = []; } throw error; } }, /** * Start listening to order status via SSE * @param {string} orderId - The order ID from API response * @param {Function} onStatusChange - Callback for status updates * @param {Object} [options] * @param {number} [options.timeoutMs=90000] - No-progress timeout: if no terminal status arrives in this window, emit {status:"TIMEOUT"}. */ startStatusListener: function (orderId, onStatusChange, options) { var self = this; var timeoutMs = (options && typeof options.timeoutMs === "number") ? options.timeoutMs : 90000; var timeoutTimer = null; function clearProgressTimer() { if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; } } function stopListening() { clearProgressTimer(); if (self.eventSource) { self.eventSource.close(); self.eventSource = null; } } var url = getApiBase() + "/orders/" + orderId + "/status"; self.eventSource = new EventSource(url); // Start no-progress timer immediately. Cancelled on terminal status (SENT/COMPLETED/FAILED) // or when caller calls stopListening. Fires once if backend goes silent (e.g. payment // provider webhook broken — saw this with City24). timeoutTimer = setTimeout(function () { stopListening(); if (onStatusChange) onStatusChange({ status: "TIMEOUT", code: "TIMEOUT", message: "Дякую за замовлення. Ваш платіж ще в обробці, згодом ви отримаєте деталі на вказаний Email." }); }, timeoutMs); self.eventSource.onmessage = function (event) { try { var eventData = JSON.parse(event.data); // Cancel no-progress timer once a terminal status arrives. var status = eventData.status || eventData.type; if (status === "SENT" || status === "COMPLETED" || status === "FAILED") { clearProgressTimer(); } if (onStatusChange) onStatusChange(eventData); } catch (e) { // Non-JSON message from server (e.g. "Validation failed.") var raw = (event.data || "").trim(); stopListening(); if (onStatusChange) onStatusChange({ status: "FAILED", code: "VALIDATION", message: "Помилка валідації даних. Перевірте правильність введених даних." }); } }; self.eventSource.onerror = function (error) { stopListening(); var raw = (error && error.data) ? (error.data + "").trim() : null; var errorPayload = null; if (raw) { try { errorPayload = JSON.parse(raw); } catch (e) { /* plain text */ } } var code = (errorPayload && errorPayload.code) || null; var message = (errorPayload && errorPayload.message) || null; if (!message) { if (!raw) { message = "Не вдалося відстежити статус замовлення. Перевірте email для підтвердження."; } else if (raw === "Validation failed." || code === "VALIDATION") { message = "Помилка валідації даних. Перевірте правильність введених даних."; code = code || "VALIDATION"; } else { message = raw; } } if (onStatusChange) onStatusChange({ status: "FAILED", code: code || "SSE_ERROR", message: message }); }; return stopListening; }, /** * Fetch order details * @param {string} orderId - The order ID * @returns {Promise} - Order details */ getOrderDetails: async function (orderId) { try { var response = await fetch(getApiBase() + "/orders/" + orderId, { method: "GET", headers: { "Content-Type": "application/json" } }); if (!response.ok) { throw new Error("Не вдалося отримати деталі замовлення: " + response.status); } var data = await response.json(); return data; } catch (error) { throw error; } }, /** * Handle the complete order flow * @param {Object} orderData - Order data from form * @param {Object} callbacks - Callbacks for different events */ processOrder: async function (orderData, callbacks) { var self = this; try { // 1. Create the order var orderResponse = await this.createOrder(orderData); if (callbacks.onOrderCreated) { callbacks.onOrderCreated(orderResponse); } // Skip SSE if page is about to redirect to payment provider. // EventSource would error on unload and falsely trigger onError, // which clears the pending order from localStorage. SSE is // re-established by resumePendingOrder on return. if (orderResponse.paymentUrl) { return; } // 2. Use generatedId (UUID) for /status and /result endpoints var orderId = orderResponse.generatedId; var stopListening = this.startStatusListener( orderId, // API returns publicId function (eventData) { // Handle different event types var eventType = eventData.status || eventData.type; switch (eventType) { case "PAID": if (callbacks.onPaid) { // Pass stopListening so caller can stop SSE if PAID is the final event (e.g. plastic SIM) callbacks.onPaid(eventData, stopListening); } break; case "SENT": case "COMPLETED": // Order has been sent/processed // Stop SSE immediately to prevent reconnect loop // (server closes connection after SENT which triggers onerror) stopListening(); // Fetch full order details self.getOrderDetails(orderId) .then(function (orderDetails) { if (callbacks.onSent) { callbacks.onSent(orderDetails); } }) .catch(function (error) { if (callbacks.onError) { callbacks.onError(error); } }); break; case "FAILED": case "ERROR": if (callbacks.onError) { callbacks.onError(eventData); } stopListening(); break; case "TIMEOUT": // No-progress timeout — payment likely still processing on provider side. // Surface as pending UI, not error. Listener already stopped itself. if (callbacks.onTimeout) { callbacks.onTimeout(eventData); } else if (callbacks.onError) { callbacks.onError(eventData); } break; default: // Other status updates if (callbacks.onStatusUpdate) { callbacks.onStatusUpdate(eventData); } } } ); } catch (error) { if (callbacks.onError) { callbacks.onError(error); } } } }; // Export to global UTM namespace window.UTM = window.UTM || {}; window.UTM.OrderAPI = OrderAPI; })(); (function () { "use strict"; // ============================================ // 1. Core & Utilities // ============================================ var UTM = window.UTM = window.UTM || {}; var CONFIG = { API_BASE: "https://api.utm.net.ua", USE_PROXY: false, // Set to true to use a proxy if CORS is blocked on staging SEARCH_MIN_CHARS: 2, DEBOUNCE_DELAY: 400 }; function getConfig(key) { if (typeof window !== 'undefined' && window.CONFIG && window.CONFIG[key] !== undefined) { return window.CONFIG[key]; } return CONFIG[key]; } // Nova Post autocomplete styles (.np-select-*, .np-item) live in the Webflow // Custom Code