diff --git a/miniapp/index.html b/miniapp/index.html
index b1f33202..5058bea8 100644
--- a/miniapp/index.html
+++ b/miniapp/index.html
@@ -705,6 +705,368 @@
color: #fff;
}
+ .purchase-card .card-header {
+ cursor: default;
+ }
+
+ .purchase-card-subtitle {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-top: 6px;
+ line-height: 1.5;
+ }
+
+ .purchase-content {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding-top: 12px;
+ }
+
+ .purchase-loading {
+ padding: 28px 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ text-align: center;
+ }
+
+ .purchase-loading .spinner {
+ width: 42px;
+ height: 42px;
+ }
+
+ .purchase-loading-text {
+ font-size: 14px;
+ color: var(--text-secondary);
+ font-weight: 600;
+ }
+
+ .purchase-error {
+ padding: 16px;
+ border-radius: var(--radius);
+ background: rgba(var(--danger-rgb), 0.08);
+ border: 1px solid rgba(var(--danger-rgb), 0.2);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .purchase-error-text {
+ font-size: 14px;
+ color: var(--text-primary);
+ line-height: 1.5;
+ }
+
+ .purchase-retry-button {
+ align-self: flex-start;
+ padding: 10px 16px;
+ border-radius: var(--radius);
+ border: none;
+ background: var(--primary);
+ color: #fff;
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: var(--shadow-sm);
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ }
+
+ .purchase-retry-button:hover {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+ }
+
+ .purchase-section {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 18px;
+ }
+
+ .purchase-section:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+
+ .purchase-section-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+ }
+
+ .purchase-section-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+ }
+
+ .purchase-section-description {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-top: 4px;
+ line-height: 1.5;
+ }
+
+ .purchase-section-meta {
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+
+ .purchase-options-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+
+ .purchase-option {
+ flex: 1 1 calc(50% - 12px);
+ min-width: 140px;
+ padding: 14px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border-color);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ text-align: left;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ transition: all 0.2s ease;
+ }
+
+ .purchase-option:hover {
+ border-color: rgba(var(--primary-rgb), 0.6);
+ box-shadow: var(--shadow-sm);
+ }
+
+ .purchase-option.active {
+ border-color: transparent;
+ background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.16), rgba(var(--primary-rgb), 0.08));
+ box-shadow: var(--shadow-md);
+ }
+
+ .purchase-option-title {
+ font-size: 15px;
+ font-weight: 700;
+ }
+
+ .purchase-option-price {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ font-weight: 700;
+ }
+
+ .purchase-option-price s {
+ font-size: 12px;
+ color: var(--text-secondary);
+ opacity: 0.7;
+ }
+
+ .purchase-option-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ }
+
+ .purchase-option-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ .purchase-option-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ background: rgba(var(--primary-rgb), 0.12);
+ color: var(--primary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+
+ .purchase-meta-note {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ }
+
+ .purchase-servers-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .purchase-chip {
+ border-radius: 999px;
+ padding: 8px 14px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .purchase-chip:hover {
+ border-color: rgba(var(--primary-rgb), 0.6);
+ box-shadow: var(--shadow-sm);
+ }
+
+ .purchase-chip.active {
+ background: var(--primary);
+ border-color: transparent;
+ color: #fff;
+ box-shadow: var(--shadow-md);
+ }
+
+ .purchase-stepper {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .purchase-stepper button {
+ width: 40px;
+ height: 40px;
+ 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;
+ }
+
+ .purchase-stepper button:hover:not(:disabled) {
+ border-color: rgba(var(--primary-rgb), 0.6);
+ box-shadow: var(--shadow-sm);
+ }
+
+ .purchase-stepper button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .purchase-stepper-value {
+ font-size: 20px;
+ font-weight: 700;
+ }
+
+ .purchase-summary {
+ border-radius: var(--radius-lg);
+ background: rgba(var(--primary-rgb), 0.08);
+ padding: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .purchase-summary-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ }
+
+ .purchase-summary-total {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+ }
+
+ .purchase-summary-original {
+ font-size: 14px;
+ color: var(--text-secondary);
+ text-decoration: line-through;
+ }
+
+ .purchase-summary-discount {
+ font-size: 13px;
+ color: var(--success);
+ font-weight: 600;
+ }
+
+ .purchase-summary-line {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ }
+
+ .purchase-summary-line strong {
+ color: var(--text-primary);
+ }
+
+ .purchase-discount-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ .purchase-discount-tag {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--primary);
+ background: rgba(var(--primary-rgb), 0.12);
+ border-radius: 999px;
+ padding: 6px 10px;
+ }
+
+ .purchase-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .purchase-actions .btn {
+ width: 100%;
+ }
+
+ .purchase-validation {
+ font-size: 13px;
+ color: var(--danger);
+ line-height: 1.5;
+ }
+
+ .purchase-empty {
+ font-size: 13px;
+ color: var(--text-secondary);
+ }
+
+ :root[data-theme="dark"] .purchase-option,
+ :root[data-theme="dark"] .purchase-chip {
+ background: rgba(15, 23, 42, 0.8);
+ border-color: rgba(148, 163, 184, 0.25);
+ }
+
+ :root[data-theme="dark"] .purchase-option.active,
+ :root[data-theme="dark"] .purchase-chip.active {
+ background: rgba(var(--primary-rgb), 0.25);
+ color: #fff;
+ }
+
+ :root[data-theme="dark"] .purchase-summary {
+ background: rgba(var(--primary-rgb), 0.16);
+ }
+
+ :root[data-theme="dark"] .purchase-error {
+ background: rgba(var(--danger-rgb), 0.18);
+ border-color: rgba(var(--danger-rgb), 0.35);
+ }
+
.promo-offers {
display: flex;
flex-direction: column;
@@ -3345,6 +3707,87 @@
+
@@ -3977,6 +4420,40 @@
'topup.status.retry': 'Try again',
'topup.done': 'Done',
'button.buy_subscription': 'Buy Subscription',
+ 'purchase.title': 'Configure your subscription',
+ 'purchase.subtitle': 'Choose the plan parameters and activate a subscription instantly.',
+ 'purchase.loading': 'Loading purchase options…',
+ 'purchase.error.generic': 'Unable to load purchase options. Please try again later.',
+ 'purchase.action.retry': 'Try again',
+ 'purchase.action.buy': 'Buy subscription',
+ 'purchase.action.topup': 'Top up balance',
+ 'purchase.period.title': 'Subscription period',
+ 'purchase.period.subtitle': 'Select how long you need VPN access.',
+ 'purchase.traffic.title': 'Traffic',
+ 'purchase.traffic.subtitle': 'Choose a monthly traffic package.',
+ 'purchase.traffic.fixed': 'Monthly traffic: {amount}',
+ 'purchase.servers.title': 'Servers',
+ 'purchase.servers.subtitle': 'Select available connection regions.',
+ 'purchase.servers.single': 'Server: {name}',
+ 'purchase.servers.meta': '{selected} of {max} selected',
+ 'purchase.servers.meta_unlimited': '{selected} selected',
+ 'purchase.devices.title': 'Devices',
+ 'purchase.devices.subtitle': 'Number of simultaneously connected devices.',
+ 'purchase.devices.meta': 'Up to {max} devices',
+ 'purchase.summary.total': 'Total',
+ 'purchase.summary.original': 'Without discounts',
+ 'purchase.summary.discount': 'You save {amount}',
+ 'purchase.summary.balance': 'Balance',
+ 'purchase.summary.balance_after': 'After purchase',
+ 'purchase.summary.missing': 'Top up needed',
+ 'purchase.validation.period': 'Select a subscription period.',
+ 'purchase.validation.traffic': 'Select a traffic package.',
+ 'purchase.validation.servers_min': 'Select at least {min} server(s).',
+ 'purchase.validation.devices': 'Select the number of devices.',
+ 'purchase.discount.promo_group': 'Promo group discount',
+ 'purchase.discount.promo_offer': 'Promo offer',
+ 'purchase.discount.active_offer': 'Active discount: {percent}%',
+ 'purchase.empty': 'No purchase options are available yet.',
'card.balance.title': 'Balance',
'subscription_settings.title': 'Subscription settings',
'subscription_settings.summary.servers': 'Servers: {count}',
@@ -4258,6 +4735,40 @@
'topup.status.retry': 'Повторить попытку',
'topup.done': 'Готово',
'button.buy_subscription': 'Купить подписку',
+ 'purchase.title': 'Оформление подписки',
+ 'purchase.subtitle': 'Выберите параметры тарифа и подключите подписку в пару кликов.',
+ 'purchase.loading': 'Загружаем варианты подписки…',
+ 'purchase.error.generic': 'Не удалось загрузить варианты подписки. Попробуйте позже.',
+ 'purchase.action.retry': 'Попробовать снова',
+ 'purchase.action.buy': 'Оформить подписку',
+ 'purchase.action.topup': 'Пополнить баланс',
+ 'purchase.period.title': 'Период подписки',
+ 'purchase.period.subtitle': 'Выберите, на какой срок нужна подписка.',
+ 'purchase.traffic.title': 'Трафик',
+ 'purchase.traffic.subtitle': 'Выберите ежемесячный пакет трафика.',
+ 'purchase.traffic.fixed': 'Трафик в месяц: {amount}',
+ 'purchase.servers.title': 'Серверы',
+ 'purchase.servers.subtitle': 'Выберите доступные регионы подключения.',
+ 'purchase.servers.single': 'Сервер: {name}',
+ 'purchase.servers.meta': 'Выбрано: {selected} из {max}',
+ 'purchase.servers.meta_unlimited': 'Выбрано: {selected}',
+ 'purchase.devices.title': 'Устройства',
+ 'purchase.devices.subtitle': 'Сколько устройств подключится одновременно.',
+ 'purchase.devices.meta': 'До {max} устройств',
+ 'purchase.summary.total': 'Итого',
+ 'purchase.summary.original': 'Без скидок',
+ 'purchase.summary.discount': 'Вы экономите {amount}',
+ 'purchase.summary.balance': 'Баланс',
+ 'purchase.summary.balance_after': 'После оплаты',
+ 'purchase.summary.missing': 'Не хватает',
+ 'purchase.validation.period': 'Выберите период подписки.',
+ 'purchase.validation.traffic': 'Выберите пакет трафика.',
+ 'purchase.validation.servers_min': 'Выберите минимум {min} сервер(а).',
+ 'purchase.validation.devices': 'Укажите количество устройств.',
+ 'purchase.discount.promo_group': 'Скидка промогруппы',
+ 'purchase.discount.promo_offer': 'Промо-предложение',
+ 'purchase.discount.active_offer': 'Активная скидка: {percent}%',
+ 'purchase.empty': 'Варианты для покупки пока недоступны.',
'card.balance.title': 'Баланс',
'subscription_settings.title': 'Настройка подписки',
'subscription_settings.summary.servers': 'Серверов: {count}',
@@ -4604,6 +5115,18 @@
devices: null,
};
+ let subscriptionPurchaseConfig = null;
+ let subscriptionPurchasePromise = null;
+ let subscriptionPurchaseError = null;
+ const subscriptionPurchaseSelections = {
+ period: null,
+ traffic: null,
+ servers: new Set(),
+ devices: null,
+ };
+ let subscriptionPurchaseSubmitting = false;
+ let subscriptionPurchaseLastTotals = null;
+
const PAYMENT_STATUS_INITIAL_DELAY_MS = 2000;
const PAYMENT_STATUS_POLL_INTERVAL_MS = 5000;
const PAYMENT_STATUS_TIMEOUT_MS = 180000;
@@ -5227,6 +5750,362 @@
return trimmed.length ? trimmed : null;
}
+ function getPurchaseRoot() {
+ if (!subscriptionPurchaseConfig || typeof subscriptionPurchaseConfig !== 'object') {
+ return null;
+ }
+ if (subscriptionPurchaseConfig.config && typeof subscriptionPurchaseConfig.config === 'object') {
+ return subscriptionPurchaseConfig.config;
+ }
+ return subscriptionPurchaseConfig;
+ }
+
+ function getPurchaseCurrency() {
+ const root = getPurchaseRoot();
+ const raw = root?.currency
+ || root?.pricing?.currency
+ || root?.balance_currency
+ || userData?.balance_currency
+ || 'RUB';
+ return String(raw || 'RUB').toUpperCase();
+ }
+
+ function getFirstDefined(source, fields) {
+ if (!source) {
+ return undefined;
+ }
+ for (const field of fields) {
+ if (Object.prototype.hasOwnProperty.call(source, field)) {
+ const value = source[field];
+ if (value !== undefined && value !== null && value !== '') {
+ return value;
+ }
+ }
+ }
+ return undefined;
+ }
+
+ function getOptionLabel(option, fallback) {
+ const raw = getFirstDefined(option, ['label', 'name', 'title', 'display_name', 'displayName']);
+ if (typeof raw === 'string' && raw.trim()) {
+ return raw.trim();
+ }
+ if (fallback) {
+ return fallback;
+ }
+ const value = getFirstDefined(option, ['value', 'id', 'key', 'uuid']);
+ return value !== undefined ? String(value) : '';
+ }
+
+ function getOptionDescription(option) {
+ const raw = getFirstDefined(option, ['description', 'subtitle', 'hint', 'note']);
+ if (typeof raw === 'string' && raw.trim()) {
+ return raw.trim();
+ }
+ return '';
+ }
+
+ function getOptionPriceKopeks(option) {
+ const candidates = [
+ 'price_kopeks',
+ 'priceKopeks',
+ 'price_kop',
+ 'price',
+ 'cost_kopeks',
+ 'costKopeks',
+ 'amount_kopeks',
+ 'amountKopeks',
+ 'total_kopeks',
+ 'totalKopeks',
+ ];
+ const raw = getFirstDefined(option, candidates);
+ const value = coercePositiveInt(raw, null);
+ return value ?? 0;
+ }
+
+ function getOptionOriginalPriceKopeks(option) {
+ const candidates = [
+ 'original_price_kopeks',
+ 'originalPriceKopeks',
+ 'base_price_kopeks',
+ 'basePriceKopeks',
+ 'full_price_kopeks',
+ 'fullPriceKopeks',
+ 'original_price',
+ 'compare_at_price_kopeks',
+ 'compareAtPriceKopeks',
+ ];
+ const raw = getFirstDefined(option, candidates);
+ const value = coercePositiveInt(raw, null);
+ return value ?? null;
+ }
+
+ function getOptionDiscountPercent(option) {
+ const raw = getFirstDefined(option, ['discount_percent', 'discountPercent', 'discount']);
+ const value = coercePositiveInt(raw, null);
+ return value ?? 0;
+ }
+
+ function isOptionPreselected(option) {
+ const raw = getFirstDefined(option, ['is_default', 'isDefault', 'default', 'selected', 'is_selected', 'isSelected', 'current', 'active', 'included']);
+ if (typeof raw === 'boolean') {
+ return raw;
+ }
+ if (typeof raw === 'number') {
+ return raw > 0;
+ }
+ if (typeof raw === 'string') {
+ return ['true', '1', 'yes', 'selected', 'included'].includes(raw.toLowerCase());
+ }
+ return false;
+ }
+
+ function getPurchasePeriods() {
+ const root = getPurchaseRoot();
+ if (!root) {
+ return [];
+ }
+ const candidates = [root.periods, root.available_periods, root.period_options, root.subscription_periods];
+ for (const candidate of candidates) {
+ if (Array.isArray(candidate) && candidate.length) {
+ return candidate;
+ }
+ }
+ return [];
+ }
+
+ function getPurchasePeriodId(period) {
+ const raw = getFirstDefined(period, ['id', 'key', 'uuid', 'value', 'period_id', 'periodId']);
+ if (raw !== undefined) {
+ return String(raw);
+ }
+ const days = getFirstDefined(period, ['days', 'period_days', 'periodDays', 'duration_days']);
+ if (days !== undefined) {
+ return `days-${days}`;
+ }
+ return JSON.stringify(period || {});
+ }
+
+ function getPurchasePeriodLabel(period) {
+ const label = getOptionLabel(period);
+ if (label) {
+ return label;
+ }
+ const days = getFirstDefined(period, ['days', 'period_days', 'periodDays', 'duration_days']);
+ if (days !== undefined) {
+ const numeric = Number(days);
+ if (Number.isFinite(numeric) && numeric > 0) {
+ const suffix = numeric === 1 ? 'day' : 'days';
+ return `${numeric} ${suffix}`;
+ }
+ }
+ return t('purchase.period.title');
+ }
+
+ function getPurchaseTrafficConfig() {
+ const root = getPurchaseRoot();
+ if (!root) {
+ return null;
+ }
+ const traffic = root.traffic || root.traffic_options || root.trafficPackages;
+ if (traffic && typeof traffic === 'object') {
+ return traffic;
+ }
+ return null;
+ }
+
+ function getPurchaseTrafficOptions() {
+ const traffic = getPurchaseTrafficConfig();
+ if (!traffic) {
+ return [];
+ }
+ const options = traffic.options || traffic.available || traffic.packages || traffic.choices;
+ return Array.isArray(options) ? options : [];
+ }
+
+ function getTrafficId(option) {
+ const raw = getFirstDefined(option, ['id', 'key', 'uuid', 'value']);
+ if (raw !== undefined) {
+ return String(raw);
+ }
+ const gb = getFirstDefined(option, ['gb', 'traffic_gb', 'trafficGb', 'limit_gb', 'limitGb']);
+ if (gb !== undefined) {
+ return `gb-${gb}`;
+ }
+ return JSON.stringify(option || {});
+ }
+
+ function getTrafficLabel(option) {
+ const label = getOptionLabel(option);
+ if (label) {
+ return label;
+ }
+ const gb = getFirstDefined(option, ['gb', 'traffic_gb', 'trafficGb', 'limit_gb', 'limitGb']);
+ if (gb !== undefined) {
+ const numeric = Number(gb);
+ if (Number.isFinite(numeric)) {
+ return formatTraffic(Math.max(numeric, 0));
+ }
+ }
+ return '';
+ }
+
+ function getPurchaseServersConfig() {
+ const root = getPurchaseRoot();
+ if (!root) {
+ return null;
+ }
+ const servers = root.servers || root.server_options || root.squads;
+ if (servers && typeof servers === 'object') {
+ return servers;
+ }
+ return null;
+ }
+
+ function getPurchaseServerOptions() {
+ const config = getPurchaseServersConfig();
+ if (!config) {
+ return [];
+ }
+ const options = config.available || config.options || config.items || config.choices;
+ return Array.isArray(options) ? options : [];
+ }
+
+ function getServerId(option) {
+ const raw = getFirstDefined(option, ['uuid', 'id', 'key', 'value', 'code']);
+ return raw !== undefined ? String(raw) : null;
+ }
+
+ function getServerLabel(option) {
+ const label = getOptionLabel(option);
+ if (label) {
+ return label;
+ }
+ const code = getFirstDefined(option, ['country', 'code']);
+ if (code !== undefined) {
+ return String(code);
+ }
+ return t('values.not_available');
+ }
+
+ function getPurchaseServersMin() {
+ const config = getPurchaseServersConfig();
+ if (!config) {
+ return 0;
+ }
+ const raw = getFirstDefined(config, ['min', 'minimum', 'min_required', 'minRequired', 'min_select']);
+ const value = coercePositiveInt(raw, null);
+ return value ?? 0;
+ }
+
+ function getPurchaseServersMax() {
+ const config = getPurchaseServersConfig();
+ if (!config) {
+ return 0;
+ }
+ const raw = getFirstDefined(config, ['max', 'maximum', 'max_allowed', 'maxAllowed', 'limit']);
+ const value = coercePositiveInt(raw, null);
+ return value ?? 0;
+ }
+
+ function getPurchaseDevicesConfig() {
+ const root = getPurchaseRoot();
+ if (!root) {
+ return null;
+ }
+ const devices = root.devices || root.device_options || root.deviceLimit;
+ if (!devices) {
+ return null;
+ }
+ if (Array.isArray(devices)) {
+ return { options: devices };
+ }
+ if (typeof devices === 'object') {
+ return devices;
+ }
+ return null;
+ }
+
+ function getPurchaseDeviceOptions() {
+ const devices = getPurchaseDevicesConfig();
+ if (!devices) {
+ return [];
+ }
+ const options = devices.options || devices.available || devices.choices;
+ return Array.isArray(options) ? options : [];
+ }
+
+ function getDeviceValue(option) {
+ const raw = getFirstDefined(option, ['value', 'devices', 'count', 'device_limit', 'deviceLimit']);
+ const value = coercePositiveInt(raw, null);
+ return value ?? null;
+ }
+
+ function getPurchaseDevicesMin() {
+ const devices = getPurchaseDevicesConfig();
+ if (!devices) {
+ return 0;
+ }
+ const raw = getFirstDefined(devices, ['min', 'minimum', 'min_allowed', 'minAllowed', 'min_devices']);
+ const value = coercePositiveInt(raw, null);
+ if (value !== null) {
+ return value;
+ }
+ const values = getPurchaseDeviceOptions().map(getDeviceValue).filter(v => v !== null);
+ return values.length ? Math.min(...values) : 0;
+ }
+
+ function getPurchaseDevicesMax() {
+ const devices = getPurchaseDevicesConfig();
+ if (!devices) {
+ return 0;
+ }
+ const raw = getFirstDefined(devices, ['max', 'maximum', 'max_allowed', 'maxAllowed', 'max_devices']);
+ const value = coercePositiveInt(raw, null);
+ if (value !== null) {
+ return value;
+ }
+ const values = getPurchaseDeviceOptions().map(getDeviceValue).filter(v => v !== null);
+ return values.length ? Math.max(...values) : 0;
+ }
+
+ function getPurchaseDevicesStep() {
+ const devices = getPurchaseDevicesConfig();
+ if (!devices) {
+ return 1;
+ }
+ const raw = getFirstDefined(devices, ['step', 'increment']);
+ const value = coercePositiveInt(raw, null);
+ return value ?? 1;
+ }
+
+ function getPurchaseBalanceKopeks(root) {
+ const candidates = [
+ root?.balance_kopeks,
+ root?.balanceKopeks,
+ root?.balance?.amount_kopeks,
+ root?.balance?.kopeks,
+ root?.pricing?.balance_kopeks,
+ ];
+ for (const candidate of candidates) {
+ const value = coercePositiveInt(candidate, null);
+ if (value !== null) {
+ return value;
+ }
+ }
+ const fromUser = coercePositiveInt(userData?.balance_kopeks, null);
+ if (fromUser !== null) {
+ return fromUser;
+ }
+ const balanceRubles = typeof userData?.balance_rubles === 'number'
+ ? userData.balance_rubles
+ : Number.parseFloat(userData?.balance_rubles ?? '0');
+ if (Number.isFinite(balanceRubles)) {
+ return Math.max(0, Math.round(balanceRubles * 100));
+ }
+ return 0;
+ }
+
function getEffectivePurchaseUrl() {
const candidates = [
currentErrorState?.purchaseUrl,
@@ -5307,6 +6186,7 @@
}
renderApps();
updateActionButtons();
+ renderSubscriptionPurchaseConfigurator();
}
function setLanguage(language, options = {}) {
@@ -6265,6 +7145,953 @@
registerPromoOfferTimers(hasContent ? timers : []);
}
+ function shouldShowSubscriptionPurchase() {
+ if (subscriptionPurchaseError && !subscriptionPurchaseConfig) {
+ return true;
+ }
+ if (!userData || !userData.user) {
+ return true;
+ }
+ const user = userData.user;
+ const status = String(user.subscription_actual_status || user.subscription_status || '').toLowerCase();
+ const type = String(userData.subscription_type || '').toLowerCase();
+ const hasActive = Boolean(user.has_active_subscription);
+ if (!hasActive) {
+ return true;
+ }
+ if (type === 'trial' || status.includes('trial')) {
+ return true;
+ }
+ if (['expired', 'disabled'].includes(status)) {
+ return true;
+ }
+ return false;
+ }
+
+ function initializeSubscriptionPurchaseSelections() {
+ const periods = getPurchasePeriods();
+ if (periods.length) {
+ const defaultPeriod = periods.find(isOptionPreselected) || periods[0];
+ subscriptionPurchaseSelections.period = getPurchasePeriodId(defaultPeriod);
+ } else {
+ subscriptionPurchaseSelections.period = null;
+ }
+
+ const trafficOptions = getPurchaseTrafficOptions();
+ if (trafficOptions.length) {
+ const defaultTraffic = trafficOptions.find(isOptionPreselected) || trafficOptions[0];
+ subscriptionPurchaseSelections.traffic = getTrafficId(defaultTraffic);
+ } else {
+ subscriptionPurchaseSelections.traffic = null;
+ }
+
+ const serverOptions = getPurchaseServerOptions();
+ subscriptionPurchaseSelections.servers = new Set();
+ if (serverOptions.length) {
+ serverOptions.forEach(option => {
+ if (option?.is_available === false) {
+ return;
+ }
+ const id = getServerId(option);
+ if (!id) {
+ return;
+ }
+ if (isOptionPreselected(option) || serverOptions.length === 1) {
+ subscriptionPurchaseSelections.servers.add(id);
+ }
+ });
+
+ const minServers = getPurchaseServersMin();
+ if (subscriptionPurchaseSelections.servers.size < minServers) {
+ for (const option of serverOptions) {
+ if (option?.is_available === false) {
+ continue;
+ }
+ const id = getServerId(option);
+ if (!id) {
+ continue;
+ }
+ subscriptionPurchaseSelections.servers.add(id);
+ if (subscriptionPurchaseSelections.servers.size >= minServers) {
+ break;
+ }
+ }
+ }
+ }
+
+ const deviceOptions = getPurchaseDeviceOptions();
+ if (deviceOptions.length) {
+ const defaultDevice = deviceOptions.find(isOptionPreselected) || deviceOptions[0];
+ subscriptionPurchaseSelections.devices = getDeviceValue(defaultDevice);
+ } else {
+ const devicesConfig = getPurchaseDevicesConfig();
+ if (devicesConfig) {
+ const raw = getFirstDefined(devicesConfig, ['current', 'value', 'default', 'device_limit', 'deviceLimit']);
+ let value = coercePositiveInt(raw, null);
+ const min = getPurchaseDevicesMin();
+ const max = getPurchaseDevicesMax();
+ if (value === null) {
+ value = min || max || 0;
+ }
+ if (min && value < min) {
+ value = min;
+ }
+ if (max && max > 0 && value > max) {
+ value = max;
+ }
+ subscriptionPurchaseSelections.devices = value || null;
+ } else {
+ subscriptionPurchaseSelections.devices = null;
+ }
+ }
+ }
+
+ function getSelectedPeriod() {
+ const periods = getPurchasePeriods();
+ if (!periods.length) {
+ return null;
+ }
+ const selectedId = subscriptionPurchaseSelections.period;
+ let period = periods.find(item => getPurchasePeriodId(item) === selectedId);
+ if (!period) {
+ period = periods[0];
+ subscriptionPurchaseSelections.period = getPurchasePeriodId(period);
+ }
+ return period;
+ }
+
+ function getSelectedTrafficOption() {
+ const options = getPurchaseTrafficOptions();
+ if (!options.length) {
+ return null;
+ }
+ const selectedId = subscriptionPurchaseSelections.traffic;
+ let option = options.find(item => getTrafficId(item) === selectedId);
+ if (!option) {
+ option = options[0];
+ subscriptionPurchaseSelections.traffic = getTrafficId(option);
+ }
+ return option;
+ }
+
+ async function ensureSubscriptionPurchaseData(options = {}) {
+ const { force = false } = options;
+ if (subscriptionPurchasePromise) {
+ return subscriptionPurchasePromise;
+ }
+ if (subscriptionPurchaseConfig && !force) {
+ return subscriptionPurchaseConfig;
+ }
+
+ const initData = tg.initData || '';
+ if (!initData) {
+ subscriptionPurchaseError = createError('Authorization', t('purchase.error.generic'));
+ renderSubscriptionPurchaseConfigurator();
+ return null;
+ }
+
+ subscriptionPurchasePromise = (async () => {
+ try {
+ const response = await fetch('/miniapp/subscription/purchase/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ initData }),
+ });
+
+ if (!response.ok) {
+ let detail = null;
+ try {
+ const payload = await response.json();
+ detail = payload?.detail || payload?.message || null;
+ } catch (parseError) {
+ // ignore
+ }
+ const message = typeof detail === 'string' && detail.trim()
+ ? detail.trim()
+ : t('purchase.error.generic');
+ throw createError('Purchase', message, response.status);
+ }
+
+ const payload = await response.json().catch(() => ({}));
+ subscriptionPurchaseConfig = payload?.config || payload || null;
+ subscriptionPurchaseError = null;
+ initializeSubscriptionPurchaseSelections();
+ } catch (error) {
+ subscriptionPurchaseError = error;
+ subscriptionPurchaseConfig = null;
+ throw error;
+ } finally {
+ subscriptionPurchasePromise = null;
+ renderSubscriptionPurchaseConfigurator();
+ }
+
+ return subscriptionPurchaseConfig;
+ })();
+
+ return subscriptionPurchasePromise;
+ }
+
+ function renderSubscriptionPurchaseConfigurator() {
+ const wrapper = document.getElementById('subscriptionPurchaseWrapper');
+ if (!wrapper) {
+ return;
+ }
+
+ const shouldShow = shouldShowSubscriptionPurchase();
+ wrapper.classList.toggle('hidden', !shouldShow);
+ if (!shouldShow) {
+ return;
+ }
+
+ const loadingEl = document.getElementById('subscriptionPurchaseLoading');
+ const contentEl = document.getElementById('subscriptionPurchaseContent');
+ const errorEl = document.getElementById('subscriptionPurchaseError');
+ const errorText = document.getElementById('subscriptionPurchaseErrorText');
+
+ if (!subscriptionPurchaseConfig && !subscriptionPurchaseError && !subscriptionPurchasePromise) {
+ loadingEl.classList.remove('hidden');
+ contentEl.classList.add('hidden');
+ errorEl.classList.add('hidden');
+ ensureSubscriptionPurchaseData().catch(error => {
+ console.warn('Failed to load purchase options:', error);
+ });
+ return;
+ }
+
+ const isLoading = Boolean(subscriptionPurchasePromise);
+ loadingEl.classList.toggle('hidden', !isLoading);
+
+ if (subscriptionPurchaseError && !isLoading) {
+ const message = subscriptionPurchaseError?.message || t('purchase.error.generic');
+ if (errorText) {
+ errorText.textContent = message === 'purchase.error.generic' ? t('purchase.error.generic') : message;
+ }
+ errorEl.classList.remove('hidden');
+ contentEl.classList.add('hidden');
+ return;
+ }
+
+ errorEl.classList.add('hidden');
+ contentEl.classList.toggle('hidden', isLoading || !subscriptionPurchaseConfig);
+ if (!subscriptionPurchaseConfig || isLoading) {
+ return;
+ }
+
+ if (!(subscriptionPurchaseSelections.servers instanceof Set)) {
+ subscriptionPurchaseSelections.servers = new Set(subscriptionPurchaseSelections.servers);
+ }
+
+ renderSubscriptionPurchaseSections();
+ }
+
+ function renderSubscriptionPurchaseSections() {
+ renderSubscriptionPurchasePeriods();
+ renderSubscriptionPurchaseTraffic();
+ renderSubscriptionPurchaseServers();
+ renderSubscriptionPurchaseDevices();
+ renderSubscriptionPurchaseSummary();
+ renderSubscriptionPurchaseDiscounts();
+ renderSubscriptionPurchaseValidation();
+ }
+
+ function renderSubscriptionPurchasePeriods() {
+ const container = document.getElementById('subscriptionPurchasePeriodOptions');
+ const meta = document.getElementById('subscriptionPurchasePeriodMeta');
+ if (!container) {
+ return;
+ }
+ container.innerHTML = '';
+ if (meta) {
+ meta.textContent = '';
+ }
+
+ const periods = getPurchasePeriods();
+ if (!periods.length) {
+ container.innerHTML = `
${escapeHtml(t('purchase.empty'))}
`;
+ return;
+ }
+
+ const currency = getPurchaseCurrency();
+ const selectedId = subscriptionPurchaseSelections.period;
+
+ periods.forEach(period => {
+ const periodId = getPurchasePeriodId(period);
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'purchase-option';
+ if (periodId === selectedId) {
+ button.classList.add('active');
+ }
+
+ const label = getPurchasePeriodLabel(period);
+ const price = getOptionPriceKopeks(period);
+ const original = getOptionOriginalPriceKopeks(period);
+ const priceLabel = formatPriceFromKopeks(price, currency);
+ const originalHtml = Number.isFinite(original) && original !== null && original > price
+ ? `
${escapeHtml(formatPriceFromKopeks(original, currency))}`
+ : '';
+ const description = getOptionDescription(period);
+ const discount = getOptionDiscountPercent(period);
+
+ button.innerHTML = `
+
${escapeHtml(label)}
+
+ ${escapeHtml(priceLabel)}
+ ${originalHtml}
+
+ ${discount > 0 ? `
-${escapeHtml(String(discount))}%
` : ''}
+ ${description ? `
${escapeHtml(description)}
` : ''}
+ `;
+
+ button.addEventListener('click', () => {
+ subscriptionPurchaseSelections.period = periodId;
+ renderSubscriptionPurchaseSections();
+ });
+
+ container.appendChild(button);
+ });
+ }
+
+ function renderSubscriptionPurchaseTraffic() {
+ const container = document.getElementById('subscriptionPurchaseTrafficOptions');
+ const meta = document.getElementById('subscriptionPurchaseTrafficMeta');
+ const note = document.getElementById('subscriptionPurchaseTrafficNote');
+ if (!container) {
+ return;
+ }
+ container.innerHTML = '';
+ if (meta) {
+ meta.textContent = '';
+ }
+ if (note) {
+ note.classList.add('hidden');
+ note.textContent = '';
+ }
+
+ const trafficConfig = getPurchaseTrafficConfig();
+ if (!trafficConfig) {
+ container.innerHTML = `
${escapeHtml(t('purchase.empty'))}
`;
+ return;
+ }
+
+ const options = getPurchaseTrafficOptions();
+ const currency = getPurchaseCurrency();
+ if (!options.length) {
+ const limitRaw = getFirstDefined(trafficConfig, ['label', 'limit_label', 'limitLabel']);
+ let amountLabel = '';
+ if (typeof limitRaw === 'string' && limitRaw.trim()) {
+ amountLabel = limitRaw.trim();
+ } else {
+ const limitValue = getFirstDefined(trafficConfig, ['gb', 'traffic_gb', 'trafficGb', 'limit_gb', 'limitGb']);
+ if (limitValue !== undefined) {
+ const numeric = Number(limitValue);
+ if (Number.isFinite(numeric)) {
+ amountLabel = formatTraffic(Math.max(numeric, 0));
+ }
+ }
+ }
+ if (note && amountLabel) {
+ const template = t('purchase.traffic.fixed');
+ note.textContent = template === 'purchase.traffic.fixed'
+ ? `Monthly traffic: ${amountLabel}`
+ : template.replace('{amount}', amountLabel);
+ note.classList.remove('hidden');
+ }
+ return;
+ }
+
+ const selectedId = subscriptionPurchaseSelections.traffic;
+
+ options.forEach(option => {
+ const optionId = getTrafficId(option);
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'purchase-option';
+ if (optionId === selectedId) {
+ button.classList.add('active');
+ }
+
+ const label = getTrafficLabel(option) || t('purchase.traffic.title');
+ const price = getOptionPriceKopeks(option);
+ const original = getOptionOriginalPriceKopeks(option);
+ const priceLabel = formatPriceFromKopeks(price, currency);
+ const originalHtml = Number.isFinite(original) && original !== null && original > price
+ ? `
${escapeHtml(formatPriceFromKopeks(original, currency))}`
+ : '';
+ const description = getOptionDescription(option);
+
+ button.innerHTML = `
+
${escapeHtml(label)}
+
+ ${escapeHtml(priceLabel)}
+ ${originalHtml}
+
+ ${description ? `
${escapeHtml(description)}
` : ''}
+ `;
+
+ button.addEventListener('click', () => {
+ subscriptionPurchaseSelections.traffic = optionId;
+ renderSubscriptionPurchaseSections();
+ });
+
+ container.appendChild(button);
+ });
+ }
+
+ function renderSubscriptionPurchaseServers() {
+ const container = document.getElementById('subscriptionPurchaseServersChips');
+ const meta = document.getElementById('subscriptionPurchaseServersMeta');
+ const note = document.getElementById('subscriptionPurchaseServersNote');
+ if (!container) {
+ return;
+ }
+ container.innerHTML = '';
+ if (meta) {
+ meta.textContent = '';
+ }
+ if (note) {
+ note.classList.add('hidden');
+ note.textContent = '';
+ }
+
+ const options = getPurchaseServerOptions().filter(option => option?.is_available !== false);
+ const selectable = getFirstDefined(getPurchaseServersConfig(), ['selectable', 'mode']) !== 'fixed';
+ if (!options.length) {
+ container.innerHTML = `
${escapeHtml(t('purchase.empty'))}
`;
+ return;
+ }
+
+ if (!selectable || options.length === 1) {
+ const option = options[0];
+ const label = getServerLabel(option);
+ if (note) {
+ const template = t('purchase.servers.single');
+ note.textContent = template === 'purchase.servers.single'
+ ? `Server: ${label}`
+ : template.replace('{name}', label);
+ note.classList.remove('hidden');
+ }
+ subscriptionPurchaseSelections.servers = new Set();
+ const id = getServerId(option);
+ if (id) {
+ subscriptionPurchaseSelections.servers.add(id);
+ }
+ return;
+ }
+
+ const max = getPurchaseServersMax();
+ const min = getPurchaseServersMin();
+ const currency = getPurchaseCurrency();
+
+ options.forEach(option => {
+ const id = getServerId(option);
+ if (!id) {
+ return;
+ }
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'purchase-chip';
+ const price = getOptionPriceKopeks(option);
+ const label = getServerLabel(option);
+ const priceLabel = price > 0 ? ` +${formatPriceFromKopeks(price, currency)}` : '';
+ button.innerHTML = `
${escapeHtml(label)}${price > 0 ? `
${escapeHtml(priceLabel)}` : ''}`;
+
+ if (subscriptionPurchaseSelections.servers.has(id)) {
+ button.classList.add('active');
+ }
+
+ button.addEventListener('click', () => {
+ if (subscriptionPurchaseSelections.servers.has(id)) {
+ subscriptionPurchaseSelections.servers.delete(id);
+ } else {
+ if (max > 0 && subscriptionPurchaseSelections.servers.size >= max) {
+ return;
+ }
+ subscriptionPurchaseSelections.servers.add(id);
+ }
+ renderSubscriptionPurchaseSections();
+ });
+
+ container.appendChild(button);
+ });
+
+ if (meta) {
+ const selectedCount = subscriptionPurchaseSelections.servers.size;
+ if (max > 0) {
+ const template = t('purchase.servers.meta');
+ meta.textContent = template === 'purchase.servers.meta'
+ ? `${selectedCount} of ${max} selected`
+ : template.replace('{selected}', String(selectedCount)).replace('{max}', String(max));
+ } else {
+ const template = t('purchase.servers.meta_unlimited');
+ meta.textContent = template === 'purchase.servers.meta_unlimited'
+ ? `${selectedCount} selected`
+ : template.replace('{selected}', String(selectedCount));
+ }
+ }
+
+ if (note && min > 0 && subscriptionPurchaseSelections.servers.size < min) {
+ note.textContent = t('purchase.validation.servers_min').replace('{min}', String(min));
+ note.classList.remove('hidden');
+ }
+ }
+
+ function renderSubscriptionPurchaseDevices() {
+ const container = document.getElementById('subscriptionPurchaseDevicesOptions');
+ const stepper = document.getElementById('subscriptionPurchaseDevicesStepper');
+ const valueEl = document.getElementById('subscriptionPurchaseDevicesValue');
+ const decreaseBtn = document.getElementById('subscriptionPurchaseDevicesDecrease');
+ const increaseBtn = document.getElementById('subscriptionPurchaseDevicesIncrease');
+ const meta = document.getElementById('subscriptionPurchaseDevicesMeta');
+ const note = document.getElementById('subscriptionPurchaseDevicesNote');
+ if (!container) {
+ return;
+ }
+ container.innerHTML = '';
+ if (meta) {
+ meta.textContent = '';
+ }
+ if (note) {
+ note.textContent = '';
+ note.classList.add('hidden');
+ }
+
+ const options = getPurchaseDeviceOptions();
+ const currency = getPurchaseCurrency();
+ if (options.length) {
+ if (stepper) {
+ stepper.classList.add('hidden');
+ }
+
+ const selectedValue = subscriptionPurchaseSelections.devices;
+ options.forEach(option => {
+ const value = getDeviceValue(option);
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'purchase-option';
+ if (value !== null && value === selectedValue) {
+ button.classList.add('active');
+ }
+
+ const label = value !== null ? formatDeviceCountLabel(value) : getOptionLabel(option);
+ const price = getOptionPriceKopeks(option);
+ const original = getOptionOriginalPriceKopeks(option);
+ const priceLabel = formatPriceFromKopeks(price, currency);
+ const originalHtml = Number.isFinite(original) && original !== null && original > price
+ ? `
${escapeHtml(formatPriceFromKopeks(original, currency))}`
+ : '';
+ const description = getOptionDescription(option);
+
+ button.innerHTML = `
+
${escapeHtml(label)}
+
+ ${escapeHtml(priceLabel)}
+ ${originalHtml}
+
+ ${description ? `
${escapeHtml(description)}
` : ''}
+ `;
+
+ button.addEventListener('click', () => {
+ subscriptionPurchaseSelections.devices = value;
+ renderSubscriptionPurchaseSections();
+ });
+
+ container.appendChild(button);
+ });
+
+ const max = getPurchaseDevicesMax();
+ if (meta && max) {
+ meta.textContent = t('purchase.devices.meta').replace('{max}', String(max));
+ }
+ return;
+ }
+
+ const devicesConfig = getPurchaseDevicesConfig();
+ if (!devicesConfig || !stepper || !valueEl || !decreaseBtn || !increaseBtn) {
+ container.innerHTML = `
${escapeHtml(t('purchase.empty'))}
`;
+ return;
+ }
+
+ stepper.classList.remove('hidden');
+ const min = getPurchaseDevicesMin() || 1;
+ const max = getPurchaseDevicesMax();
+ const step = Math.max(1, getPurchaseDevicesStep());
+ let value = subscriptionPurchaseSelections.devices ?? min;
+ if (value < min) {
+ value = min;
+ }
+ if (max > 0 && value > max) {
+ value = max;
+ }
+ subscriptionPurchaseSelections.devices = value;
+ valueEl.textContent = String(value);
+
+ decreaseBtn.disabled = value <= min;
+ increaseBtn.disabled = max > 0 ? value >= max : false;
+
+ decreaseBtn.onclick = () => {
+ const next = subscriptionPurchaseSelections.devices - step;
+ if (next < min) {
+ return;
+ }
+ subscriptionPurchaseSelections.devices = next;
+ renderSubscriptionPurchaseSections();
+ };
+
+ increaseBtn.onclick = () => {
+ const next = subscriptionPurchaseSelections.devices + step;
+ if (max > 0 && next > max) {
+ return;
+ }
+ subscriptionPurchaseSelections.devices = next;
+ renderSubscriptionPurchaseSections();
+ };
+
+ if (meta && max) {
+ meta.textContent = t('purchase.devices.meta').replace('{max}', String(max));
+ }
+ }
+
+ function computeSubscriptionPurchaseTotals() {
+ const root = getPurchaseRoot();
+ if (!root) {
+ subscriptionPurchaseLastTotals = null;
+ return null;
+ }
+
+ const currency = getPurchaseCurrency();
+ let total = 0;
+ let original = 0;
+
+ const period = getSelectedPeriod();
+ if (period) {
+ const price = getOptionPriceKopeks(period);
+ const originalPrice = getOptionOriginalPriceKopeks(period);
+ total += price;
+ original += originalPrice !== null ? Math.max(originalPrice, price) : price;
+ }
+
+ const traffic = getSelectedTrafficOption();
+ if (traffic) {
+ const price = getOptionPriceKopeks(traffic);
+ const originalPrice = getOptionOriginalPriceKopeks(traffic);
+ total += price;
+ original += originalPrice !== null ? Math.max(originalPrice, price) : price;
+ }
+
+ const serverOptions = getPurchaseServerOptions();
+ if (serverOptions.length && subscriptionPurchaseSelections.servers instanceof Set) {
+ serverOptions.forEach(option => {
+ const id = getServerId(option);
+ if (!id || !subscriptionPurchaseSelections.servers.has(id)) {
+ return;
+ }
+ const price = getOptionPriceKopeks(option);
+ const originalPrice = getOptionOriginalPriceKopeks(option);
+ total += price;
+ original += originalPrice !== null ? Math.max(originalPrice, price) : price;
+ });
+ }
+
+ const deviceOptions = getPurchaseDeviceOptions();
+ if (deviceOptions.length) {
+ const selected = deviceOptions.find(option => getDeviceValue(option) === subscriptionPurchaseSelections.devices);
+ if (selected) {
+ const price = getOptionPriceKopeks(selected);
+ const originalPrice = getOptionOriginalPriceKopeks(selected);
+ total += price;
+ original += originalPrice !== null ? Math.max(originalPrice, price) : price;
+ }
+ }
+
+ if (original < total) {
+ original = total;
+ }
+
+ const pricing = root.pricing || root.summary;
+ if (pricing && typeof pricing === 'object') {
+ const explicitTotal = coercePositiveInt(getFirstDefined(pricing, ['total_kopeks', 'totalKopeks', 'total_price_kopeks', 'totalPriceKopeks']), null);
+ if (explicitTotal !== null) {
+ total = explicitTotal;
+ }
+ const explicitOriginal = coercePositiveInt(getFirstDefined(pricing, ['original_total_kopeks', 'originalTotalKopeks', 'original_price_kopeks', 'base_total_kopeks', 'baseTotalKopeks', 'full_price_kopeks']), null);
+ if (explicitOriginal !== null) {
+ original = explicitOriginal;
+ }
+ }
+
+ const balance = getPurchaseBalanceKopeks(root);
+ const discount = Math.max(0, original - total);
+ const missing = Math.max(0, total - balance);
+
+ const totals = {
+ currency,
+ totalKopeks: total,
+ originalKopeks: original,
+ discountKopeks: discount,
+ balanceKopeks: balance,
+ missingKopeks: missing,
+ };
+
+ subscriptionPurchaseLastTotals = totals;
+ return totals;
+ }
+
+ function renderSubscriptionPurchaseSummary() {
+ const container = document.getElementById('subscriptionPurchaseSummary');
+ if (!container) {
+ return;
+ }
+
+ const totals = computeSubscriptionPurchaseTotals();
+ if (!totals) {
+ container.innerHTML = `
${escapeHtml(t('purchase.empty'))}
`;
+ updateSubscriptionPurchaseButtons();
+ return;
+ }
+
+ const totalLabel = formatPriceFromKopeks(totals.totalKopeks, totals.currency);
+ const originalLabel = totals.originalKopeks > totals.totalKopeks
+ ? formatPriceFromKopeks(totals.originalKopeks, totals.currency)
+ : null;
+ const discountLabel = totals.discountKopeks > 0
+ ? t('purchase.summary.discount').replace('{amount}', formatPriceFromKopeks(totals.discountKopeks, totals.currency))
+ : '';
+ const balanceLabel = formatPriceFromKopeks(totals.balanceKopeks, totals.currency);
+ const afterPurchaseLabel = formatPriceFromKopeks(Math.max(totals.balanceKopeks - totals.totalKopeks, 0), totals.currency);
+ const missingLabel = totals.missingKopeks > 0
+ ? formatPriceFromKopeks(totals.missingKopeks, totals.currency)
+ : null;
+
+ container.innerHTML = `
+
+ ${discountLabel ? `
${escapeHtml(discountLabel)}
` : ''}
+
+ ${escapeHtml(t('purchase.summary.balance'))}
+ ${escapeHtml(balanceLabel)}
+
+
+ ${escapeHtml(t('purchase.summary.balance_after'))}
+ ${escapeHtml(afterPurchaseLabel)}
+
+ ${missingLabel ? `
${escapeHtml(t('purchase.summary.missing'))}${escapeHtml(missingLabel)}
` : ''}
+ `;
+
+ updateSubscriptionPurchaseButtons();
+ }
+
+ function collectPurchaseDiscountTags() {
+ const tags = [];
+ const root = getPurchaseRoot();
+ const promoGroup = root?.promo_group || userData?.promo_group;
+ if (promoGroup) {
+ const values = [
+ promoGroup.server_discount_percent,
+ promoGroup.traffic_discount_percent,
+ promoGroup.device_discount_percent,
+ promoGroup.discount_percent,
+ ].map(value => coercePositiveInt(value, null)).filter(value => value && value > 0);
+ if (values.length) {
+ const percent = Math.max(...values);
+ tags.push(`${t('purchase.discount.promo_group')}: -${percent}%`);
+ }
+ }
+
+ const activeOffer = (userData?.promo_offers || []).find(offer => {
+ if (String(offer?.status || '').toLowerCase() !== 'active') {
+ return false;
+ }
+ const percent = coercePositiveInt(offer?.discount_percent, null);
+ return percent && percent > 0;
+ });
+ if (activeOffer) {
+ const percent = coercePositiveInt(activeOffer.discount_percent, null);
+ if (percent) {
+ tags.push(`${t('purchase.discount.promo_offer')}: -${percent}%`);
+ }
+ } else {
+ const percent = coercePositiveInt(getFirstDefined(root, ['active_discount_percent', 'discount_percent']), null);
+ if (percent) {
+ tags.push(t('purchase.discount.active_offer').replace('{percent}', String(percent)));
+ }
+ }
+
+ return tags;
+ }
+
+ function renderSubscriptionPurchaseDiscounts() {
+ const container = document.getElementById('subscriptionPurchaseDiscounts');
+ if (!container) {
+ return;
+ }
+
+ const tags = collectPurchaseDiscountTags();
+ if (!tags.length) {
+ container.classList.add('hidden');
+ container.innerHTML = '';
+ return;
+ }
+
+ container.classList.remove('hidden');
+ container.innerHTML = tags
+ .map(tag => `
${escapeHtml(tag)}`)
+ .join('');
+ }
+
+ function validateSubscriptionPurchaseSelections() {
+ const errors = [];
+ const periods = getPurchasePeriods();
+ if (periods.length && !getSelectedPeriod()) {
+ errors.push(t('purchase.validation.period'));
+ }
+
+ const trafficOptions = getPurchaseTrafficOptions();
+ if (trafficOptions.length && !getSelectedTrafficOption()) {
+ errors.push(t('purchase.validation.traffic'));
+ }
+
+ const serverOptions = getPurchaseServerOptions().filter(option => option?.is_available !== false);
+ const minServers = getPurchaseServersMin();
+ if (serverOptions.length && minServers > 0 && subscriptionPurchaseSelections.servers.size < minServers) {
+ errors.push(t('purchase.validation.servers_min').replace('{min}', String(minServers)));
+ }
+
+ const devicesConfig = getPurchaseDevicesConfig();
+ if ((devicesConfig || getPurchaseDeviceOptions().length) && subscriptionPurchaseSelections.devices == null) {
+ errors.push(t('purchase.validation.devices'));
+ }
+
+ return errors;
+ }
+
+ function renderSubscriptionPurchaseValidation() {
+ const container = document.getElementById('subscriptionPurchaseValidation');
+ if (!container) {
+ updateSubscriptionPurchaseButtons();
+ return;
+ }
+
+ const errors = validateSubscriptionPurchaseSelections();
+ if (!errors.length) {
+ container.classList.add('hidden');
+ container.innerHTML = '';
+ } else {
+ container.classList.remove('hidden');
+ container.innerHTML = errors.map(message => `
${escapeHtml(message)}
`).join('');
+ }
+
+ updateSubscriptionPurchaseButtons(errors);
+ }
+
+ function updateSubscriptionPurchaseButtons(validationErrors) {
+ const submitBtn = document.getElementById('subscriptionPurchaseSubmit');
+ const topupBtn = document.getElementById('subscriptionPurchaseTopup');
+ const errors = validationErrors || validateSubscriptionPurchaseSelections();
+ const totals = subscriptionPurchaseLastTotals;
+
+ if (submitBtn) {
+ const disabled = subscriptionPurchaseSubmitting
+ || !totals
+ || totals.totalKopeks <= 0
+ || errors.length > 0;
+ submitBtn.disabled = disabled;
+ submitBtn.textContent = subscriptionPurchaseSubmitting
+ ? (t('subscription_settings.pending_action') || 'Saving…')
+ : t('purchase.action.buy');
+ }
+
+ if (topupBtn) {
+ const showTopup = Boolean(totals && totals.missingKopeks > 0);
+ topupBtn.classList.toggle('hidden', !showTopup);
+ }
+ }
+
+ function buildSubscriptionPurchasePayload() {
+ const initData = tg.initData || '';
+ if (!initData) {
+ return null;
+ }
+
+ const payload = {
+ initData,
+ period: subscriptionPurchaseSelections.period,
+ periodId: subscriptionPurchaseSelections.period,
+ traffic: subscriptionPurchaseSelections.traffic,
+ trafficId: subscriptionPurchaseSelections.traffic,
+ servers: Array.from(subscriptionPurchaseSelections.servers || []),
+ serverUuids: Array.from(subscriptionPurchaseSelections.servers || []),
+ devices: subscriptionPurchaseSelections.devices,
+ deviceLimit: subscriptionPurchaseSelections.devices,
+ };
+
+ const root = getPurchaseRoot();
+ const contextId = getFirstDefined(root, ['context_id', 'contextId', 'purchase_context_id', 'purchaseContextId']);
+ if (contextId !== undefined) {
+ payload.contextId = contextId;
+ payload.purchaseContextId = contextId;
+ }
+
+ if (userData?.subscription_id || userData?.subscriptionId) {
+ payload.subscriptionId = userData.subscription_id || userData.subscriptionId;
+ }
+
+ return payload;
+ }
+
+ async function submitSubscriptionPurchase() {
+ if (subscriptionPurchaseSubmitting) {
+ return;
+ }
+
+ const errors = validateSubscriptionPurchaseSelections();
+ if (errors.length) {
+ renderSubscriptionPurchaseValidation();
+ return;
+ }
+
+ const payload = buildSubscriptionPurchasePayload();
+ if (!payload) {
+ const fallbackUrl = getEffectivePurchaseUrl();
+ if (fallbackUrl) {
+ openExternalLink(fallbackUrl, { openInMiniApp: true });
+ }
+ return;
+ }
+
+ subscriptionPurchaseSubmitting = true;
+ updateSubscriptionPurchaseButtons(errors);
+
+ 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 || result?.success === false) {
+ const message = result?.message || result?.detail || t('purchase.error.generic');
+ showPopup(message === 'purchase.error.generic' ? t('purchase.error.generic') : message, t('purchase.title'));
+ return;
+ }
+
+ const successMessage = result?.message || t('purchase.action.buy');
+ showPopup(successMessage === 'purchase.action.buy' ? t('purchase.action.buy') : successMessage, t('purchase.title'));
+ await refreshSubscriptionData({ silent: false });
+ } catch (error) {
+ console.error('Failed to submit subscription purchase:', error);
+ const message = error?.message || t('purchase.error.generic');
+ showPopup(message === 'purchase.error.generic' ? t('purchase.error.generic') : message, t('purchase.title'));
+ } finally {
+ subscriptionPurchaseSubmitting = false;
+ renderSubscriptionPurchaseConfigurator();
+ }
+ }
+
async function handlePromoOfferAccept(offerId, button) {
if (!offerId) {
return;
@@ -10824,6 +12651,20 @@
openExternalLink(link, { openInMiniApp: true });
});
+ document.getElementById('subscriptionPurchaseRetry')?.addEventListener('click', () => {
+ ensureSubscriptionPurchaseData({ force: true }).catch(error => {
+ console.warn('Failed to reload subscription purchase config:', error);
+ });
+ });
+
+ document.getElementById('subscriptionPurchaseSubmit')?.addEventListener('click', () => {
+ submitSubscriptionPurchase();
+ });
+
+ document.getElementById('subscriptionPurchaseTopup')?.addEventListener('click', () => {
+ openTopupModal();
+ });
+
initializePromoCodeForm();
init();