From 9d374f3ba19dc452f2d3545acedf96c45f3747bd Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 08:12:02 +0300 Subject: [PATCH] Revert "Add interactive purchase configurator flow to mini app" --- miniapp/index.html | 2152 -------------------------------------------- 1 file changed, 2152 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index c8524f68..b1f33202 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -705,295 +705,6 @@ color: #fff; } - /* Purchase Configurator */ - .purchase-card { - display: flex; - flex-direction: column; - gap: 16px; - } - - .purchase-card .card-header { - align-items: flex-start; - } - - .purchase-card .card-description { - font-size: 14px; - color: var(--text-secondary); - margin-top: 6px; - line-height: 1.5; - } - - .purchase-section { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; - border-radius: var(--radius); - background: var(--bg-secondary); - } - - .purchase-section-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - } - - .purchase-section-title { - font-size: 16px; - font-weight: 600; - display: flex; - align-items: center; - gap: 8px; - } - - .purchase-section-subtitle { - font-size: 13px; - color: var(--text-secondary); - line-height: 1.5; - } - - .purchase-options-grid { - display: grid; - gap: 10px; - } - - .purchase-option { - border-radius: var(--radius); - border: 1.5px solid var(--border-color); - padding: 12px; - background: rgba(255, 255, 255, 0.02); - display: flex; - flex-direction: column; - gap: 6px; - transition: all 0.25s ease; - cursor: pointer; - } - - .purchase-option:hover:not(.disabled) { - border-color: var(--primary); - box-shadow: var(--shadow-sm); - } - - .purchase-option.disabled { - opacity: 0.6; - cursor: not-allowed; - } - - .purchase-option.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.1); - box-shadow: var(--shadow-sm); - } - - .purchase-option-title { - font-weight: 600; - font-size: 15px; - } - - .purchase-option-meta { - font-size: 13px; - color: var(--text-secondary); - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: baseline; - } - - .purchase-option-meta strong { - color: var(--text-primary); - font-weight: 600; - } - - .purchase-option-description { - font-size: 12px; - color: var(--text-secondary); - } - - .purchase-option-tag { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; - background: rgba(var(--primary-rgb), 0.12); - color: var(--primary); - } - - .purchase-summary { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; - border-radius: var(--radius); - background: var(--bg-secondary); - } - - .purchase-summary-row { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 14px; - } - - .purchase-summary-row strong { - font-size: 16px; - } - - .purchase-summary-old-price { - text-decoration: line-through; - color: var(--text-secondary); - font-size: 13px; - } - - .purchase-summary-discount { - font-size: 12px; - color: var(--success); - } - - .purchase-actions { - display: flex; - flex-direction: column; - gap: 10px; - } - - .purchase-hint { - font-size: 13px; - color: var(--text-secondary); - line-height: 1.5; - } - - .purchase-warning { - color: var(--danger); - font-weight: 600; - } - - .purchase-error { - padding: 12px; - border-radius: var(--radius); - background: rgba(var(--danger-rgb), 0.1); - color: var(--danger); - font-size: 13px; - } - - .purchase-loading { - display: flex; - flex-direction: column; - gap: 8px; - padding: 20px; - align-items: center; - justify-content: center; - text-align: center; - color: var(--text-secondary); - font-size: 14px; - } - - .purchase-loading .spinner { - width: 32px; - height: 32px; - } - - .purchase-tag-list { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - - .purchase-inline-list { - display: flex; - flex-wrap: wrap; - gap: 6px; - font-size: 13px; - color: var(--text-secondary); - } - - .purchase-inline-list span::after { - content: '·'; - margin-left: 6px; - } - - .purchase-inline-list span:last-child::after { - content: ''; - margin: 0; - } - - .purchase-empty-state { - font-size: 14px; - color: var(--text-secondary); - padding: 12px 0; - text-align: center; - } - - .purchase-stepper { - display: inline-flex; - align-items: center; - border-radius: var(--radius); - border: 1px solid var(--border-color); - overflow: hidden; - background: rgba(255, 255, 255, 0.02); - } - - .purchase-stepper button { - width: 40px; - height: 36px; - border: none; - background: none; - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.2s ease; - } - - .purchase-stepper button:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .purchase-stepper button:hover:not(:disabled) { - background: rgba(var(--primary-rgb), 0.1); - } - - .purchase-stepper-value { - min-width: 48px; - text-align: center; - font-weight: 600; - font-size: 15px; - } - - .purchase-summary-breakdown { - display: flex; - flex-direction: column; - gap: 8px; - font-size: 13px; - color: var(--text-secondary); - } - - .purchase-summary-breakdown strong { - color: var(--text-primary); - } - - .purchase-summary-breakdown .purchase-summary-old-price { - margin-left: 6px; - } - - :root[data-theme="dark"] .purchase-option { - background: rgba(15, 23, 42, 0.35); - } - - :root[data-theme="dark"] .purchase-section { - background: rgba(15, 23, 42, 0.45); - } - - :root[data-theme="dark"] .purchase-summary { - background: rgba(15, 23, 42, 0.45); - } - .promo-offers { display: flex; flex-direction: column; @@ -3639,116 +3350,6 @@ - - -
@@ -4178,26 +3779,6 @@ let promoOfferTimerHandle = null; let referralListExpanded = false; let referralCopyResetHandle = null; - let purchaseConfiguratorData = null; - let purchaseConfiguratorLoading = false; - let purchaseConfiguratorError = null; - let purchaseConfiguratorRequest = null; - let purchaseConfiguratorSubmitting = false; - let purchaseConfiguratorSelections = { - period: null, - traffic: null, - servers: new Set(), - devices: null, - }; - const purchaseConfiguratorEndpoints = [ - '/miniapp/subscription/configurator', - '/miniapp/subscription/purchase', - '/miniapp/subscription/constructor', - ]; - const purchaseSubmissionEndpoints = [ - '/miniapp/subscription/purchase', - '/miniapp/subscription/constructor', - ]; if (typeof tg.expand === 'function') { tg.expand(); @@ -4396,44 +3977,6 @@ 'topup.status.retry': 'Try again', 'topup.done': 'Done', 'button.buy_subscription': 'Buy Subscription', - 'purchase.title': 'Configure your subscription', - 'purchase.subtitle': 'Select period, traffic, servers and devices before checkout.', - 'purchase.loading': 'Loading available plans…', - 'purchase.period.title': 'Subscription period', - 'purchase.period.subtitle': 'Choose how long the subscription will last.', - 'purchase.period.empty': 'No subscription periods are available at the moment.', - 'purchase.traffic.title': 'Traffic package', - 'purchase.traffic.subtitle': 'Pick a monthly traffic amount that suits you.', - 'purchase.traffic.empty': 'Traffic packages are not available right now.', - 'purchase.servers.title': 'Servers', - 'purchase.servers.subtitle': 'Select the regions that will be available in your subscription.', - 'purchase.servers.empty': 'No servers are available at the moment.', - 'purchase.devices.title': 'Devices', - 'purchase.devices.subtitle': 'Set how many devices can connect simultaneously.', - 'purchase.total': 'Total', - 'purchase.action.buy': 'Buy subscription', - 'purchase.action.processing': 'Processing…', - 'purchase.action.topup': 'Top up balance', - 'purchase.balance.insufficient': 'You need {amount} more to complete the purchase.', - 'purchase.balance.sufficient': 'Available balance: {amount}.', - 'purchase.success.title': 'Subscription purchased', - 'purchase.success.message': 'Your subscription has been purchased successfully.', - 'purchase.error.generic': 'Unable to load purchase options. Please try again later.', - 'purchase.error.unauthorized': 'Authorization failed. Please reopen the mini app from Telegram.', - 'purchase.error.validation': 'Please select all required options before purchase.', - 'purchase.summary.period': 'Period', - 'purchase.summary.traffic': 'Traffic', - 'purchase.summary.servers': 'Servers', - 'purchase.summary.devices': 'Devices', - 'purchase.summary.discount': 'Discount applied: {value}', - 'purchase.server.meta.range': '{min}–{max} regions can be selected.', - 'purchase.server.meta.required': 'Select at least {count} region.', - 'purchase.server.meta.single': 'The subscription includes one region.', - 'purchase.server.meta.selected': '{count} selected', - 'purchase.devices.included': '{count} devices are included in the plan.', - 'purchase.devices.additional': '{count} additional devices selected.', - 'purchase.fixed.traffic': 'Traffic is fixed at {value}.', - 'purchase.fixed.servers': 'Servers are fixed for this plan.', 'card.balance.title': 'Balance', 'subscription_settings.title': 'Subscription settings', 'subscription_settings.summary.servers': 'Servers: {count}', @@ -4715,44 +4258,6 @@ 'topup.status.retry': 'Повторить попытку', 'topup.done': 'Готово', 'button.buy_subscription': 'Купить подписку', - 'purchase.title': 'Настройте подписку', - 'purchase.subtitle': 'Выберите период, трафик, серверы и устройства перед оплатой.', - 'purchase.loading': 'Загружаем варианты подписок…', - 'purchase.period.title': 'Период подписки', - 'purchase.period.subtitle': 'Выберите удобную длительность подписки.', - 'purchase.period.empty': 'Периоды подписки временно недоступны.', - 'purchase.traffic.title': 'Пакет трафика', - 'purchase.traffic.subtitle': 'Выберите подходящий месячный лимит трафика.', - 'purchase.traffic.empty': 'Пакеты трафика недоступны.', - 'purchase.servers.title': 'Серверы', - 'purchase.servers.subtitle': 'Выберите регионы, которые будут доступны в подписке.', - 'purchase.servers.empty': 'Нет доступных серверов.', - 'purchase.devices.title': 'Устройства', - 'purchase.devices.subtitle': 'Сколько устройств будет подключаться одновременно?', - 'purchase.total': 'Итого', - 'purchase.action.buy': 'Оформить подписку', - 'purchase.action.processing': 'Оформляем…', - 'purchase.action.topup': 'Пополнить баланс', - 'purchase.balance.insufficient': 'Не хватает {amount} для оформления подписки.', - 'purchase.balance.sufficient': 'Доступно на балансе: {amount}.', - 'purchase.success.title': 'Подписка оформлена', - 'purchase.success.message': 'Подписка успешно оформлена.', - 'purchase.error.generic': 'Не удалось загрузить варианты покупки. Попробуйте позже.', - 'purchase.error.unauthorized': 'Не удалось пройти авторизацию. Откройте мини-приложение из Telegram.', - 'purchase.error.validation': 'Пожалуйста, выберите все параметры перед покупкой.', - 'purchase.summary.period': 'Период', - 'purchase.summary.traffic': 'Трафик', - 'purchase.summary.servers': 'Серверы', - 'purchase.summary.devices': 'Устройства', - 'purchase.summary.discount': 'Применена скидка: {value}', - 'purchase.server.meta.range': 'Можно выбрать от {min} до {max} регионов.', - 'purchase.server.meta.required': 'Выберите как минимум {count} регион.', - 'purchase.server.meta.single': 'В подписку входит один регион.', - 'purchase.server.meta.selected': 'Выбрано: {count}', - 'purchase.devices.included': 'В тариф входит {count} устройств.', - 'purchase.devices.additional': 'Дополнительно выбрано устройств: {count}.', - 'purchase.fixed.traffic': 'Трафик фиксирован: {value}.', - 'purchase.fixed.servers': 'Серверы фиксированы для этого тарифа.', 'card.balance.title': 'Баланс', 'subscription_settings.title': 'Настройка подписки', 'subscription_settings.summary.servers': 'Серверов: {count}', @@ -5797,11 +5302,9 @@ applyTranslations(); if (userData) { renderUserData(); - renderPurchaseConfiguratorCard(); } else { updateConnectButtonLabel(); } - updatePurchaseSubmitButtonState(); renderApps(); updateActionButtons(); } @@ -5937,18 +5440,6 @@ userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; userData.referral = userData.referral || null; - purchaseConfiguratorData = null; - purchaseConfiguratorError = null; - purchaseConfiguratorLoading = false; - purchaseConfiguratorRequest = null; - purchaseConfiguratorSubmitting = false; - purchaseConfiguratorSelections = { - period: null, - traffic: null, - servers: new Set(), - devices: null, - }; - const normalizedPurchaseUrl = normalizeUrl( userData.subscription_purchase_url || userData.subscriptionPurchaseUrl @@ -5956,11 +5447,6 @@ subscriptionPurchaseUrl = normalizedPurchaseUrl; userData.subscriptionPurchaseUrl = normalizedPurchaseUrl || null; - const configuratorSource = getPurchaseConfiguratorSource(); - if (configuratorSource) { - purchaseConfiguratorData = normalizePurchaseConfiguratorData(configuratorSource); - } - if (userData.branding) { applyBrandingOverrides(userData.branding); } @@ -5991,7 +5477,6 @@ detectPlatform(); setActivePlatformButton(); refreshAfterLanguageChange(); - renderPurchaseConfiguratorCard(); animateCardsOnce(); @@ -7359,1628 +6844,6 @@ return template.replace('{count}', String(normalized)); } - function readField(object, keys) { - if (!object || typeof object !== 'object') { - return undefined; - } - for (const key of keys) { - if (Object.prototype.hasOwnProperty.call(object, key)) { - return object[key]; - } - const snake = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - if (Object.prototype.hasOwnProperty.call(object, snake)) { - return object[snake]; - } - const camel = key.replace(/_([a-z])/g, (_, chr) => chr.toUpperCase()); - if (Object.prototype.hasOwnProperty.call(object, camel)) { - return object[camel]; - } - } - return undefined; - } - - function toArray(value) { - if (Array.isArray(value)) { - return value.filter(item => item !== undefined && item !== null); - } - if (value && typeof value === 'object') { - return Object.values(value).filter(item => item !== undefined && item !== null); - } - return []; - } - - function applyPercentDiscount(amount, percent) { - const base = coercePositiveInt(amount, 0) || 0; - const rawPercent = coercePositiveInt(percent, 0) || 0; - const clampedPercent = Math.max(0, Math.min(100, rawPercent)); - if (base <= 0 || clampedPercent <= 0) { - return { base, discounted: base, discount: 0, percent: 0 }; - } - const discount = Math.floor(base * clampedPercent / 100); - const discounted = Math.max(0, base - discount); - return { base, discounted, discount, percent: clampedPercent }; - } - - function getPurchaseConfiguratorSource() { - if (!userData || typeof userData !== 'object') { - return null; - } - const candidates = [ - userData.purchase_config, - userData.purchaseConfig, - userData.subscription_purchase_config, - userData.subscriptionPurchaseConfig, - userData.subscription_constructor, - userData.subscriptionConstructor, - userData.constructor, - ]; - for (const candidate of candidates) { - if (candidate && typeof candidate === 'object') { - return candidate; - } - } - return null; - } - - function normalizePurchaseConfiguratorData(raw) { - if (!raw || typeof raw !== 'object') { - return null; - } - - const currency = String(readField(raw, ['currency', 'currencyCode', 'currency_code']) - || userData?.balance_currency - || 'RUB').toUpperCase(); - - const config = { - currency, - periods: [], - traffic: { - mode: 'fixed', - options: [], - defaultOption: null, - fixedValue: null, - fixedLabel: '', - }, - servers: { - mode: 'fixed', - options: [], - min: 0, - max: 0, - defaultSelected: new Set(), - }, - devices: { - min: 0, - max: 0, - step: 1, - included: 0, - pricePerDeviceKopeks: 0, - defaultValue: 0, - options: [], - }, - promoGroup: userData?.promo_group || null, - promoOffers: Array.isArray(userData?.promo_offers) ? userData.promo_offers : [], - promoOfferPercent: coercePositiveInt(readField(raw, ['promo_offer_discount_percent', 'promoOfferDiscountPercent']), - coercePositiveInt(userData?.user?.promo_offer_discount_percent, 0)) || 0, - balanceKopeks: coercePositiveInt(userData?.balance_kopeks, 0) || 0, - }; - - const periodsRaw = readField(raw, ['periods', 'available_periods', 'options']); - config.periods = normalizePeriodOptions(periodsRaw, currency); - - const trafficRaw = readField(raw, ['traffic', 'traffic_options', 'trafficOptions', 'trafficPackages']); - config.traffic = normalizeTrafficConfig(trafficRaw, currency); - - const serversRaw = readField(raw, ['servers', 'server_options', 'serverOptions', 'squads', 'countries']); - config.servers = normalizeServersConfig(serversRaw, currency); - - const devicesRaw = readField(raw, ['devices', 'device_options', 'deviceOptions']); - config.devices = normalizeDevicesConfig(devicesRaw, currency); - - return config; - } - - function normalizePeriodOptions(source, currency) { - const options = toArray(source); - const result = []; - options.forEach((option, index) => { - const normalized = normalizePeriodOption(option, currency); - if (!normalized) { - return; - } - if (!normalized.id) { - normalized.id = `period-${index}`; - } - result.push(normalized); - }); - return result; - } - - function normalizePeriodOption(option, currency) { - if (!option || typeof option !== 'object') { - return null; - } - - const days = coercePositiveInt(readField(option, ['days', 'period_days', 'value']), null); - const rawMonths = coercePositiveInt(readField(option, ['months', 'months_count', 'monthsCount']), null); - const months = rawMonths || (days ? Math.max(1, Math.round(days / 30)) : null); - const id = readField(option, ['id', 'uuid', 'key', 'value']) || (days !== null ? `period-${days}` : null); - const label = readField(option, ['label', 'title', 'name']) - || (days ? `${days} ${t('units.days') || 'days'}` : null); - const description = readField(option, ['description', 'subtitle', 'hint']) || null; - - const pricePerMonth = coercePositiveInt(readField(option, [ - 'price_per_month_kopeks', - 'pricePerMonthKopeks', - 'monthly_price_kopeks', - 'monthlyPriceKopeks', - 'price_per_month', - ]), null); - - let totalPrice = coercePositiveInt(readField(option, [ - 'total_price_kopeks', - 'totalPriceKopeks', - 'price_kopeks', - 'priceKopeks', - 'price', - 'cost_kopeks', - 'costKopeks', - ]), null); - - if (totalPrice === null && pricePerMonth !== null && months !== null) { - totalPrice = pricePerMonth * months; - } - - const discountedTotal = coercePositiveInt(readField(option, [ - 'discounted_total_price_kopeks', - 'discountedTotalPriceKopeks', - 'discounted_price_kopeks', - 'discountedPriceKopeks', - 'final_price_kopeks', - 'finalPriceKopeks', - 'final_price', - ]), null); - - const discountPercent = coercePositiveInt(readField(option, ['discount_percent', 'discountPercent']), 0) || 0; - const discountValue = coercePositiveInt(readField(option, ['discount_value_kopeks', 'discountValueKopeks']), null); - - return { - id: String(id || `period-${Math.random().toString(36).slice(2)}`), - days, - months, - priceKopeks: totalPrice !== null ? totalPrice : 0, - pricePerMonthKopeks: pricePerMonth, - discountedPriceKopeks: discountedTotal !== null - ? discountedTotal - : (totalPrice !== null ? applyPercentDiscount(totalPrice, discountPercent).discounted : totalPrice), - discountPercent, - discountValueKopeks: discountValue, - label, - description, - currency, - raw: option, - }; - } - - function normalizeTrafficConfig(raw, currency) { - if (!raw || typeof raw !== 'object') { - return { - mode: 'fixed', - options: [], - defaultOption: null, - fixedValue: null, - fixedLabel: '', - }; - } - - const modeRaw = String(readField(raw, ['mode', 'type', 'selection'])) || ''; - const normalizedMode = modeRaw.toLowerCase(); - const isFixed = normalizedMode === 'fixed' || normalizedMode === 'static' || normalizedMode === 'disabled'; - const options = []; - - toArray(readField(raw, ['options', 'packages', 'values'])).forEach((option, index) => { - if (!option || typeof option !== 'object') { - return; - } - const value = coercePositiveInt(readField(option, ['value', 'gb', 'traffic', 'limit_gb', 'limitGb']), 0); - const label = readField(option, ['label', 'title', 'name']) - || (value === 0 ? t('values.unlimited') : `${value} ${t('units.gb')}`); - const pricePerMonth = coercePositiveInt(readField(option, [ - 'price_per_month_kopeks', - 'pricePerMonthKopeks', - 'monthly_price_kopeks', - 'monthlyPriceKopeks', - ]), null); - let totalPrice = coercePositiveInt(readField(option, [ - 'total_price_kopeks', - 'totalPriceKopeks', - 'price_kopeks', - 'priceKopeks', - 'price', - ]), null); - if (totalPrice === null && pricePerMonth !== null && purchaseConfiguratorSelections?.period?.months) { - totalPrice = pricePerMonth * purchaseConfiguratorSelections.period.months; - } - const discountedTotal = coercePositiveInt(readField(option, [ - 'discounted_total_price_kopeks', - 'discountedTotalPriceKopeks', - 'discounted_price_kopeks', - 'discountedPriceKopeks', - 'final_price_kopeks', - 'finalPriceKopeks', - ]), null); - const discountPercent = coercePositiveInt(readField(option, ['discount_percent', 'discountPercent']), 0) || 0; - options.push({ - id: readField(option, ['id', 'uuid', 'key']) || `traffic-${index}`, - value, - label, - pricePerMonthKopeks: pricePerMonth, - priceKopeks: totalPrice !== null ? totalPrice : 0, - discountedPriceKopeks: discountedTotal !== null - ? discountedTotal - : (totalPrice !== null ? applyPercentDiscount(totalPrice, discountPercent).discounted : totalPrice), - discountPercent, - raw: option, - currency, - }); - }); - - const defaultOptionValue = coercePositiveInt(readField(raw, ['default_value', 'defaultValue', 'current']), null); - const fixedValue = coercePositiveInt(readField(raw, ['fixed_value', 'fixedValue']), null); - const fixedLabel = readField(raw, ['fixed_label', 'fixedLabel']) || ''; - - return { - mode: isFixed ? 'fixed' : 'selectable', - options, - defaultOption: options.find(opt => opt.value === defaultOptionValue) || options[0] || null, - fixedValue: fixedValue !== null ? fixedValue : (isFixed ? defaultOptionValue : null), - fixedLabel, - raw, - }; - } - - function normalizeServersConfig(raw, currency) { - const defaults = { - mode: 'fixed', - options: [], - min: 0, - max: 0, - defaultSelected: new Set(), - raw, - }; - if (!raw || typeof raw !== 'object') { - return defaults; - } - - const modeRaw = String(readField(raw, ['mode', 'type', 'selection'])) || ''; - const normalizedMode = modeRaw.toLowerCase(); - const isSelectable = normalizedMode === 'selectable' || normalizedMode === 'multi' || normalizedMode === 'multiple'; - const min = coercePositiveInt(readField(raw, ['min', 'min_selected', 'minSelected']), 0) || 0; - const max = coercePositiveInt(readField(raw, ['max', 'max_selected', 'maxSelected']), 0) || 0; - - const options = []; - const defaultSelected = new Set(); - - toArray(readField(raw, ['options', 'available', 'servers', 'items'])).forEach((option, index) => { - if (!option || typeof option !== 'object') { - return; - } - const uuid = readField(option, ['uuid', 'id', 'key', 'value']) || `server-${index}`; - const name = readField(option, ['name', 'label', 'title']) || String(uuid); - const pricePerMonth = coercePositiveInt(readField(option, [ - 'price_per_month_kopeks', - 'pricePerMonthKopeks', - 'monthly_price_kopeks', - 'monthlyPriceKopeks', - ]), null); - let totalPrice = coercePositiveInt(readField(option, [ - 'total_price_kopeks', - 'totalPriceKopeks', - 'price_kopeks', - 'priceKopeks', - 'price', - ]), null); - const discountPercent = coercePositiveInt(readField(option, ['discount_percent', 'discountPercent']), 0) || 0; - const discountedTotal = coercePositiveInt(readField(option, [ - 'discounted_total_price_kopeks', - 'discountedTotalPriceKopeks', - 'discounted_price_kopeks', - 'discountedPriceKopeks', - 'final_price_kopeks', - 'finalPriceKopeks', - ]), null); - if (totalPrice === null && pricePerMonth !== null && purchaseConfiguratorSelections?.period?.months) { - totalPrice = pricePerMonth * purchaseConfiguratorSelections.period.months; - } - const isIncluded = readField(option, ['included', 'is_included', 'isIncluded']) === true - || coercePositiveInt(readField(option, ['price_kopeks', 'priceKopeks', 'price']), null) === 0; - const isDefault = readField(option, ['is_default', 'default', 'selected']) === true - || isIncluded; - if (isDefault) { - defaultSelected.add(String(uuid)); - } - const isAvailable = readField(option, ['is_available', 'available', 'enabled']) !== false; - options.push({ - id: String(uuid), - name, - pricePerMonthKopeks: pricePerMonth, - priceKopeks: totalPrice !== null ? totalPrice : 0, - discountedPriceKopeks: discountedTotal !== null - ? discountedTotal - : (totalPrice !== null ? applyPercentDiscount(totalPrice, discountPercent).discounted : totalPrice), - discountPercent, - isIncluded, - isAvailable, - raw: option, - currency, - }); - }); - - return { - mode: isSelectable ? 'selectable' : 'fixed', - options, - min, - max, - defaultSelected, - raw, - }; - } - - function normalizeDevicesConfig(raw, currency) { - const defaults = { - min: 0, - max: 0, - step: 1, - included: 0, - pricePerDeviceKopeks: 0, - defaultValue: 0, - options: [], - raw, - currency, - }; - if (!raw || typeof raw !== 'object') { - return defaults; - } - - const min = coercePositiveInt(readField(raw, ['min', 'min_devices', 'minDevices']), 0) || 0; - const max = coercePositiveInt(readField(raw, ['max', 'max_devices', 'maxDevices']), 0) || 0; - const step = coercePositiveInt(readField(raw, ['step', 'step_size', 'stepSize']), 1) || 1; - const included = coercePositiveInt(readField(raw, ['included', 'included_devices', 'includedDevices', 'base']), 0) || 0; - const pricePerDevice = coercePositiveInt(readField(raw, ['price_per_device_kopeks', 'pricePerDeviceKopeks', 'price_per_device']), 0) || 0; - const defaultValue = coercePositiveInt(readField(raw, ['default', 'default_value', 'current', 'value']), null); - - const options = []; - toArray(readField(raw, ['options', 'values'])).forEach((option, index) => { - if (!option || typeof option !== 'object') { - return; - } - const value = coercePositiveInt(readField(option, ['value', 'devices', 'count']), null); - if (value === null) { - return; - } - const price = coercePositiveInt(readField(option, ['price_kopeks', 'priceKopeks', 'price', 'total_price_kopeks', 'totalPriceKopeks']), null); - const discountPercent = coercePositiveInt(readField(option, ['discount_percent', 'discountPercent']), 0) || 0; - const discounted = coercePositiveInt(readField(option, ['discounted_price_kopeks', 'discountedPriceKopeks', 'final_price_kopeks', 'finalPriceKopeks']), null); - options.push({ - id: readField(option, ['id', 'uuid']) || `devices-${index}`, - value, - priceKopeks: price !== null ? price : Math.max(0, value - included) * pricePerDevice, - discountedPriceKopeks: discounted !== null - ? discounted - : applyPercentDiscount(Math.max(0, value - included) * pricePerDevice, discountPercent).discounted, - discountPercent, - raw: option, - currency, - }); - }); - - return { - min, - max, - step, - included, - pricePerDeviceKopeks: pricePerDevice, - defaultValue: defaultValue !== null ? defaultValue : Math.max(min, included), - options, - raw, - currency, - }; - } - - function initializePurchaseConfiguratorSelections(config) { - if (!config) { - return; - } - - const periodId = purchaseConfiguratorSelections.period?.id; - const nextPeriod = config.periods.find(option => option.id === periodId) || config.periods[0] || null; - purchaseConfiguratorSelections.period = nextPeriod; - - if (config.traffic.mode === 'selectable') { - const trafficId = purchaseConfiguratorSelections.traffic?.id; - const nextTraffic = config.traffic.options.find(option => option.id === trafficId) - || config.traffic.defaultOption - || config.traffic.options[0] - || null; - purchaseConfiguratorSelections.traffic = nextTraffic; - } else { - purchaseConfiguratorSelections.traffic = null; - } - - if (!(purchaseConfiguratorSelections.servers instanceof Set)) { - purchaseConfiguratorSelections.servers = new Set(); - } - const availableServerIds = new Set(config.servers.options.map(option => option.id)); - if (config.servers.mode === 'fixed') { - purchaseConfiguratorSelections.servers = new Set(config.servers.options.map(option => option.id)); - } else { - const initial = purchaseConfiguratorSelections.servers.size - ? new Set([...purchaseConfiguratorSelections.servers].filter(id => availableServerIds.has(id))) - : new Set(config.servers.defaultSelected); - if (!initial.size && config.servers.options.length) { - initial.add(config.servers.options[0].id); - } - purchaseConfiguratorSelections.servers = initial; - } - - const deviceMin = config.devices.min || 0; - const deviceMax = config.devices.max || 0; - let devicesSelected = coercePositiveInt(purchaseConfiguratorSelections.devices, null); - if (devicesSelected === null) { - devicesSelected = config.devices.defaultValue ?? deviceMin; - } - if (deviceMax && devicesSelected > deviceMax) { - devicesSelected = deviceMax; - } - if (devicesSelected < deviceMin) { - devicesSelected = deviceMin; - } - purchaseConfiguratorSelections.devices = devicesSelected; - } - - function calculatePurchaseTotals(config, selections) { - if (!config || !selections?.period) { - return null; - } - - const period = config.periods.find(option => option.id === selections.period.id) || selections.period; - if (!period) { - return null; - } - - const months = coercePositiveInt(period.months, null) - || (period.days ? Math.max(1, Math.round(period.days / 30)) : 1); - - const summary = { - currency: config.currency, - months, - components: [], - promoOfferPercent: config.promoOfferPercent || 0, - promoOfferDiscount: 0, - baseTotal: 0, - discountedTotal: 0, - validationErrors: [], - isValid: true, - serversMeta: { - min: config.servers.min || 0, - max: config.servers.max || 0, - mode: config.servers.mode, - }, - selections, - }; - - const periodBase = coercePositiveInt(period.priceKopeks, 0) || 0; - const periodDiscounted = coercePositiveInt(period.discountedPriceKopeks, null); - summary.components.push({ - type: 'period', - label: period.label || formatMonthsLabel(months), - base: periodBase, - discounted: periodDiscounted !== null ? periodDiscounted : periodBase, - raw: period, - }); - - if (config.traffic.mode === 'selectable') { - const selectedTraffic = config.traffic.options.find(option => option.id === selections.traffic?.id) - || config.traffic.defaultOption - || null; - if (selectedTraffic) { - const trafficBase = coercePositiveInt(selectedTraffic.priceKopeks, null); - const trafficPerMonth = coercePositiveInt(selectedTraffic.pricePerMonthKopeks, null); - const baseValue = trafficBase !== null - ? trafficBase - : trafficPerMonth !== null - ? trafficPerMonth * months - : 0; - const discountedValue = coercePositiveInt(selectedTraffic.discountedPriceKopeks, null); - summary.components.push({ - type: 'traffic', - label: selectedTraffic.label || '', - base: baseValue, - discounted: discountedValue !== null ? discountedValue : baseValue, - raw: selectedTraffic, - }); - } - } else if (config.traffic.mode === 'fixed') { - const value = config.traffic.fixedValue; - let label = config.traffic.fixedLabel || ''; - if (!label) { - if (value !== null && value !== undefined) { - label = formatTrafficLimit(value); - } else { - const unlimited = t('values.unlimited'); - label = unlimited === 'values.unlimited' ? 'Unlimited' : unlimited; - } - } - summary.components.push({ - type: 'traffic', - label, - base: 0, - discounted: 0, - raw: config.traffic, - }); - } - - const serverSelection = selections.servers instanceof Set ? Array.from(selections.servers) : []; - const serverOptions = config.servers.options.filter(option => serverSelection.includes(option.id)); - if (config.servers.mode === 'selectable') { - if (config.servers.min && serverSelection.length < config.servers.min) { - summary.isValid = false; - summary.validationErrors.push('servers-min'); - } - if (config.servers.max && serverSelection.length > config.servers.max) { - summary.isValid = false; - summary.validationErrors.push('servers-max'); - } - } - - const serverBase = serverOptions.reduce((total, option) => { - const baseValue = coercePositiveInt(option.priceKopeks, null); - const perMonth = coercePositiveInt(option.pricePerMonthKopeks, null); - if (baseValue !== null) { - return total + baseValue; - } - if (perMonth !== null) { - return total + perMonth * months; - } - return total; - }, 0); - - const serverDiscounted = serverOptions.reduce((total, option) => { - const discountedValue = coercePositiveInt(option.discountedPriceKopeks, null); - const perMonth = coercePositiveInt(option.pricePerMonthKopeks, null); - if (discountedValue !== null) { - return total + discountedValue; - } - if (perMonth !== null) { - return total + perMonth * months; - } - return total; - }, 0); - - const serverNames = serverOptions.map(option => option.name || option.id).filter(Boolean).join(', '); - const serverLabel = serverNames || (config.servers.mode === 'fixed' - ? t('purchase.fixed.servers') - : ''); - summary.components.push({ - type: 'servers', - label: serverLabel, - base: serverBase, - discounted: serverDiscounted, - raw: serverOptions, - }); - - let devicesSelected = coercePositiveInt(selections.devices, null); - if (devicesSelected === null) { - devicesSelected = config.devices.defaultValue ?? config.devices.min ?? 0; - } - if (config.devices.max && devicesSelected > config.devices.max) { - devicesSelected = config.devices.max; - } - if (devicesSelected < config.devices.min) { - devicesSelected = config.devices.min; - } - - let devicesBase = 0; - let devicesDiscounted = 0; - const devicesOption = config.devices.options.find(option => option.value === devicesSelected) || null; - if (devicesOption) { - devicesBase = coercePositiveInt(devicesOption.priceKopeks, 0) || 0; - const discountedValue = coercePositiveInt(devicesOption.discountedPriceKopeks, null); - devicesDiscounted = discountedValue !== null ? discountedValue : devicesBase; - } else { - const included = config.devices.included || 0; - const extra = Math.max(0, devicesSelected - included); - const perDevice = config.devices.pricePerDeviceKopeks || 0; - devicesBase = extra * perDevice * months; - devicesDiscounted = devicesBase; - } - - let devicesLabel = String(devicesSelected); - if (!devicesSelected) { - const unlimited = t('values.unlimited'); - devicesLabel = unlimited === 'values.unlimited' ? 'Unlimited' : unlimited; - } - summary.components.push({ - type: 'devices', - label: devicesLabel, - base: devicesBase, - discounted: devicesDiscounted, - raw: { value: devicesSelected }, - }); - - summary.baseTotal = summary.components.reduce((total, component) => total + (component.base || 0), 0); - summary.discountedTotal = summary.components.reduce((total, component) => total + (component.discounted || component.base || 0), 0); - - if (summary.promoOfferPercent > 0 && summary.discountedTotal > 0) { - const promoResult = applyPercentDiscount(summary.discountedTotal, summary.promoOfferPercent); - summary.promoOfferDiscount = promoResult.discount; - summary.discountedTotal = promoResult.discounted; - } - - summary.totalDiscount = summary.baseTotal - summary.discountedTotal; - return summary; - } - - function renderPurchaseConfiguratorCard() { - const card = document.getElementById('purchaseConfiguratorCard'); - if (!card) { - return; - } - - const shouldShow = shouldShowPurchaseConfigurator(); - card.classList.toggle('hidden', !shouldShow); - updatePurchaseSubmitButtonState(); - if (!shouldShow) { - return; - } - - const loadingElement = document.getElementById('purchaseConfiguratorLoading'); - const errorElement = document.getElementById('purchaseConfiguratorError'); - - loadingElement?.classList.toggle('hidden', !purchaseConfiguratorLoading); - - if (purchaseConfiguratorError) { - const message = purchaseConfiguratorError.message - || t('purchase.error.generic') - || 'Unable to load purchase options.'; - if (errorElement) { - errorElement.textContent = message; - errorElement.classList.remove('hidden'); - } - } else { - errorElement?.classList.add('hidden'); - } - - if (!purchaseConfiguratorData) { - const source = getPurchaseConfiguratorSource(); - if (source && !purchaseConfiguratorLoading) { - purchaseConfiguratorData = normalizePurchaseConfiguratorData(source); - if (purchaseConfiguratorData) { - initializePurchaseConfiguratorSelections(purchaseConfiguratorData); - } - } - } - - if (!purchaseConfiguratorData) { - if (!purchaseConfiguratorLoading && !purchaseConfiguratorError) { - ensurePurchaseConfiguratorLoaded().catch(error => { - purchaseConfiguratorError = error instanceof Error - ? error - : createError('Error', String(error || 'Failed to load configurator')); - renderPurchaseConfiguratorCard(); - }); - } - return; - } - - initializePurchaseConfiguratorSelections(purchaseConfiguratorData); - renderPurchasePeriodOptions(purchaseConfiguratorData); - renderPurchaseTrafficOptions(purchaseConfiguratorData); - renderPurchaseServersOptions(purchaseConfiguratorData); - renderPurchaseDevicesSection(purchaseConfiguratorData); - updatePurchaseSummary(purchaseConfiguratorData); - updatePurchaseSubmitButtonState(); - } - - function renderPurchasePeriodOptions(config) { - const section = document.getElementById('purchaseConfiguratorPeriodSection'); - const list = document.getElementById('purchaseConfiguratorPeriodList'); - const emptyState = document.getElementById('purchaseConfiguratorPeriodEmpty'); - const tags = document.getElementById('purchaseConfiguratorPeriodTags'); - if (!section || !list) { - return; - } - - list.innerHTML = ''; - if (tags) { - tags.innerHTML = ''; - } - - if (!config.periods.length) { - section.classList.remove('hidden'); - emptyState?.classList.remove('hidden'); - return; - } - - section.classList.remove('hidden'); - emptyState?.classList.add('hidden'); - - config.periods.forEach(option => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'purchase-option'; - if (purchaseConfiguratorSelections.period?.id === option.id) { - button.classList.add('active'); - } - button.disabled = purchaseConfiguratorLoading || purchaseConfiguratorSubmitting; - - const title = document.createElement('div'); - title.className = 'purchase-option-title'; - title.textContent = option.label - || formatMonthsLabel(option.months) - || (option.days ? `${option.days} ${t('units.days') || 'days'}` : ''); - button.appendChild(title); - - const meta = document.createElement('div'); - meta.className = 'purchase-option-meta'; - if (option.discountedPriceKopeks !== null && option.discountedPriceKopeks < option.priceKopeks) { - const old = document.createElement('div'); - old.className = 'purchase-summary-old-price'; - old.textContent = formatPriceFromKopeks(option.priceKopeks, config.currency); - meta.appendChild(old); - } - const final = document.createElement('strong'); - final.textContent = formatPriceFromKopeks( - option.discountedPriceKopeks !== null ? option.discountedPriceKopeks : option.priceKopeks, - config.currency, - ); - meta.appendChild(final); - button.appendChild(meta); - - if (option.description) { - const description = document.createElement('div'); - description.className = 'purchase-option-description'; - description.textContent = option.description; - button.appendChild(description); - } - - button.addEventListener('click', () => { - if (purchaseConfiguratorSelections.period?.id === option.id - || purchaseConfiguratorLoading - || purchaseConfiguratorSubmitting) { - return; - } - purchaseConfiguratorSelections.period = option; - renderPurchaseConfiguratorCard(); - }); - - list.appendChild(button); - }); - } - - function renderPurchaseTrafficOptions(config) { - const section = document.getElementById('purchaseConfiguratorTrafficSection'); - const list = document.getElementById('purchaseConfiguratorTrafficList'); - const emptyState = document.getElementById('purchaseConfiguratorTrafficEmpty'); - const subtitle = document.getElementById('purchaseConfiguratorTrafficSubtitle'); - if (!section || !list) { - return; - } - - list.innerHTML = ''; - - if (config.traffic.mode === 'fixed') { - const label = config.traffic.fixedLabel - || (config.traffic.fixedValue !== null - ? t('purchase.fixed.traffic').replace('{value}', formatTrafficLimit(config.traffic.fixedValue)) - : ''); - if (subtitle) { - subtitle.textContent = label || t('purchase.fixed.traffic').replace('{value}', '—'); - } - section.classList.remove('hidden'); - emptyState?.classList.add('hidden'); - return; - } - - if (!config.traffic.options.length) { - section.classList.remove('hidden'); - emptyState?.classList.remove('hidden'); - return; - } - - emptyState?.classList.add('hidden'); - section.classList.remove('hidden'); - if (subtitle) { - const baseText = t('purchase.traffic.subtitle'); - subtitle.textContent = baseText === 'purchase.traffic.subtitle' - ? 'Pick a monthly traffic limit.' - : baseText; - } - - config.traffic.options.forEach(option => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'purchase-option'; - if (purchaseConfiguratorSelections.traffic?.id === option.id) { - button.classList.add('active'); - } - button.disabled = purchaseConfiguratorLoading || purchaseConfiguratorSubmitting; - - const title = document.createElement('div'); - title.className = 'purchase-option-title'; - title.textContent = option.label || formatTrafficLimit(option.value); - button.appendChild(title); - - const meta = document.createElement('div'); - meta.className = 'purchase-option-meta'; - if (option.discountedPriceKopeks !== null && option.discountedPriceKopeks < option.priceKopeks) { - const old = document.createElement('div'); - old.className = 'purchase-summary-old-price'; - old.textContent = formatPriceFromKopeks(option.priceKopeks, config.currency); - meta.appendChild(old); - } - const final = document.createElement('strong'); - const finalPriceValue = option.discountedPriceKopeks !== null ? option.discountedPriceKopeks : option.priceKopeks; - final.textContent = finalPriceValue > 0 - ? formatPriceFromKopeks(finalPriceValue, config.currency) - : t('subscription_settings.price.included'); - meta.appendChild(final); - button.appendChild(meta); - - button.addEventListener('click', () => { - if (purchaseConfiguratorLoading || purchaseConfiguratorSubmitting) { - return; - } - purchaseConfiguratorSelections.traffic = option; - updatePurchaseSummary(config); - renderPurchaseTrafficOptions(config); - }); - - list.appendChild(button); - }); - } - - function renderPurchaseServersOptions(config) { - const section = document.getElementById('purchaseConfiguratorServersSection'); - const list = document.getElementById('purchaseConfiguratorServersList'); - const emptyState = document.getElementById('purchaseConfiguratorServersEmpty'); - const metaElement = document.getElementById('purchaseConfiguratorServersMeta'); - const subtitle = document.getElementById('purchaseConfiguratorServersSubtitle'); - if (!section || !list) { - return; - } - - list.innerHTML = ''; - const serverOptions = config.servers.options; - const selectedSet = purchaseConfiguratorSelections.servers instanceof Set - ? new Set(purchaseConfiguratorSelections.servers) - : new Set(); - - if (!serverOptions.length) { - section.classList.remove('hidden'); - emptyState?.classList.remove('hidden'); - metaElement?.classList.remove('purchase-warning'); - metaElement && (metaElement.textContent = ''); - return; - } - - section.classList.remove('hidden'); - emptyState?.classList.add('hidden'); - - serverOptions.forEach(option => { - const isSelected = selectedSet.has(option.id); - const element = document.createElement(config.servers.mode === 'selectable' ? 'button' : 'div'); - if (config.servers.mode === 'selectable') { - element.type = 'button'; - element.disabled = purchaseConfiguratorLoading - || purchaseConfiguratorSubmitting - || option.isAvailable === false; - } - element.className = 'purchase-option'; - if (isSelected || config.servers.mode === 'fixed') { - element.classList.add('active'); - } - if (config.servers.mode === 'selectable' && option.isAvailable === false) { - element.classList.add('disabled'); - } - - const title = document.createElement('div'); - title.className = 'purchase-option-title'; - title.textContent = option.name || option.id; - element.appendChild(title); - - const meta = document.createElement('div'); - meta.className = 'purchase-option-meta'; - const finalValue = option.discountedPriceKopeks !== null ? option.discountedPriceKopeks : option.priceKopeks; - if (finalValue > 0) { - if (option.discountedPriceKopeks !== null && option.discountedPriceKopeks < option.priceKopeks) { - const old = document.createElement('div'); - old.className = 'purchase-summary-old-price'; - old.textContent = formatPriceFromKopeks(option.priceKopeks, config.currency); - meta.appendChild(old); - } - const final = document.createElement('strong'); - final.textContent = formatPriceFromKopeks(finalValue, config.currency); - meta.appendChild(final); - } else { - const included = document.createElement('strong'); - included.textContent = t('subscription_settings.price.included'); - meta.appendChild(included); - } - element.appendChild(meta); - - if (config.servers.mode === 'selectable' && option.isAvailable !== false) { - element.addEventListener('click', () => { - if (purchaseConfiguratorLoading || purchaseConfiguratorSubmitting) { - return; - } - const nextSet = new Set(purchaseConfiguratorSelections.servers); - const currentlySelected = nextSet.has(option.id); - if (currentlySelected) { - if (config.servers.min && nextSet.size <= config.servers.min) { - return; - } - nextSet.delete(option.id); - } else { - if (config.servers.max && nextSet.size >= config.servers.max) { - return; - } - nextSet.add(option.id); - } - purchaseConfiguratorSelections.servers = nextSet; - updatePurchaseSummary(config); - renderPurchaseServersOptions(config); - }); - } - - list.appendChild(element); - }); - - const selectedIds = purchaseConfiguratorSelections.servers instanceof Set - ? Array.from(purchaseConfiguratorSelections.servers) - : []; - const selectedOptions = serverOptions.filter(option => selectedIds.includes(option.id)); - const selectedNames = selectedOptions.map(option => option.name || option.id).filter(Boolean); - const selectedCount = selectedNames.length; - const metaParts = []; - if (config.servers.mode === 'selectable') { - if (config.servers.min && config.servers.max) { - const rangeText = t('purchase.server.meta.range'); - metaParts.push(rangeText && rangeText !== 'purchase.server.meta.range' - ? rangeText.replace('{min}', String(config.servers.min)).replace('{max}', String(config.servers.max)) - : `${config.servers.min}–${config.servers.max}`); - } else if (config.servers.min) { - const requiredText = t('purchase.server.meta.required'); - metaParts.push(requiredText && requiredText !== 'purchase.server.meta.required' - ? requiredText.replace('{count}', String(config.servers.min)) - : `Select at least ${config.servers.min}`); - } - const selectedText = t('purchase.server.meta.selected'); - metaParts.push(selectedText && selectedText !== 'purchase.server.meta.selected' - ? selectedText.replace('{count}', String(selectedCount)) - : `${selectedCount}`); - } else { - const fixedText = t('purchase.fixed.servers'); - metaParts.push(fixedText && fixedText !== 'purchase.fixed.servers' - ? fixedText - : 'Servers are fixed for this plan.'); - if (selectedNames.length === 1) { - metaParts.push(selectedNames[0]); - } else if (selectedNames.length > 1) { - metaParts.push(selectedNames.join(', ')); - } else { - const singleText = t('purchase.server.meta.single'); - metaParts.push(singleText && singleText !== 'purchase.server.meta.single' - ? singleText - : 'The subscription includes one region.'); - } - } - if (metaElement) { - metaElement.textContent = metaParts.filter(Boolean).join(' · '); - if (config.servers.mode === 'selectable') { - const belowMin = config.servers.min && selectedCount < config.servers.min; - metaElement.classList.toggle('purchase-warning', Boolean(belowMin)); - } else { - metaElement.classList.remove('purchase-warning'); - } - } - if (subtitle) { - if (config.servers.mode === 'fixed') { - const fixedSubtitle = t('purchase.fixed.servers'); - subtitle.textContent = fixedSubtitle && fixedSubtitle !== 'purchase.fixed.servers' - ? fixedSubtitle - : 'Servers are fixed for this plan.'; - } else { - const baseText = t('purchase.servers.subtitle'); - subtitle.textContent = baseText === 'purchase.servers.subtitle' - ? 'Choose regions that will be available in the subscription.' - : baseText; - } - } - } - - function renderPurchaseDevicesSection(config) { - const section = document.getElementById('purchaseConfiguratorDevicesSection'); - const valueElement = document.getElementById('purchaseConfiguratorDevicesValue'); - const decreaseBtn = document.getElementById('purchaseConfiguratorDevicesDecrease'); - const increaseBtn = document.getElementById('purchaseConfiguratorDevicesIncrease'); - const hintElement = document.getElementById('purchaseConfiguratorDevicesHint'); - if (!section || !valueElement || !decreaseBtn || !increaseBtn) { - return; - } - - section.classList.remove('hidden'); - const min = config.devices.min || 0; - const max = config.devices.max || 0; - const step = config.devices.step || 1; - const devicesSelected = coercePositiveInt(purchaseConfiguratorSelections.devices, null) - ?? config.devices.defaultValue - ?? min - ?? 0; - - valueElement.textContent = devicesSelected === 0 - ? t('subscription_settings.devices.unlimited') - : String(devicesSelected); - - decreaseBtn.disabled = purchaseConfiguratorLoading || purchaseConfiguratorSubmitting || devicesSelected <= min; - increaseBtn.disabled = purchaseConfiguratorLoading - || purchaseConfiguratorSubmitting - || (max && devicesSelected >= max); - - decreaseBtn.onclick = () => { - if (purchaseConfiguratorLoading || purchaseConfiguratorSubmitting || devicesSelected <= min) { - return; - } - const next = Math.max(min, devicesSelected - step); - purchaseConfiguratorSelections.devices = next; - renderPurchaseDevicesSection(config); - updatePurchaseSummary(config); - }; - - increaseBtn.onclick = () => { - if (purchaseConfiguratorLoading - || purchaseConfiguratorSubmitting - || (max && devicesSelected >= max)) { - return; - } - const next = max ? Math.min(max, devicesSelected + step) : devicesSelected + step; - purchaseConfiguratorSelections.devices = next; - renderPurchaseDevicesSection(config); - updatePurchaseSummary(config); - }; - - if (hintElement) { - let hint = ''; - if (config.devices.included) { - const template = t('purchase.devices.included'); - hint = template && template !== 'purchase.devices.included' - ? template.replace('{count}', String(config.devices.included)) - : `Included devices: ${config.devices.included}`; - } - hintElement.textContent = hint; - } - } - - function updatePurchaseSummary(config) { - const summaryElement = document.getElementById('purchaseConfiguratorSummary'); - if (!summaryElement) { - return; - } - - const summary = calculatePurchaseTotals(config, purchaseConfiguratorSelections); - if (!summary) { - summaryElement.classList.add('hidden'); - return; - } - - summaryElement.classList.remove('hidden'); - const breakdown = document.getElementById('purchaseConfiguratorBreakdown'); - breakdown.innerHTML = ''; - - summary.components.forEach(component => { - if (!component) { - return; - } - const row = document.createElement('div'); - row.className = 'purchase-summary-row'; - - const label = document.createElement('span'); - const labelKey = t(`purchase.summary.${component.type}`); - const baseLabel = labelKey && labelKey !== `purchase.summary.${component.type}` - ? labelKey - : component.type.charAt(0).toUpperCase() + component.type.slice(1); - label.textContent = component.label ? `${baseLabel}: ${component.label}` : baseLabel; - row.appendChild(label); - - const valueContainer = document.createElement('div'); - const finalValue = component.discounted ?? component.base ?? 0; - if (component.base !== undefined && finalValue < component.base) { - const old = document.createElement('div'); - old.className = 'purchase-summary-old-price'; - old.textContent = formatPriceFromKopeks(component.base, summary.currency); - valueContainer.appendChild(old); - } - const final = document.createElement('strong'); - final.textContent = formatPriceFromKopeks(finalValue, summary.currency); - valueContainer.appendChild(final); - row.appendChild(valueContainer); - - breakdown.appendChild(row); - }); - - const totalElement = document.getElementById('purchaseConfiguratorTotal'); - const oldTotalElement = document.getElementById('purchaseConfiguratorOldTotal'); - const discountElement = document.getElementById('purchaseConfiguratorDiscount'); - - if (summary.baseTotal > summary.discountedTotal) { - oldTotalElement.textContent = formatPriceFromKopeks(summary.baseTotal, summary.currency); - oldTotalElement.classList.remove('hidden'); - } else { - oldTotalElement.classList.add('hidden'); - } - totalElement.textContent = formatPriceFromKopeks(summary.discountedTotal, summary.currency); - - if (summary.totalDiscount > 0) { - const discountLabel = t('purchase.summary.discount'); - discountElement.textContent = discountLabel && discountLabel !== 'purchase.summary.discount' - ? discountLabel.replace('{value}', formatPriceFromKopeks(summary.totalDiscount, summary.currency)) - : `Discount: ${formatPriceFromKopeks(summary.totalDiscount, summary.currency)}`; - discountElement.classList.remove('hidden'); - } else { - discountElement.classList.add('hidden'); - } - - const submitButton = document.getElementById('purchaseConfiguratorSubmit'); - const topupButton = document.getElementById('purchaseConfiguratorTopup'); - const balanceHint = document.getElementById('purchaseConfiguratorBalanceHint'); - - const balance = config.balanceKopeks || 0; - if (summary.discountedTotal > balance) { - const missing = summary.discountedTotal - balance; - const insufficientText = t('purchase.balance.insufficient'); - balanceHint.textContent = insufficientText && insufficientText !== 'purchase.balance.insufficient' - ? insufficientText.replace('{amount}', formatPriceFromKopeks(missing, summary.currency)) - : `Not enough funds. Missing ${formatPriceFromKopeks(missing, summary.currency)}`; - balanceHint.classList.add('purchase-warning'); - topupButton?.classList.remove('hidden'); - if (submitButton) { - submitButton.disabled = true; - } - } else { - const sufficientText = t('purchase.balance.sufficient'); - balanceHint.textContent = sufficientText && sufficientText !== 'purchase.balance.sufficient' - ? sufficientText.replace('{amount}', formatPriceFromKopeks(balance, summary.currency)) - : `Balance: ${formatPriceFromKopeks(balance, summary.currency)}`; - balanceHint.classList.remove('purchase-warning'); - topupButton?.classList.add('hidden'); - if (submitButton) { - submitButton.disabled = !summary.isValid - || purchaseConfiguratorLoading - || purchaseConfiguratorSubmitting; - } - } - } - - function resolvePurchaseConfiguratorPayload(body) { - if (!body || typeof body !== 'object') { - return null; - } - const candidates = [ - readField(body, ['config']), - readField(body, ['data']), - readField(body, ['result']), - readField(body, ['payload']), - readField(body, ['options']), - readField(body, ['purchase_config', 'purchaseConfig']), - readField(body, ['constructor']), - body, - ]; - for (const candidate of candidates) { - if (candidate && typeof candidate === 'object') { - return candidate; - } - } - return null; - } - - function extractPurchaseError(body, status) { - if (status === 401) { - const unauthorized = t('purchase.error.unauthorized'); - return unauthorized && unauthorized !== 'purchase.error.unauthorized' - ? unauthorized - : 'Authorization failed. Please reopen the mini app from Telegram.'; - } - - let message = null; - if (body && typeof body === 'object') { - const direct = readField(body, ['message', 'detail', 'error']); - if (typeof direct === 'string') { - message = direct; - } else if (direct && typeof direct === 'object') { - const nested = readField(direct, ['message', 'detail', 'error']); - if (typeof nested === 'string') { - message = nested; - } - } - - if (!message) { - const errors = readField(body, ['errors']); - if (Array.isArray(errors) && errors.length) { - const candidate = errors.find(item => typeof item === 'string') || errors[0]; - if (typeof candidate === 'string') { - message = candidate; - } else if (candidate && typeof candidate === 'object') { - const nested = readField(candidate, ['message', 'detail', 'error']); - if (typeof nested === 'string') { - message = nested; - } - } - } - } - } - - if (message) { - return message; - } - - if (status === 402) { - return 'Not enough balance to complete the purchase.'; - } - - const fallback = t('purchase.error.generic'); - return fallback && fallback !== 'purchase.error.generic' - ? fallback - : 'Unable to load purchase options. Please try again later.'; - } - - function updatePurchaseSubmitButtonState() { - const submitButton = document.getElementById('purchaseConfiguratorSubmit'); - if (!submitButton) { - return; - } - if (purchaseConfiguratorSubmitting) { - const processingLabel = t('purchase.action.processing'); - submitButton.textContent = processingLabel && processingLabel !== 'purchase.action.processing' - ? processingLabel - : 'Processing…'; - } else { - const defaultLabel = t('purchase.action.buy'); - submitButton.textContent = defaultLabel && defaultLabel !== 'purchase.action.buy' - ? defaultLabel - : 'Buy subscription'; - } - } - - async function ensurePurchaseConfiguratorLoaded(options = {}) { - const { force = false } = options; - - if (!shouldShowPurchaseConfigurator()) { - purchaseConfiguratorData = null; - purchaseConfiguratorError = null; - purchaseConfiguratorLoading = false; - purchaseConfiguratorRequest = null; - purchaseConfiguratorSubmitting = false; - renderPurchaseConfiguratorCard(); - return null; - } - - if (!force) { - if (purchaseConfiguratorRequest) { - return purchaseConfiguratorRequest; - } - if (purchaseConfiguratorData && !purchaseConfiguratorError) { - return Promise.resolve(purchaseConfiguratorData); - } - } else { - purchaseConfiguratorData = null; - purchaseConfiguratorError = null; - purchaseConfiguratorRequest = null; - } - - const initData = tg.initData || ''; - if (!initData) { - const error = createError('Authorization Error', t('purchase.error.unauthorized')); - purchaseConfiguratorError = error; - purchaseConfiguratorLoading = false; - renderPurchaseConfiguratorCard(); - return Promise.reject(error); - } - - purchaseConfiguratorLoading = true; - purchaseConfiguratorError = null; - renderPurchaseConfiguratorCard(); - - const payload = { - initData, - subscription_id: userData?.subscription_id ?? userData?.subscriptionId ?? null, - subscriptionId: userData?.subscription_id ?? userData?.subscriptionId ?? null, - promo_group: userData?.promo_group ?? null, - promoGroup: userData?.promo_group ?? null, - language: preferredLanguage || null, - }; - - const request = (async () => { - const endpoints = purchaseConfiguratorEndpoints.filter(endpoint => typeof endpoint === 'string' && endpoint.trim().length); - let lastError = null; - - for (const endpoint of endpoints) { - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const body = await parseJsonSafe(response); - if (!response.ok || (body && body.success === false)) { - const message = extractPurchaseError(body, response.status); - throw createError('Purchase configurator error', message, response.status); - } - - const configPayload = resolvePurchaseConfiguratorPayload(body); - const normalized = normalizePurchaseConfiguratorData(configPayload); - if (!normalized) { - throw createError('Purchase configurator error', t('purchase.error.generic')); - } - - const balanceFromResponse = coercePositiveInt( - readField(body, ['balance_kopeks', 'balanceKopeks', 'balance']), - null, - ); - if (balanceFromResponse !== null) { - normalized.balanceKopeks = balanceFromResponse; - } - - purchaseConfiguratorData = normalized; - purchaseConfiguratorError = null; - purchaseConfiguratorLoading = false; - purchaseConfiguratorRequest = null; - initializePurchaseConfiguratorSelections(normalized); - renderPurchaseConfiguratorCard(); - return normalized; - } catch (error) { - lastError = error instanceof Error - ? error - : createError('Purchase configurator error', String(error)); - } - } - - purchaseConfiguratorLoading = false; - purchaseConfiguratorError = lastError || createError('Purchase configurator error', t('purchase.error.generic')); - purchaseConfiguratorRequest = null; - renderPurchaseConfiguratorCard(); - throw purchaseConfiguratorError; - })(); - - purchaseConfiguratorRequest = request; - return request; - } - - async function submitPurchaseConfigurator() { - if (purchaseConfiguratorSubmitting || purchaseConfiguratorLoading) { - return; - } - if (!shouldShowPurchaseConfigurator()) { - return; - } - - try { - if (!purchaseConfiguratorData) { - await ensurePurchaseConfiguratorLoaded(); - } - } catch (error) { - const message = error?.message || extractPurchaseError(null, error?.status); - showPopup(message, t('purchase.title') || 'Configure your subscription'); - return; - } - - const config = purchaseConfiguratorData; - if (!config) { - return; - } - - initializePurchaseConfiguratorSelections(config); - const summary = calculatePurchaseTotals(config, purchaseConfiguratorSelections); - if (!summary || !summary.isValid) { - const validationMessage = t('purchase.error.validation'); - showPopup( - validationMessage && validationMessage !== 'purchase.error.validation' - ? validationMessage - : 'Please select all required options before purchase.', - t('purchase.title') || 'Configure your subscription' - ); - return; - } - - const balance = config.balanceKopeks || 0; - if (summary.discountedTotal > balance) { - const missing = summary.discountedTotal - balance; - let message = t('purchase.balance.insufficient'); - if (message && message !== 'purchase.balance.insufficient') { - message = message.replace('{amount}', formatPriceFromKopeks(missing, summary.currency)); - } else { - message = `Not enough funds. Missing ${formatPriceFromKopeks(missing, summary.currency)}`; - } - showPopup(message, t('purchase.title') || 'Configure your subscription'); - openTopupModal(); - return; - } - - const initData = tg.initData || ''; - if (!initData) { - const unauthorized = t('purchase.error.unauthorized'); - showPopup( - unauthorized && unauthorized !== 'purchase.error.unauthorized' - ? unauthorized - : 'Authorization failed. Please reopen the mini app from Telegram.', - t('purchase.title') || 'Configure your subscription' - ); - return; - } - - purchaseConfiguratorSubmitting = true; - updatePurchaseSubmitButtonState(); - updatePurchaseSummary(config); - - const period = purchaseConfiguratorSelections.period - || config.periods[0] - || null; - const traffic = config.traffic.mode === 'selectable' - ? purchaseConfiguratorSelections.traffic - : null; - const serverIds = purchaseConfiguratorSelections.servers instanceof Set - ? Array.from(purchaseConfiguratorSelections.servers) - : []; - const devicesSelected = coercePositiveInt(purchaseConfiguratorSelections.devices, null) - ?? config.devices.defaultValue - ?? config.devices.min - ?? 0; - const selectedServerOptions = config.servers.options.filter(option => serverIds.includes(option.id)); - const devicesOption = config.devices.options.find(option => option.value === devicesSelected) || null; - - const payload = { - initData, - subscription_id: userData?.subscription_id ?? userData?.subscriptionId ?? null, - subscriptionId: userData?.subscription_id ?? userData?.subscriptionId ?? null, - period_id: period?.id ?? null, - periodId: period?.id ?? null, - period: period?.raw ?? period, - period_option: period?.raw ?? null, - periodOption: period?.raw ?? null, - traffic_id: traffic?.id ?? null, - trafficId: traffic?.id ?? null, - traffic: traffic?.raw ?? traffic, - traffic_option: traffic?.raw ?? null, - trafficOption: traffic?.raw ?? null, - traffic_value: traffic?.value ?? null, - servers: serverIds, - server_ids: serverIds, - serverIds, - squads: serverIds, - squad_uuids: serverIds, - selected_servers: selectedServerOptions.map(option => option.raw ?? option.id), - selectedServers: selectedServerOptions.map(option => option.raw ?? option.id), - devices: devicesSelected, - devices_count: devicesSelected, - devicesCount: devicesSelected, - devices_option: devicesOption?.raw ?? null, - devicesOption: devicesOption?.raw ?? null, - currency: summary.currency, - total_price_kopeks: summary.baseTotal, - totalPriceKopeks: summary.baseTotal, - final_price_kopeks: summary.discountedTotal, - finalPriceKopeks: summary.discountedTotal, - discount_kopeks: summary.totalDiscount, - discountKopeks: summary.totalDiscount, - promo_offer_percent: summary.promoOfferPercent, - promoOfferPercent: summary.promoOfferPercent, - promo_group: config.promoGroup ?? userData?.promo_group ?? null, - promoGroup: config.promoGroup ?? userData?.promo_group ?? null, - language: preferredLanguage || null, - }; - - payload.selection = { - period: period?.raw ?? period, - periodId: period?.id ?? null, - traffic: traffic?.raw ?? traffic, - trafficId: traffic?.id ?? null, - servers: serverIds, - devices: devicesSelected, - }; - - payload.summary = { - months: summary.months, - currency: summary.currency, - base_total_price_kopeks: summary.baseTotal, - discounted_total_price_kopeks: summary.discountedTotal, - discount_kopeks: summary.totalDiscount, - promo_offer_percent: summary.promoOfferPercent, - components: summary.components.map(component => ({ - type: component.type, - base: component.base, - discounted: component.discounted, - label: component.label, - })), - }; - - let responseBody = null; - let lastError = null; - - for (const endpoint of purchaseSubmissionEndpoints) { - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const body = await parseJsonSafe(response); - if (!response.ok || (body && body.success === false)) { - const message = extractPurchaseError(body, response.status); - throw createError('Purchase error', message, response.status); - } - responseBody = body; - break; - } catch (error) { - lastError = error instanceof Error ? error : createError('Purchase error', String(error)); - } - } - - if (!responseBody) { - const error = lastError || createError('Purchase error', t('purchase.error.generic')); - throw error; - } - - const balanceFromResponse = coercePositiveInt( - readField(responseBody, ['balance_kopeks', 'balanceKopeks', 'balance']), - null, - ); - if (balanceFromResponse !== null && purchaseConfiguratorData) { - purchaseConfiguratorData.balanceKopeks = balanceFromResponse; - } - - const redirectUrl = normalizeUrl(readField(responseBody, [ - 'redirect_url', - 'redirectUrl', - 'payment_url', - 'paymentUrl', - 'invoice_url', - 'invoiceUrl', - 'deeplink', - 'url', - ])); - if (redirectUrl) { - openExternalLink(redirectUrl, { openInMiniApp: true }); - } - - const successMessage = t('purchase.success.message'); - const successTitle = t('purchase.success.title'); - showPopup( - successMessage && successMessage !== 'purchase.success.message' - ? successMessage - : 'Your subscription has been purchased successfully.', - successTitle && successTitle !== 'purchase.success.title' - ? successTitle - : 'Subscription purchased' - ); - - try { - await refreshSubscriptionData({ silent: true }); - } catch (error) { - console.warn('Failed to refresh subscription data after purchase:', error); - renderPurchaseConfiguratorCard(); - } - } catch (error) { - const title = error?.title || (t('purchase.title') || 'Configure your subscription'); - const message = error?.message || extractPurchaseError(null, error?.status); - showPopup(message, title); - } finally { - purchaseConfiguratorSubmitting = false; - updatePurchaseSubmitButtonState(); - if (purchaseConfiguratorData) { - updatePurchaseSummary(purchaseConfiguratorData); - } - renderPurchaseConfiguratorCard(); - } - } - function formatMonthsLabel(months) { const normalized = coercePositiveInt(months, null); if (!normalized) { @@ -11454,13 +9317,6 @@ return true; } - function shouldShowPurchaseConfigurator() { - if (!userData?.user) { - return false; - } - return !hasPaidSubscription(); - } - function normalizeServerEntry(entry) { if (!entry) { return null; @@ -12968,14 +10824,6 @@ openExternalLink(link, { openInMiniApp: true }); }); - document.getElementById('purchaseConfiguratorSubmit')?.addEventListener('click', () => { - submitPurchaseConfigurator(); - }); - - document.getElementById('purchaseConfiguratorTopup')?.addEventListener('click', () => { - openTopupModal(); - }); - initializePromoCodeForm(); init();