diff --git a/miniapp/index.html b/miniapp/index.html index b1f33202..327307bd 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -705,6 +705,298 @@ color: #fff; } + .subscription-configurator-card { + position: relative; + } + + .subscription-configurator-summary { + font-size: 13px; + color: var(--text-secondary); + padding: 12px 0; + border-bottom: 1px solid var(--border-color); + } + + .subscription-configurator-summary strong { + color: var(--text-primary); + font-weight: 700; + } + + .subscription-configurator-discount-badge { + margin-left: auto; + padding: 6px 12px; + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .subscription-configurator-content { + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 12px; + } + + .subscription-configurator-section { + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); + } + + .subscription-configurator-section:last-child { + border-bottom: none; + } + + .subscription-configurator-section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + } + + .subscription-configurator-section-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .subscription-configurator-section-description { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.45; + } + + .subscription-configurator-section-meta { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .subscription-configurator-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + } + + .subscription-configurator-option { + position: relative; + padding: 14px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + display: flex; + flex-direction: column; + gap: 8px; + cursor: pointer; + transition: all 0.2s ease; + } + + .subscription-configurator-option:hover:not(.disabled) { + border-color: rgba(var(--primary-rgb), 0.5); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); + } + + .subscription-configurator-option.active { + border-color: var(--primary); + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.12), rgba(var(--primary-rgb), 0.04)); + box-shadow: var(--shadow-sm); + } + + .subscription-configurator-option.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .subscription-configurator-option-title { + font-weight: 700; + font-size: 15px; + color: inherit; + } + + .subscription-configurator-option-meta { + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; + } + + .subscription-configurator-option-price { + font-size: 13px; + font-weight: 600; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + } + + .subscription-configurator-option-price .original { + text-decoration: line-through; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + } + + .subscription-configurator-option-price .current { + color: var(--text-primary); + } + + .subscription-configurator-option-price .discount { + color: var(--success); + font-size: 12px; + font-weight: 700; + } + + .subscription-configurator-fixed-value { + font-size: 14px; + color: var(--text-secondary); + font-weight: 600; + } + + .subscription-configurator-stepper { + display: flex; + align-items: center; + gap: 12px; + } + + .subscription-configurator-stepper button { + width: 42px; + height: 42px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 20px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + } + + .subscription-configurator-stepper button:hover:not(:disabled) { + border-color: rgba(var(--primary-rgb), 0.5); + box-shadow: var(--shadow-sm); + } + + .subscription-configurator-stepper button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .subscription-configurator-stepper-value { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); + min-width: 36px; + text-align: center; + } + + .subscription-configurator-devices-note { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + } + + .subscription-configurator-footer { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 4px; + } + + .subscription-configurator-price { + display: flex; + flex-direction: column; + gap: 6px; + } + + .subscription-configurator-price-main { + display: flex; + align-items: baseline; + gap: 10px; + } + + .subscription-configurator-price-main .original { + text-decoration: line-through; + color: var(--text-secondary); + font-size: 15px; + font-weight: 600; + } + + .subscription-configurator-price-main .current { + font-size: 24px; + font-weight: 800; + color: var(--text-primary); + } + + .subscription-configurator-price-extra { + font-size: 13px; + color: var(--text-secondary); + font-weight: 600; + } + + .subscription-configurator-balance-warning { + padding: 12px 14px; + border-radius: var(--radius); + border: 1px solid rgba(var(--danger-rgb, 239, 68, 68), 0.3); + background: rgba(var(--danger-rgb, 239, 68, 68), 0.08); + display: flex; + flex-direction: column; + gap: 10px; + } + + .subscription-configurator-balance-text { + font-size: 13px; + color: var(--danger); + font-weight: 600; + } + + .subscription-configurator-actions { + display: flex; + flex-direction: column; + gap: 8px; + } + + .subscription-configurator-status { + font-size: 13px; + font-weight: 600; + } + + .subscription-configurator-status.error { + color: var(--danger); + } + + .subscription-configurator-status.success { + color: var(--success); + } + + .subscription-configurator-status.info { + color: var(--text-secondary); + } + + :root[data-theme="dark"] .subscription-configurator-option { + background: rgba(15, 23, 42, 0.6); + border-color: rgba(148, 163, 184, 0.28); + } + + :root[data-theme="dark"] .subscription-configurator-option.active { + background: rgba(37, 99, 235, 0.12); + border-color: rgba(96, 165, 250, 0.6); + } + + :root[data-theme="dark"] .subscription-configurator-balance-warning { + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.4); + } + .promo-offers { display: flex; flex-direction: column; @@ -3350,6 +3642,94 @@ + + +
@@ -3779,6 +4159,14 @@ let promoOfferTimerHandle = null; let referralListExpanded = false; let referralCopyResetHandle = null; + let subscriptionConfiguratorData = null; + let subscriptionConfiguratorLoading = false; + const subscriptionConfiguratorSelections = { + periodId: null, + trafficId: null, + servers: new Set(), + devices: null, + }; if (typeof tg.expand === 'function') { tg.expand(); @@ -3977,6 +4365,46 @@ 'topup.status.retry': 'Try again', 'topup.done': 'Done', 'button.buy_subscription': 'Buy Subscription', + 'subscription_configurator.title': 'Subscription configurator', + 'subscription_configurator.summary.placeholder': 'Choose parameters to see the final price.', + 'subscription_configurator.period.title': 'Subscription period', + 'subscription_configurator.period.description': 'Choose how long you need access.', + 'subscription_configurator.period.meta': 'Valid for {months} month(s)', + 'subscription_configurator.traffic.title': 'Traffic', + 'subscription_configurator.traffic.description': 'Select the monthly traffic package.', + 'subscription_configurator.traffic.unlimited': 'Unlimited traffic', + 'subscription_configurator.servers.title': 'Servers', + 'subscription_configurator.servers.description': 'Choose the regions for connection.', + 'subscription_configurator.servers.meta.exact': 'Select {count} server(s)', + 'subscription_configurator.servers.meta.range': 'Select {min}–{max} servers', + 'subscription_configurator.servers.meta.min': 'Select at least {count} server(s)', + 'subscription_configurator.servers.meta.max': 'Up to {count} servers', + 'subscription_configurator.servers.fixed_single': 'Included server: {list}', + 'subscription_configurator.servers.fixed_multiple': 'Included servers: {list}', + 'subscription_configurator.devices.title': 'Devices', + 'subscription_configurator.devices.description': 'Number of simultaneously connected devices.', + 'subscription_configurator.devices.included': 'Included: {count}', + 'subscription_configurator.devices.meta': '{min}–{max} devices', + 'subscription_configurator.summary.period': 'Period: {label}', + 'subscription_configurator.summary.traffic': 'Traffic: {label}', + 'subscription_configurator.summary.servers': '{count} servers: {list}', + 'subscription_configurator.summary.servers_one': '1 server: {list}', + 'subscription_configurator.summary.devices': '{count} devices', + 'subscription_configurator.summary.devices_one': '{count} device', + 'subscription_configurator.price.discount': 'You save {value}.', + 'subscription_configurator.discount.badge': '-{percent}%', + 'subscription_configurator.balance.insufficient': 'Not enough balance. Missing {amount}.', + 'subscription_configurator.status.validation.servers_min': 'At least {count} server required.', + 'subscription_configurator.status.validation.servers_max': 'Maximum {count} servers allowed.', + 'subscription_configurator.status.loading': 'Processing purchase…', + 'subscription_configurator.action.topup': 'Top up balance', + 'subscription_configurator.action.purchase': 'Buy subscription', + 'subscription_configurator.action.activate': 'Activate for free', + 'subscription_configurator.recommended': 'Recommended', + 'subscription_configurator.success': 'Subscription purchased successfully!', + 'subscription_configurator.error.unauthorized': 'Authorization error. Please reopen the mini app.', + 'subscription_configurator.error.insufficient_funds': 'Not enough balance to complete the purchase.', + 'subscription_configurator.error.generic': 'Failed to complete the purchase. Please try again later.', 'card.balance.title': 'Balance', 'subscription_settings.title': 'Subscription settings', 'subscription_settings.summary.servers': 'Servers: {count}', @@ -4258,6 +4686,46 @@ 'topup.status.retry': 'Повторить попытку', 'topup.done': 'Готово', 'button.buy_subscription': 'Купить подписку', + 'subscription_configurator.title': 'Конфигуратор подписки', + 'subscription_configurator.summary.placeholder': 'Выберите параметры, чтобы увидеть итоговую стоимость.', + 'subscription_configurator.period.title': 'Период подписки', + 'subscription_configurator.period.description': 'Выберите срок доступа.', + 'subscription_configurator.period.meta': 'Доступ на {months} мес.', + 'subscription_configurator.traffic.title': 'Трафик', + 'subscription_configurator.traffic.description': 'Выберите объём трафика в месяц.', + 'subscription_configurator.traffic.unlimited': 'Безлимитный трафик', + 'subscription_configurator.servers.title': 'Серверы', + 'subscription_configurator.servers.description': 'Выберите регионы подключения.', + 'subscription_configurator.servers.meta.exact': 'Нужно выбрать {count} сервер(ов)', + 'subscription_configurator.servers.meta.range': 'Выберите от {min} до {max} серверов', + 'subscription_configurator.servers.meta.min': 'Выберите минимум {count} сервер(ов)', + 'subscription_configurator.servers.meta.max': 'Не более {count} серверов', + 'subscription_configurator.servers.fixed_single': 'Доступный сервер: {list}', + 'subscription_configurator.servers.fixed_multiple': 'Доступные серверы: {list}', + 'subscription_configurator.devices.title': 'Устройства', + 'subscription_configurator.devices.description': 'Количество одновременных подключений.', + 'subscription_configurator.devices.included': 'Включено: {count}', + 'subscription_configurator.devices.meta': '{min}–{max} устройств', + 'subscription_configurator.summary.period': 'Период: {label}', + 'subscription_configurator.summary.traffic': 'Трафик: {label}', + 'subscription_configurator.summary.servers': '{count} серверов: {list}', + 'subscription_configurator.summary.servers_one': '1 сервер: {list}', + 'subscription_configurator.summary.devices': '{count} устройств', + 'subscription_configurator.summary.devices_one': '{count} устройство', + 'subscription_configurator.price.discount': 'Экономия {value}.', + 'subscription_configurator.discount.badge': '-{percent}%', + 'subscription_configurator.balance.insufficient': 'Недостаточно средств. Не хватает {amount}.', + 'subscription_configurator.status.validation.servers_min': 'Нужно выбрать минимум {count} сервер(ов).', + 'subscription_configurator.status.validation.servers_max': 'Можно выбрать не более {count} серверов.', + 'subscription_configurator.status.loading': 'Оформляем подписку…', + 'subscription_configurator.action.topup': 'Пополнить баланс', + 'subscription_configurator.action.purchase': 'Купить подписку', + 'subscription_configurator.action.activate': 'Активировать бесплатно', + 'subscription_configurator.recommended': 'Рекомендуем', + 'subscription_configurator.success': 'Подписка успешно оформлена!', + 'subscription_configurator.error.unauthorized': 'Ошибка авторизации. Перезапустите мини-приложение.', + 'subscription_configurator.error.insufficient_funds': 'Недостаточно средств для покупки.', + 'subscription_configurator.error.generic': 'Не удалось завершить покупку. Попробуйте позже.', 'card.balance.title': 'Баланс', 'subscription_settings.title': 'Настройка подписки', 'subscription_settings.summary.servers': 'Серверов: {count}', @@ -5646,6 +6114,10 @@ : autopayLabel; } + subscriptionConfiguratorData = normalizeSubscriptionConfigurator(userData); + resetSubscriptionConfiguratorSelections(subscriptionConfiguratorData); + renderSubscriptionConfigurator(); + renderSubscriptionSettingsCard(); renderPromoOffers(); renderPromoSection(); @@ -6704,6 +7176,29 @@ return Array.isArray(value) ? value : []; } + function clampValue(value, min, max) { + let minValue = Number.isFinite(min) ? min : 0; + let maxValue = Number.isFinite(max) && max > 0 ? max : max === 0 ? 0 : null; + if (maxValue !== null && maxValue < minValue) { + const temp = maxValue; + maxValue = minValue; + minValue = temp; + } + + const numeric = coerceNumber(value, null); + if (numeric === null || Number.isNaN(numeric)) { + return minValue; + } + + if (numeric < minValue) { + return minValue; + } + if (maxValue !== null && numeric > maxValue) { + return maxValue; + } + return Math.trunc(numeric); + } + function coerceNumber(value, fallback = null) { if (typeof value === 'number') { return Number.isFinite(value) ? value : fallback; @@ -9584,6 +10079,1601 @@ }; } + function normalizeConfiguratorPeriodOption(option, index) { + if (!option || typeof option !== 'object') { + return null; + } + const idRaw = option.id + ?? option.key + ?? option.code + ?? option.period_id + ?? option.periodId + ?? option.identifier + ?? null; + const id = idRaw != null ? String(idRaw) : `period-${index}`; + if (!id) { + return null; + } + + const days = coercePositiveInt( + option.period_days + ?? option.days + ?? option.duration_days + ?? option.durationDays, + null + ); + let months = coercePositiveInt( + option.months + ?? option.period_months + ?? option.periodMonths + ?? option.duration_months + ?? option.durationMonths, + null + ); + if (!months && days) { + months = Math.max(1, Math.round(days / 30)); + } + + const fallbackLabel = months + ? `${months} ${months === 1 ? 'month' : 'months'}` + : id; + const label = option.label + || option.title + || option.name + || option.display_name + || option.displayName + || fallbackLabel; + + const priceKopeks = coercePositiveInt( + option.total_price_kopeks + ?? option.price_kopeks + ?? option.final_price_kopeks + ?? option.discounted_price_kopeks + ?? option.amount_kopeks + ?? option.priceKopeks + ?? option.price + ?? option.cost, + 0 + ); + const originalKopeks = coercePositiveInt( + option.original_price_kopeks + ?? option.base_price_kopeks + ?? option.price_before_discount_kopeks + ?? option.list_price_kopeks + ?? option.originalPriceKopeks + ?? option.originalPrice + ?? null, + null + ); + const discountPercent = coercePositiveInt( + option.discount_percent + ?? option.period_discount_percent + ?? option.discountPercent + ?? option.discount, + null + ); + + return { + id, + label, + days, + months: months || (days ? Math.max(1, Math.round(days / 30)) : 1), + priceKopeks: Number.isFinite(priceKopeks) ? priceKopeks : 0, + originalKopeks: Number.isFinite(originalKopeks) ? originalKopeks : null, + discountPercent, + priceLabel: option.price_label || option.priceLabel || null, + originalLabel: option.original_price_label || option.originalPriceLabel || option.base_price_label || null, + description: option.description || option.subtitle || null, + recommended: coerceBoolean(option.is_recommended ?? option.recommended ?? option.best_value ?? false, false), + disabled: coerceBoolean(option.disabled ?? option.is_disabled ?? option.unavailable ?? false, false), + }; + } + + function normalizeConfiguratorTrafficOption(option, index) { + if (!option || typeof option !== 'object') { + return null; + } + + const idRaw = option.id + ?? option.key + ?? option.code + ?? option.option_id + ?? option.optionId + ?? option.value + ?? null; + const id = idRaw != null ? String(idRaw) : `traffic-${index}`; + const valueGb = coerceNumber( + option.value + ?? option.gb + ?? option.limit + ?? option.traffic_gb + ?? option.trafficGb + ?? null, + null + ); + + const pricePerMonth = coercePositiveInt( + option.price_per_month_kopeks + ?? option.pricePerMonthKopeks + ?? option.price_kopeks + ?? option.priceKopeks + ?? option.final_price_kopeks + ?? option.discounted_price_kopeks + ?? option.amount_kopeks + ?? option.price + ?? 0, + 0 + ); + const originalPerMonth = coercePositiveInt( + option.original_price_per_month_kopeks + ?? option.originalPricePerMonthKopeks + ?? option.original_price_kopeks + ?? option.originalPriceKopeks + ?? option.base_price_per_month_kopeks + ?? option.price_before_discount_kopeks + ?? null, + null + ); + const discountPercent = coercePositiveInt( + option.discount_percent + ?? option.discountPercent + ?? option.discount, + null + ); + + return { + id, + valueGb, + label: option.label + || option.title + || option.name + || (valueGb === 0 + ? t('values.unlimited') + : (valueGb !== null ? `${valueGb} ${t('units.gb')}` : id)), + pricePerMonthKopeks: Number.isFinite(pricePerMonth) ? pricePerMonth : 0, + originalPricePerMonthKopeks: Number.isFinite(originalPerMonth) ? originalPerMonth : null, + discountPercent, + priceLabel: option.price_label || option.priceLabel || null, + originalLabel: option.original_price_label || option.originalPriceLabel || null, + description: option.description || option.subtitle || null, + isAvailable: coerceBoolean(option.is_available ?? option.available ?? option.enabled ?? option.selectable ?? true, true), + isIncluded: coerceBoolean(option.is_included ?? option.included ?? option.default ?? false, false), + recommended: coerceBoolean(option.is_recommended ?? option.recommended ?? false, false), + }; + } + + function normalizeConfiguratorServerOption(option, index) { + if (!option) { + return null; + } + const base = normalizeServerEntry(option); + const uuid = base?.uuid + || option.id + || option.code + || option.squad_uuid + || option.short_uuid + || option.shortUuid + || `server-${index}`; + const name = base?.name + || option.name + || option.title + || option.display_name + || option.displayName + || option.label + || uuid; + if (!uuid) { + return null; + } + + const pricePerMonth = coercePositiveInt( + option.price_per_month_kopeks + ?? option.pricePerMonthKopeks + ?? option.price_kopeks + ?? option.priceKopeks + ?? option.final_price_kopeks + ?? option.discounted_price_kopeks + ?? option.amount_kopeks + ?? option.price + ?? option.cost + ?? 0, + 0 + ); + const originalPerMonth = coercePositiveInt( + option.original_price_per_month_kopeks + ?? option.originalPricePerMonthKopeks + ?? option.original_price_kopeks + ?? option.originalPriceKopeks + ?? option.base_price_per_month_kopeks + ?? option.price_before_discount_kopeks + ?? null, + null + ); + const discountPercent = coercePositiveInt( + option.discount_percent + ?? option.discountPercent + ?? option.discount, + null + ); + + return { + uuid: String(uuid), + name, + pricePerMonthKopeks: Number.isFinite(pricePerMonth) ? pricePerMonth : 0, + originalPricePerMonthKopeks: Number.isFinite(originalPerMonth) ? originalPerMonth : null, + discountPercent, + priceLabel: option.price_label || option.priceLabel || null, + originalLabel: option.original_price_label || option.originalPriceLabel || null, + isAvailable: coerceBoolean(option.is_available ?? option.available ?? option.enabled ?? option.selectable ?? true, true), + isDefault: coerceBoolean(option.is_default ?? option.default ?? option.recommended ?? false, false), + disabledReason: option.disabled_reason || option.reason || null, + description: option.description || option.subtitle || null, + }; + } + + function normalizeSubscriptionConfiguratorPricing(root, currency) { + const pricing = root?.pricing || root?.price || root?.summary || {}; + return { + basePriceKopeks: coercePositiveInt( + pricing.base_price_kopeks + ?? pricing.basePriceKopeks + ?? null, + null + ), + baseOriginalPriceKopeks: coercePositiveInt( + pricing.base_original_price_kopeks + ?? pricing.baseOriginalPriceKopeks + ?? null, + null + ), + addonsPriceKopeks: coercePositiveInt( + pricing.addons_price_kopeks + ?? pricing.addonsPriceKopeks + ?? null, + null + ), + finalPriceKopeks: coercePositiveInt( + pricing.final_price_kopeks + ?? pricing.total_price_kopeks + ?? pricing.totalPriceKopeks + ?? root.total_price_kopeks + ?? root.totalPriceKopeks + ?? null, + null + ), + discountTotalKopeks: coercePositiveInt( + pricing.discount_total_kopeks + ?? pricing.discountTotalKopeks + ?? null, + null + ), + currency: (pricing.currency || currency || 'RUB').toString().toUpperCase(), + label: pricing.label || null, + }; + } + + function normalizeSubscriptionConfigurator(payload) { + if (!payload || typeof payload !== 'object') { + return null; + } + + const root = payload.subscription_configurator + || payload.subscriptionConfigurator + || payload.subscription_builder + || payload.subscriptionBuilder + || payload.configurator + || null; + if (!root || typeof root !== 'object') { + return null; + } + + const currency = (root.currency || payload.currency || userData?.balance_currency || 'RUB').toString().toUpperCase(); + + const periodOptionsRaw = ensureArray( + root.periods + || root.period_options + || root.periodOptions + || root.period?.options + || root.options?.periods + || [] + ); + const normalizedPeriods = periodOptionsRaw + .map((option, index) => normalizeConfiguratorPeriodOption(option, index)) + .filter(Boolean); + const periodModeRaw = root.period?.mode + ?? root.period_mode + ?? root.periodMode + ?? root.mode?.period + ?? null; + const periodMode = (periodModeRaw ? String(periodModeRaw).toLowerCase() : (normalizedPeriods.length > 1 ? 'selectable' : 'fixed')); + const selectedPeriodRaw = root.selected_period + ?? root.selected_period_id + ?? root.period?.selected + ?? root.period?.selected_id + ?? root.periodId + ?? root.period_id + ?? root.selections?.period + ?? null; + let selectedPeriodId = selectedPeriodRaw != null ? String(selectedPeriodRaw) : null; + if (periodMode === 'fixed') { + selectedPeriodId = normalizedPeriods[0]?.id || selectedPeriodId; + } else if (normalizedPeriods.length) { + const preferred = normalizedPeriods.find(option => option.id === selectedPeriodId && !option.disabled) + || normalizedPeriods.find(option => option.recommended && !option.disabled) + || normalizedPeriods.find(option => !option.disabled) + || normalizedPeriods[0]; + selectedPeriodId = preferred ? preferred.id : selectedPeriodId; + } + + const trafficOptionsRaw = ensureArray( + root.traffic?.options + || root.traffic_options + || root.available_traffic + || root.options?.traffic + || [] + ); + const normalizedTraffic = trafficOptionsRaw + .map((option, index) => normalizeConfiguratorTrafficOption(option, index)) + .filter(Boolean); + const trafficModeRaw = root.traffic?.mode + ?? root.traffic_mode + ?? root.trafficMode + ?? root.mode?.traffic + ?? null; + const trafficMode = (trafficModeRaw ? String(trafficModeRaw).toLowerCase() : (normalizedTraffic.length > 1 ? 'selectable' : 'fixed')); + const selectedTrafficRaw = root.selected_traffic + ?? root.selected_traffic_id + ?? root.traffic?.selected + ?? root.traffic?.selected_id + ?? root.selections?.traffic + ?? null; + let selectedTrafficId = selectedTrafficRaw != null ? String(selectedTrafficRaw) : null; + if (trafficMode === 'fixed') { + selectedTrafficId = normalizedTraffic[0]?.id || selectedTrafficId; + } else if (normalizedTraffic.length) { + const preferredTraffic = normalizedTraffic.find(option => option.id === selectedTrafficId && option.isAvailable) + || normalizedTraffic.find(option => option.recommended && option.isAvailable) + || normalizedTraffic.find(option => option.isAvailable) + || normalizedTraffic[0]; + selectedTrafficId = preferredTraffic ? preferredTraffic.id : selectedTrafficId; + } + + const serverOptionsRaw = ensureArray( + root.servers?.options + || root.servers?.available + || root.server_options + || root.available_servers + || root.available_squads + || root.options?.servers + || [] + ); + const normalizedServers = serverOptionsRaw + .map((option, index) => normalizeConfiguratorServerOption(option, index)) + .filter(Boolean); + const serversModeRaw = root.servers?.mode + ?? root.servers_mode + ?? root.serversMode + ?? root.mode?.servers + ?? null; + let serversMode = (serversModeRaw ? String(serversModeRaw).toLowerCase() : (normalizedServers.length > 1 ? 'selectable' : 'fixed')); + if (!normalizedServers.length) { + serversMode = 'fixed'; + } + + const minServers = coercePositiveInt( + root.servers?.min + ?? root.servers?.min_selectable + ?? root.min_servers + ?? root.serversMin + ?? 0, + 0 + ) || 0; + const maxServersValue = coercePositiveInt( + root.servers?.max + ?? root.servers?.max_selectable + ?? root.max_servers + ?? root.serversMax + ?? 0, + 0 + ) || 0; + const maxServers = maxServersValue > 0 ? maxServersValue : null; + + const selectedServersRaw = ensureArray( + root.servers?.selected + ?? root.servers?.current + ?? root.selected_servers + ?? root.selected_squads + ?? root.current_servers + ?? root.current_squads + ?? root.selections?.servers + ?? root.selections?.squads + ?? [] + ).map(item => String(item)).filter(Boolean); + const selectedServers = new Set(selectedServersRaw.filter(uuid => normalizedServers.some(server => server.uuid === uuid))); + + if (serversMode === 'fixed') { + if (!selectedServers.size && normalizedServers.length) { + normalizedServers.forEach(server => selectedServers.add(server.uuid)); + } + } else if (normalizedServers.length) { + if (!selectedServers.size) { + const selectableServers = normalizedServers.filter(server => server.isAvailable); + if (selectableServers.length) { + const required = Math.max(1, minServers || 1); + selectableServers.slice(0, required).forEach(server => selectedServers.add(server.uuid)); + } + } + if (minServers && selectedServers.size < minServers) { + const remaining = normalizedServers + .filter(server => server.isAvailable && !selectedServers.has(server.uuid)); + for (const server of remaining) { + selectedServers.add(server.uuid); + if (selectedServers.size >= minServers) { + break; + } + } + } + if (maxServers && selectedServers.size > maxServers) { + const keep = Array.from(selectedServers).slice(0, maxServers); + selectedServers.clear(); + keep.forEach(uuid => selectedServers.add(uuid)); + } + } + + const fixedServers = ensureArray( + root.servers?.fixed + ?? root.servers?.fixed_servers + ?? root.fixed_servers + ?? root.fixed_squads + ?? [] + ).map(entry => { + if (typeof entry === 'string') { + return entry; + } + if (entry && typeof entry === 'object') { + return entry.name || entry.title || entry.label || entry.uuid || ''; + } + return ''; + }).filter(Boolean); + + const devicesRoot = root.devices || root.device_options || root.options?.devices || {}; + const devicesMin = Math.max(1, coercePositiveInt( + devicesRoot.min + ?? devicesRoot.min_devices + ?? root.devices_min + ?? root.min_devices + ?? 1, + 1 + ) || 1); + const devicesMaxValue = coercePositiveInt( + devicesRoot.max + ?? devicesRoot.max_devices + ?? root.devices_max + ?? root.max_devices + ?? 0, + 0 + ); + const devicesMax = devicesMaxValue && devicesMaxValue >= devicesMin ? devicesMaxValue : Math.max(devicesMin, devicesMaxValue || devicesMin); + const devicesStep = Math.max(1, coercePositiveInt(devicesRoot.step ?? devicesRoot.increment ?? 1, 1) || 1); + const devicesIncluded = coercePositiveInt( + devicesRoot.included + ?? devicesRoot.base + ?? devicesRoot.default + ?? root.default_devices + ?? devicesMin, + devicesMin + ); + const devicesPricePerMonth = coercePositiveInt( + devicesRoot.price_per_device_per_month_kopeks + ?? devicesRoot.pricePerDevicePerMonthKopeks + ?? devicesRoot.price_per_month_kopeks + ?? devicesRoot.price_kopeks + ?? devicesRoot.priceKopeks + ?? 0, + 0 + ); + const devicesOriginalPerMonth = coercePositiveInt( + devicesRoot.original_price_per_device_per_month_kopeks + ?? devicesRoot.originalPricePerDevicePerMonthKopeks + ?? devicesRoot.original_price_per_month_kopeks + ?? devicesRoot.original_price_kopeks + ?? null, + null + ); + const devicesSelectedRaw = coercePositiveInt( + root.selected_devices + ?? root.device_limit + ?? devicesRoot.selected + ?? devicesRoot.value + ?? root.selections?.devices + ?? root.selections?.device_limit, + null + ); + const devicesSelected = clampValue( + devicesSelectedRaw ?? devicesIncluded ?? devicesMin, + devicesMin, + devicesMax + ); + + return { + enabled: coerceBoolean(root.enabled ?? root.available ?? true, true), + currency, + period: { + mode: periodMode === 'selectable' ? 'selectable' : 'fixed', + options: normalizedPeriods, + selectedId: selectedPeriodId, + fixedLabel: root.period?.fixed_label || root.period?.label || (periodMode === 'fixed' ? normalizedPeriods[0]?.label : null), + }, + traffic: { + mode: trafficMode === 'selectable' ? 'selectable' : 'fixed', + options: normalizedTraffic, + selectedId: selectedTrafficId, + fixedLabel: root.traffic?.fixed_label || root.traffic?.label || (trafficMode === 'fixed' ? normalizedTraffic[0]?.label : null), + }, + servers: { + mode: serversMode === 'selectable' ? 'selectable' : 'fixed', + options: normalizedServers, + min: minServers, + max: maxServers, + selected: Array.from(selectedServers), + fixed: fixedServers, + hint: root.servers?.hint || null, + }, + devices: { + min: devicesMin, + max: devicesMax, + step: devicesStep, + included: devicesIncluded, + pricePerDevicePerMonthKopeks: devicesPricePerMonth, + originalPricePerDevicePerMonthKopeks: devicesOriginalPerMonth, + note: devicesRoot.note || devicesRoot.description || null, + selected: devicesSelected, + }, + pricing: normalizeSubscriptionConfiguratorPricing(root, currency), + }; + } + + function resetSubscriptionConfiguratorSelections(data) { + subscriptionConfiguratorSelections.periodId = null; + subscriptionConfiguratorSelections.trafficId = null; + subscriptionConfiguratorSelections.servers = new Set(); + subscriptionConfiguratorSelections.devices = null; + + if (!data || !data.enabled) { + return; + } + + if (data.period?.selectedId) { + subscriptionConfiguratorSelections.periodId = data.period.selectedId; + } else if (data.period?.options?.length) { + subscriptionConfiguratorSelections.periodId = data.period.options[0].id; + } + + if (data.traffic?.selectedId) { + subscriptionConfiguratorSelections.trafficId = data.traffic.selectedId; + } else if (data.traffic?.options?.length) { + subscriptionConfiguratorSelections.trafficId = data.traffic.options[0].id; + } + + if (Array.isArray(data.servers?.selected)) { + subscriptionConfiguratorSelections.servers = new Set(data.servers.selected); + } else { + subscriptionConfiguratorSelections.servers = new Set(); + } + + if (subscriptionConfiguratorSelections.servers.size === 0 && Array.isArray(data.servers?.options)) { + const available = data.servers.options.filter(server => server.isAvailable); + if (available.length) { + const min = data.servers.min || 0; + const required = data.servers.mode === 'fixed' + ? available.length + : Math.max(1, min || 1); + available.slice(0, required).forEach(server => subscriptionConfiguratorSelections.servers.add(server.uuid)); + } + } + + const devicesMin = data.devices?.min || 1; + const devicesMax = data.devices?.max || devicesMin; + const defaultDevices = data.devices?.selected ?? data.devices?.included ?? devicesMin; + subscriptionConfiguratorSelections.devices = clampValue(defaultDevices, devicesMin, devicesMax); + } + + function getConfiguratorSelectedPeriod(data) { + if (!data?.period?.options?.length) { + return null; + } + const selectedId = subscriptionConfiguratorSelections.periodId; + return data.period.options.find(option => option.id === selectedId) + || data.period.options[0] + || null; + } + + function getConfiguratorSelectedTraffic(data) { + if (!data?.traffic?.options?.length) { + return null; + } + const selectedId = subscriptionConfiguratorSelections.trafficId; + return data.traffic.options.find(option => option.id === selectedId) + || data.traffic.options[0] + || null; + } + + function getConfiguratorSelectedServers(data) { + if (!data?.servers?.options?.length) { + return []; + } + const selectedSet = subscriptionConfiguratorSelections.servers instanceof Set + ? subscriptionConfiguratorSelections.servers + : new Set(); + return data.servers.options.filter(option => selectedSet.has(option.uuid)); + } + + function getConfiguratorSelectedDevices() { + return subscriptionConfiguratorSelections.devices ?? null; + } + + function shouldShowSubscriptionConfigurator() { + if (!subscriptionConfiguratorData || !subscriptionConfiguratorData.enabled) { + return false; + } + const statusRaw = String( + userData?.user?.subscription_actual_status + || userData?.user?.subscription_status + || '' + ).toLowerCase(); + if (!statusRaw) { + return true; + } + if (['trial', 'expired', 'disabled'].includes(statusRaw)) { + return true; + } + if (statusRaw === 'active') { + const typeRaw = String(userData?.subscription_type || '').toLowerCase(); + if (typeRaw === 'trial') { + return true; + } + } + return false; + } + + function renderSubscriptionConfigurator() { + const card = document.getElementById('subscriptionConfiguratorCard'); + if (!card) { + return; + } + + if (!shouldShowSubscriptionConfigurator()) { + card.classList.add('hidden'); + showSubscriptionConfiguratorStatus(null); + return; + } + + const data = subscriptionConfiguratorData; + if (!data || !data.enabled) { + card.classList.add('hidden'); + showSubscriptionConfiguratorStatus(null); + return; + } + + card.classList.remove('hidden'); + + renderSubscriptionConfiguratorPeriod(data); + renderSubscriptionConfiguratorTraffic(data); + renderSubscriptionConfiguratorServers(data); + renderSubscriptionConfiguratorDevices(data); + updateSubscriptionConfiguratorSummary(); + updateSubscriptionConfiguratorPricing(); + } + + function renderSubscriptionConfiguratorPeriod(data) { + const section = document.getElementById('subscriptionConfiguratorPeriodSection'); + const list = document.getElementById('subscriptionConfiguratorPeriodOptions'); + const fixedValue = document.getElementById('subscriptionConfiguratorPeriodFixed'); + const meta = document.getElementById('subscriptionConfiguratorPeriodMeta'); + if (!section) { + return; + } + + if (!data?.period?.options?.length) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + + const selected = getConfiguratorSelectedPeriod(data); + if (meta) { + if (selected?.months) { + const template = t('subscription_configurator.period.meta'); + if (template && template !== 'subscription_configurator.period.meta') { + meta.textContent = template.replace('{months}', String(selected.months)); + } else { + meta.textContent = `${selected.months} ${selected.months === 1 ? 'month' : 'months'}`; + } + } else { + meta.textContent = ''; + } + } + + if (data.period.mode === 'fixed') { + if (list) { + list.innerHTML = ''; + list.classList.add('hidden'); + } + if (fixedValue) { + fixedValue.textContent = data.period.fixedLabel || selected?.label || ''; + fixedValue.classList.remove('hidden'); + } + return; + } + + if (fixedValue) { + fixedValue.classList.add('hidden'); + fixedValue.textContent = ''; + } + + if (!list) { + return; + } + list.classList.remove('hidden'); + list.innerHTML = ''; + + data.period.options.forEach(option => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'subscription-configurator-option'; + if (option.disabled) { + button.classList.add('disabled'); + button.disabled = true; + } + if (subscriptionConfiguratorSelections.periodId === option.id) { + button.classList.add('active'); + } + + const title = document.createElement('div'); + title.className = 'subscription-configurator-option-title'; + title.textContent = option.label || option.id; + button.appendChild(title); + + if (option.description) { + const metaText = document.createElement('div'); + metaText.className = 'subscription-configurator-option-meta'; + metaText.textContent = option.description; + button.appendChild(metaText); + } + + const price = document.createElement('div'); + price.className = 'subscription-configurator-option-price'; + + const original = option.originalKopeks; + if (original !== null && original > option.priceKopeks) { + const originalSpan = document.createElement('span'); + originalSpan.className = 'original'; + originalSpan.textContent = option.originalLabel || formatPriceFromKopeks(original, data.currency); + price.appendChild(originalSpan); + } + + const currentSpan = document.createElement('span'); + currentSpan.className = 'current'; + currentSpan.textContent = option.priceLabel || formatPriceFromKopeks(option.priceKopeks, data.currency); + price.appendChild(currentSpan); + + if (option.discountPercent) { + const discountSpan = document.createElement('span'); + discountSpan.className = 'discount'; + discountSpan.textContent = `−${option.discountPercent}%`; + price.appendChild(discountSpan); + } + + button.appendChild(price); + + if (option.recommended) { + const badge = document.createElement('div'); + badge.className = 'subscription-settings-chip'; + const badgeKey = 'subscription_configurator.recommended'; + const label = t(badgeKey); + badge.textContent = label && label !== badgeKey ? label : 'Recommended'; + button.appendChild(badge); + } + + button.addEventListener('click', () => { + if (subscriptionConfiguratorSelections.periodId === option.id || option.disabled) { + return; + } + subscriptionConfiguratorSelections.periodId = option.id; + showSubscriptionConfiguratorStatus(null); + renderSubscriptionConfiguratorPeriod(data); + updateSubscriptionConfiguratorSummary(); + updateSubscriptionConfiguratorPricing(); + }); + + list.appendChild(button); + }); + } + + function renderSubscriptionConfiguratorTraffic(data) { + const section = document.getElementById('subscriptionConfiguratorTrafficSection'); + const list = document.getElementById('subscriptionConfiguratorTrafficOptions'); + const fixedValue = document.getElementById('subscriptionConfiguratorTrafficFixed'); + const meta = document.getElementById('subscriptionConfiguratorTrafficMeta'); + if (!section) { + return; + } + + if (!data?.traffic?.options?.length) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + + const selected = getConfiguratorSelectedTraffic(data); + if (meta) { + if (selected?.valueGb === 0) { + const unlimitedKey = 'subscription_configurator.traffic.unlimited'; + const unlimitedLabel = t(unlimitedKey); + meta.textContent = unlimitedLabel && unlimitedLabel !== unlimitedKey + ? unlimitedLabel + : t('values.unlimited'); + } else if (selected?.valueGb) { + meta.textContent = `${selected.valueGb} ${t('units.gb')}`; + } else { + meta.textContent = ''; + } + } + + if (data.traffic.mode === 'fixed') { + if (list) { + list.innerHTML = ''; + list.classList.add('hidden'); + } + if (fixedValue) { + fixedValue.textContent = data.traffic.fixedLabel || selected?.label || ''; + fixedValue.classList.remove('hidden'); + } + return; + } + + if (fixedValue) { + fixedValue.classList.add('hidden'); + fixedValue.textContent = ''; + } + + if (!list) { + return; + } + list.classList.remove('hidden'); + list.innerHTML = ''; + + data.traffic.options.forEach(option => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'subscription-configurator-option'; + if (!option.isAvailable) { + button.classList.add('disabled'); + button.disabled = true; + } + if (subscriptionConfiguratorSelections.trafficId === option.id) { + button.classList.add('active'); + } + + const title = document.createElement('div'); + title.className = 'subscription-configurator-option-title'; + title.textContent = option.label || option.id; + button.appendChild(title); + + if (option.description) { + const metaText = document.createElement('div'); + metaText.className = 'subscription-configurator-option-meta'; + metaText.textContent = option.description; + button.appendChild(metaText); + } + + const price = document.createElement('div'); + price.className = 'subscription-configurator-option-price'; + + const original = option.originalPricePerMonthKopeks; + if (original !== null && original > option.pricePerMonthKopeks) { + const originalSpan = document.createElement('span'); + originalSpan.className = 'original'; + originalSpan.textContent = option.originalLabel || formatPriceFromKopeks(original, data.currency); + price.appendChild(originalSpan); + } + + const currentSpan = document.createElement('span'); + currentSpan.className = 'current'; + currentSpan.textContent = option.priceLabel || formatPriceFromKopeks(option.pricePerMonthKopeks, data.currency); + price.appendChild(currentSpan); + + if (option.discountPercent) { + const discountSpan = document.createElement('span'); + discountSpan.className = 'discount'; + discountSpan.textContent = `−${option.discountPercent}%`; + price.appendChild(discountSpan); + } + + button.appendChild(price); + + button.addEventListener('click', () => { + if (!option.isAvailable) { + return; + } + if (subscriptionConfiguratorSelections.trafficId === option.id) { + return; + } + subscriptionConfiguratorSelections.trafficId = option.id; + showSubscriptionConfiguratorStatus(null); + renderSubscriptionConfiguratorTraffic(data); + updateSubscriptionConfiguratorSummary(); + updateSubscriptionConfiguratorPricing(); + }); + + list.appendChild(button); + }); + } + + function renderSubscriptionConfiguratorServers(data) { + const section = document.getElementById('subscriptionConfiguratorServersSection'); + const list = document.getElementById('subscriptionConfiguratorServersOptions'); + const fixedValue = document.getElementById('subscriptionConfiguratorServersFixed'); + const hintElement = document.getElementById('subscriptionConfiguratorServersHint'); + const meta = document.getElementById('subscriptionConfiguratorServersMeta'); + if (!section) { + return; + } + + if (!data?.servers?.options?.length) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + + if (hintElement) { + hintElement.textContent = data.servers.hint || ''; + hintElement.classList.toggle('hidden', !hintElement.textContent); + } + + if (meta) { + const min = data.servers.min || 0; + const max = data.servers.max || null; + let metaText = ''; + if (min && max && min === max) { + const key = 'subscription_configurator.servers.meta.exact'; + const template = t(key); + metaText = template && template !== key + ? template.replace('{count}', String(min)) + : `Select ${min}`; + } else if (min && max) { + const key = 'subscription_configurator.servers.meta.range'; + const template = t(key); + metaText = template && template !== key + ? template.replace('{min}', String(min)).replace('{max}', String(max)) + : `Select ${min}-${max}`; + } else if (min) { + const key = 'subscription_configurator.servers.meta.min'; + const template = t(key); + metaText = template && template !== key + ? template.replace('{count}', String(min)) + : `Select at least ${min}`; + } else if (max) { + const key = 'subscription_configurator.servers.meta.max'; + const template = t(key); + metaText = template && template !== key + ? template.replace('{count}', String(max)) + : `Up to ${max}`; + } else { + metaText = ''; + } + meta.textContent = metaText; + } + + if (data.servers.mode === 'fixed') { + if (list) { + list.innerHTML = ''; + list.classList.add('hidden'); + } + if (fixedValue) { + const selectedServers = getConfiguratorSelectedServers(data); + const names = selectedServers.map(server => server.name).join(', ') || data.servers.fixed?.join(', ') || ''; + const key = selectedServers.length === 1 + ? 'subscription_configurator.servers.fixed_single' + : 'subscription_configurator.servers.fixed_multiple'; + const template = t(key); + fixedValue.textContent = template && template !== key + ? template.replace('{list}', names) + : names; + fixedValue.classList.remove('hidden'); + } + return; + } + + if (fixedValue) { + fixedValue.classList.add('hidden'); + fixedValue.textContent = ''; + } + + if (!list) { + return; + } + list.classList.remove('hidden'); + list.innerHTML = ''; + + const selectedSet = subscriptionConfiguratorSelections.servers instanceof Set + ? subscriptionConfiguratorSelections.servers + : new Set(); + const min = data.servers.min || 0; + const max = data.servers.max || null; + + data.servers.options.forEach(option => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'subscription-configurator-option'; + if (!option.isAvailable) { + button.classList.add('disabled'); + button.disabled = true; + } + if (selectedSet.has(option.uuid)) { + button.classList.add('active'); + } + + const title = document.createElement('div'); + title.className = 'subscription-configurator-option-title'; + title.textContent = option.name || option.uuid; + button.appendChild(title); + + if (option.description) { + const metaText = document.createElement('div'); + metaText.className = 'subscription-configurator-option-meta'; + metaText.textContent = option.description; + button.appendChild(metaText); + } + + const price = document.createElement('div'); + price.className = 'subscription-configurator-option-price'; + + const original = option.originalPricePerMonthKopeks; + if (original !== null && original > option.pricePerMonthKopeks) { + const originalSpan = document.createElement('span'); + originalSpan.className = 'original'; + originalSpan.textContent = option.originalLabel || formatPriceFromKopeks(original, data.currency); + price.appendChild(originalSpan); + } + + const currentSpan = document.createElement('span'); + currentSpan.className = 'current'; + currentSpan.textContent = option.priceLabel || formatPriceFromKopeks(option.pricePerMonthKopeks, data.currency); + price.appendChild(currentSpan); + + if (option.discountPercent) { + const discountSpan = document.createElement('span'); + discountSpan.className = 'discount'; + discountSpan.textContent = `−${option.discountPercent}%`; + price.appendChild(discountSpan); + } + + button.appendChild(price); + + button.addEventListener('click', () => { + if (!option.isAvailable) { + return; + } + const alreadySelected = selectedSet.has(option.uuid); + if (alreadySelected) { + if (selectedSet.size <= (min || 0)) { + const key = 'subscription_configurator.status.validation.servers_min'; + const template = t(key); + const message = template && template !== key + ? template.replace('{count}', String(min || 1)) + : `At least ${min || 1} server required.`; + showSubscriptionConfiguratorStatus(message, 'error'); + return; + } + selectedSet.delete(option.uuid); + } else { + if (max && selectedSet.size >= max) { + const key = 'subscription_configurator.status.validation.servers_max'; + const template = t(key); + const message = template && template !== key + ? template.replace('{count}', String(max)) + : `Maximum ${max} servers.`; + showSubscriptionConfiguratorStatus(message, 'error'); + return; + } + selectedSet.add(option.uuid); + } + + subscriptionConfiguratorSelections.servers = new Set(selectedSet); + showSubscriptionConfiguratorStatus(null); + renderSubscriptionConfiguratorServers(data); + updateSubscriptionConfiguratorSummary(); + updateSubscriptionConfiguratorPricing(); + }); + + list.appendChild(button); + }); + } + + function renderSubscriptionConfiguratorDevices(data) { + const section = document.getElementById('subscriptionConfiguratorDevicesSection'); + if (!section) { + return; + } + + const decreaseBtn = document.getElementById('subscriptionConfiguratorDevicesDecrease'); + const increaseBtn = document.getElementById('subscriptionConfiguratorDevicesIncrease'); + const valueElement = document.getElementById('subscriptionConfiguratorDevicesValue'); + const noteElement = document.getElementById('subscriptionConfiguratorDevicesNote'); + const meta = document.getElementById('subscriptionConfiguratorDevicesMeta'); + if (!data?.devices) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + + const current = subscriptionConfiguratorSelections.devices ?? data.devices.selected ?? data.devices.min; + const min = data.devices.min ?? 1; + const max = data.devices.max ?? min; + const step = data.devices.step ?? 1; + const included = data.devices.included ?? min; + + if (valueElement) { + valueElement.textContent = String(current); + } + + if (noteElement) { + const key = 'subscription_configurator.devices.included'; + const template = t(key); + const message = template && template !== key + ? template.replace('{count}', String(included)) + : `Included: ${included}`; + noteElement.textContent = message; + noteElement.classList.toggle('hidden', !message); + } + + if (meta) { + const metaKey = 'subscription_configurator.devices.meta'; + const template = t(metaKey); + meta.textContent = template && template !== metaKey + ? template.replace('{min}', String(min)).replace('{max}', String(max)) + : `${min}–${max}`; + } + + if (decreaseBtn) { + decreaseBtn.disabled = current <= min; + decreaseBtn.onclick = () => { + setConfiguratorDeviceCount(current - step); + }; + } + + if (increaseBtn) { + increaseBtn.disabled = current >= max; + increaseBtn.onclick = () => { + setConfiguratorDeviceCount(current + step); + }; + } + } + + function setConfiguratorDeviceCount(next) { + if (!subscriptionConfiguratorData) { + return; + } + const min = subscriptionConfiguratorData.devices?.min ?? 1; + const max = subscriptionConfiguratorData.devices?.max ?? min; + const clamped = clampValue(next, min, max); + if (clamped === subscriptionConfiguratorSelections.devices) { + return; + } + subscriptionConfiguratorSelections.devices = clamped; + showSubscriptionConfiguratorStatus(null); + renderSubscriptionConfiguratorDevices(subscriptionConfiguratorData); + updateSubscriptionConfiguratorSummary(); + updateSubscriptionConfiguratorPricing(); + } + + function updateSubscriptionConfiguratorSummary() { + const summary = document.getElementById('subscriptionConfiguratorSummary'); + if (!summary) { + return; + } + + if (!shouldShowSubscriptionConfigurator()) { + const key = 'subscription_configurator.summary.placeholder'; + const fallback = t(key); + summary.textContent = fallback && fallback !== key + ? fallback + : 'Choose parameters to see the final price.'; + return; + } + + const data = subscriptionConfiguratorData; + const parts = []; + + const period = getConfiguratorSelectedPeriod(data); + if (period) { + const key = 'subscription_configurator.summary.period'; + const template = t(key); + const part = template && template !== key + ? template.replace('{label}', period.label) + : period.label; + if (part) { + parts.push(part); + } + } + + const traffic = getConfiguratorSelectedTraffic(data); + if (traffic) { + const key = 'subscription_configurator.summary.traffic'; + const template = t(key); + const label = traffic.label || (traffic.valueGb === 0 ? t('values.unlimited') : `${traffic.valueGb} ${t('units.gb')}`); + const part = template && template !== key + ? template.replace('{label}', label) + : label; + if (part) { + parts.push(part); + } + } + + const servers = getConfiguratorSelectedServers(data); + if (servers.length) { + const names = servers.map(server => server.name).join(', '); + const key = servers.length === 1 + ? 'subscription_configurator.summary.servers_one' + : 'subscription_configurator.summary.servers'; + const template = t(key); + const part = template && template !== key + ? template.replace('{count}', String(servers.length)).replace('{list}', names) + : names; + if (part) { + parts.push(part); + } + } + + const devices = getConfiguratorSelectedDevices(); + if (devices !== null) { + const key = devices === 1 + ? 'subscription_configurator.summary.devices_one' + : 'subscription_configurator.summary.devices'; + const template = t(key); + const part = template && template !== key + ? template.replace('{count}', String(devices)) + : `${devices}`; + if (part) { + parts.push(part); + } + } + + if (!parts.length) { + const key = 'subscription_configurator.summary.placeholder'; + const fallback = t(key); + summary.textContent = fallback && fallback !== key + ? fallback + : 'Choose parameters to see the final price.'; + return; + } + + summary.textContent = ''; + const strong = document.createElement('strong'); + strong.textContent = parts.join(' • '); + summary.appendChild(strong); + } + + function calculateSubscriptionConfiguratorPrice(data, selections) { + const period = getConfiguratorSelectedPeriod(data); + const months = period?.months || 1; + const basePrice = Number.isFinite(period?.priceKopeks) ? period.priceKopeks : 0; + const baseOriginal = Number.isFinite(period?.originalKopeks) ? period.originalKopeks : basePrice; + + const servers = getConfiguratorSelectedServers(data); + let serversPrice = 0; + let serversOriginal = 0; + servers.forEach(server => { + const price = Number.isFinite(server.pricePerMonthKopeks) ? server.pricePerMonthKopeks : 0; + serversPrice += price * months; + const original = Number.isFinite(server.originalPricePerMonthKopeks) + ? server.originalPricePerMonthKopeks + : price; + serversOriginal += original * months; + }); + + const traffic = getConfiguratorSelectedTraffic(data); + let trafficPrice = 0; + let trafficOriginal = 0; + if (traffic) { + const price = Number.isFinite(traffic.pricePerMonthKopeks) ? traffic.pricePerMonthKopeks : 0; + const original = Number.isFinite(traffic.originalPricePerMonthKopeks) + ? traffic.originalPricePerMonthKopeks + : price; + trafficPrice = price * months; + trafficOriginal = original * months; + } + + const devicesCount = selections.devices ?? data.devices?.selected ?? data.devices?.min ?? 1; + const included = data.devices?.included ?? data.devices?.min ?? 1; + const additionalDevices = Math.max(0, devicesCount - included); + const devicePricePerMonth = Number.isFinite(data.devices?.pricePerDevicePerMonthKopeks) + ? data.devices.pricePerDevicePerMonthKopeks + : 0; + const deviceOriginalPerMonth = Number.isFinite(data.devices?.originalPricePerDevicePerMonthKopeks) + ? data.devices.originalPricePerDevicePerMonthKopeks + : devicePricePerMonth; + const devicesPrice = additionalDevices * devicePricePerMonth * months; + const devicesOriginal = additionalDevices * deviceOriginalPerMonth * months; + + const finalPrice = basePrice + serversPrice + trafficPrice + devicesPrice; + const originalPrice = (baseOriginal ?? basePrice) + serversOriginal + trafficOriginal + devicesOriginal; + const discount = Math.max(0, originalPrice - finalPrice); + + return { + finalPrice, + originalPrice, + discount, + months, + basePrice, + baseOriginal, + serversPrice, + serversOriginal, + trafficPrice, + trafficOriginal, + devicesPrice, + devicesOriginal, + }; + } + + function updateSubscriptionConfiguratorPricing() { + const data = subscriptionConfiguratorData; + if (!data || !shouldShowSubscriptionConfigurator()) { + return; + } + + const priceElement = document.getElementById('subscriptionConfiguratorPriceCurrent'); + const originalElement = document.getElementById('subscriptionConfiguratorPriceOriginal'); + const discountElement = document.getElementById('subscriptionConfiguratorPriceDiscount'); + const discountBadge = document.getElementById('subscriptionConfiguratorDiscountBadge'); + if (!priceElement) { + return; + } + + const result = calculateSubscriptionConfiguratorPrice(data, subscriptionConfiguratorSelections); + const currency = data.currency || userData?.balance_currency || 'RUB'; + priceElement.textContent = formatPriceFromKopeks(result.finalPrice, currency); + + if (originalElement) { + if (result.originalPrice > result.finalPrice) { + originalElement.textContent = formatPriceFromKopeks(result.originalPrice, currency); + originalElement.classList.remove('hidden'); + } else { + originalElement.classList.add('hidden'); + } + } + + if (discountElement) { + if (result.discount > 0) { + const key = 'subscription_configurator.price.discount'; + const template = t(key); + const message = template && template !== key + ? template.replace('{value}', formatPriceFromKopeks(result.discount, currency)) + : `Discount: ${formatPriceFromKopeks(result.discount, currency)}`; + discountElement.textContent = message; + discountElement.classList.remove('hidden'); + } else { + discountElement.classList.add('hidden'); + discountElement.textContent = ''; + } + } + + if (discountBadge) { + const percent = result.originalPrice > 0 + ? Math.round((1 - result.finalPrice / result.originalPrice) * 100) + : 0; + if (percent > 0) { + const key = 'subscription_configurator.discount.badge'; + const template = t(key); + discountBadge.textContent = template && template !== key + ? template.replace('{percent}', String(percent)) + : `-${percent}%`; + discountBadge.classList.remove('hidden'); + } else { + discountBadge.classList.add('hidden'); + discountBadge.textContent = ''; + } + } + + const balanceWarning = document.getElementById('subscriptionConfiguratorBalanceWarning'); + const balanceText = document.getElementById('subscriptionConfiguratorBalanceText'); + if (balanceWarning && balanceText) { + const balanceKopeks = coercePositiveInt(userData?.balance_kopeks, 0); + if (balanceKopeks < result.finalPrice) { + const missing = result.finalPrice - balanceKopeks; + const key = 'subscription_configurator.balance.insufficient'; + const template = t(key); + const message = template && template !== key + ? template.replace('{amount}', formatPriceFromKopeks(missing, currency)) + : `Not enough balance. Missing ${formatPriceFromKopeks(missing, currency)}.`; + balanceText.textContent = message; + balanceWarning.classList.remove('hidden'); + } else { + balanceWarning.classList.add('hidden'); + balanceText.textContent = ''; + } + } + + const button = document.getElementById('subscriptionConfiguratorPurchaseButton'); + if (button) { + button.disabled = subscriptionConfiguratorLoading; + const key = result.finalPrice <= 0 + ? 'subscription_configurator.action.activate' + : 'subscription_configurator.action.purchase'; + const label = t(key); + button.textContent = label && label !== key + ? label + : (result.finalPrice <= 0 ? 'Activate' : 'Buy subscription'); + } + } + + function showSubscriptionConfiguratorStatus(message, variant = 'info') { + const element = document.getElementById('subscriptionConfiguratorStatus'); + if (!element) { + return; + } + + if (!message) { + element.textContent = ''; + element.classList.add('hidden'); + element.classList.remove('error', 'success', 'info'); + return; + } + + element.textContent = message; + element.classList.remove('hidden', 'error', 'success', 'info'); + element.classList.add(variant); + } + + function resolveSubscriptionConfiguratorError(payload, status) { + if (status === 401) { + const key = 'subscription_configurator.error.unauthorized'; + const message = t(key); + return message && message !== key + ? message + : 'Authorization error. Please reopen the mini app.'; + } + if (status === 402) { + const key = 'subscription_configurator.error.insufficient_funds'; + const message = t(key); + return message && message !== key + ? message + : 'Not enough balance to complete the purchase.'; + } + if (!payload || typeof payload !== 'object') { + const key = 'subscription_configurator.error.generic'; + const message = t(key); + return message && message !== key + ? message + : 'Failed to complete the purchase. Please try again later.'; + } + if (typeof payload.detail === 'string') { + return payload.detail; + } + if (payload.detail && typeof payload.detail === 'object') { + if (typeof payload.detail.message === 'string') { + return payload.detail.message; + } + if (typeof payload.detail.error === 'string') { + return payload.detail.error; + } + } + if (typeof payload.message === 'string') { + return payload.message; + } + if (typeof payload.error === 'string') { + return payload.error; + } + const key = 'subscription_configurator.error.generic'; + const message = t(key); + return message && message !== key + ? message + : 'Failed to complete the purchase. Please try again later.'; + } + + async function submitSubscriptionConfiguratorPurchase() { + if (subscriptionConfiguratorLoading) { + return; + } + + if (!subscriptionConfiguratorData || !shouldShowSubscriptionConfigurator()) { + return; + } + + const initData = tg.initData || ''; + if (!initData) { + const key = 'subscription_configurator.error.unauthorized'; + const message = t(key); + showSubscriptionConfiguratorStatus( + message && message !== key ? message : 'Authorization error. Please reopen the mini app.', + 'error' + ); + return; + } + + const payload = { + initData, + periodId: subscriptionConfiguratorSelections.periodId, + trafficId: subscriptionConfiguratorSelections.trafficId, + servers: Array.from(subscriptionConfiguratorSelections.servers || []), + devices: subscriptionConfiguratorSelections.devices, + }; + + const period = getConfiguratorSelectedPeriod(subscriptionConfiguratorData); + if (period?.days) { + payload.periodDays = period.days; + } + if (period?.months) { + payload.periodMonths = period.months; + } + + const traffic = getConfiguratorSelectedTraffic(subscriptionConfiguratorData); + if (traffic && Number.isFinite(traffic.valueGb)) { + payload.trafficGb = traffic.valueGb; + } + + subscriptionConfiguratorLoading = true; + updateSubscriptionConfiguratorPricing(); + const statusKey = 'subscription_configurator.status.loading'; + const statusMessage = t(statusKey); + showSubscriptionConfiguratorStatus( + statusMessage && statusMessage !== statusKey + ? statusMessage + : 'Processing purchase...', + 'info' + ); + + try { + const response = await fetch('/miniapp/subscription/purchase', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const result = await response.json().catch(() => ({})); + + if (!response.ok) { + const message = resolveSubscriptionConfiguratorError(result, response.status); + showSubscriptionConfiguratorStatus(message, 'error'); + if (response.status === 402) { + const warning = document.getElementById('subscriptionConfiguratorBalanceWarning'); + if (warning) { + warning.classList.remove('hidden'); + } + } + return; + } + + const key = 'subscription_configurator.success'; + const defaultMessage = t(key); + const successMessage = typeof result?.message === 'string' + ? result.message + : (defaultMessage && defaultMessage !== key + ? defaultMessage + : 'Subscription purchased successfully!'); + showSubscriptionConfiguratorStatus(successMessage, 'success'); + + await refreshSubscriptionData({ silent: true }); + } catch (error) { + console.error('Subscription purchase failed:', error); + const key = 'subscription_configurator.error.generic'; + const message = t(key); + showSubscriptionConfiguratorStatus( + message && message !== key + ? message + : 'Failed to complete the purchase. Please try again later.', + 'error' + ); + } finally { + subscriptionConfiguratorLoading = false; + updateSubscriptionConfiguratorPricing(); + } + } + function extractSettingsError(payload, status) { if (status === 401) { return t('subscription_settings.error.unauthorized'); @@ -10824,6 +12914,14 @@ openExternalLink(link, { openInMiniApp: true }); }); + document.getElementById('subscriptionConfiguratorTopupButton')?.addEventListener('click', () => { + openTopupModal(); + }); + + document.getElementById('subscriptionConfiguratorPurchaseButton')?.addEventListener('click', () => { + submitSubscriptionConfiguratorPurchase(); + }); + initializePromoCodeForm(); init();