Files
remnawave-bedolaga-telegram…/miniapp/index.html
T

1758 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#2481cc">
<title>VPN Subscription</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
:root {
--tg-theme-bg-color: #ffffff;
--tg-theme-text-color: #000000;
--tg-theme-hint-color: #999999;
--tg-theme-link-color: #2481cc;
--tg-theme-button-color: #2481cc;
--tg-theme-button-text-color: #ffffff;
--tg-theme-secondary-bg-color: #f0f0f0;
--primary: var(--tg-theme-button-color);
--text-primary: var(--tg-theme-text-color);
--text-secondary: var(--tg-theme-hint-color);
--bg-primary: var(--tg-theme-bg-color);
--bg-secondary: var(--tg-theme-secondary-bg-color);
--border-color: rgba(0, 0, 0, 0.08);
--shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
--radius: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
padding: 16px;
padding-bottom: 32px;
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 480px;
margin: 0 auto;
}
.language-switcher {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.language-select {
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: white;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
appearance: none;
}
.language-select:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(36, 129, 204, 0.15);
}
/* Header */
.header {
text-align: center;
margin-bottom: 24px;
}
.logo {
font-size: 28px;
font-weight: 700;
color: var(--primary);
margin-bottom: 4px;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
}
/* Loading State */
.loading {
text-align: center;
padding: 60px 20px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--bg-secondary);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Card */
.card {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
box-shadow: var(--shadow);
}
.card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.balance-summary {
display: flex;
align-items: baseline;
gap: 8px;
}
.balance-amount {
font-size: 24px;
font-weight: 700;
color: var(--primary);
}
.balance-currency {
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
}
.history-list,
.server-list {
list-style: none;
padding: 0;
margin: 0;
}
.history-item {
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.history-item:last-child {
border-bottom: none;
}
.history-item-main {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.history-amount {
font-size: 16px;
font-weight: 700;
}
.history-amount.positive {
color: #0f5132;
}
.history-amount.negative {
color: #842029;
}
.history-type {
font-size: 14px;
font-weight: 600;
}
.history-meta {
font-size: 12px;
color: var(--text-secondary);
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.history-meta span + span::before {
content: '•';
margin: 0 4px 0 0;
color: var(--border-color);
}
.history-description {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.5;
}
.empty-state {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
padding: 12px 0;
}
.server-item {
padding: 12px;
background: white;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
box-shadow: inset 0 0 0 1px var(--border-color);
margin-bottom: 8px;
}
.server-item:last-child {
margin-bottom: 0;
}
.device-list {
list-style: none;
padding: 0;
margin: 0;
}
.device-item {
padding: 12px;
background: white;
border-radius: 10px;
font-size: 13px;
color: var(--text-primary);
box-shadow: inset 0 0 0 1px var(--border-color);
margin-bottom: 8px;
}
.device-item:last-child {
margin-bottom: 0;
}
.device-title {
font-weight: 600;
margin-bottom: 4px;
}
.device-meta {
font-size: 12px;
color: var(--text-secondary);
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* User Info */
.user-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.user-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), #1a6fb8);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
flex-shrink: 0;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.status-active {
background: #d4f4dd;
color: #0f5132;
}
.status-expired {
background: #f8d7da;
color: #842029;
}
.status-trial {
background: #fff3cd;
color: #664d03;
}
.status-disabled {
background: #e2e3e5;
color: #41464b;
}
.status-unknown {
background: #e7eaf3;
color: #495057;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.stat-item {
text-align: center;
padding: 16px 12px;
background: white;
border-radius: 10px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
}
/* Info Items */
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 14px;
color: var(--text-secondary);
}
.info-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
text-align: right;
}
/* Buttons */
.btn {
width: 100%;
padding: 14px 20px;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:active {
transform: scale(0.98);
opacity: 0.9;
}
.btn-secondary {
background: white;
color: var(--primary);
border: 1px solid var(--border-color);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
.btn-icon {
width: 20px;
height: 20px;
}
/* App Installation */
.app-section {
margin-top: 24px;
}
.platform-selector {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.platform-btn {
padding: 12px 8px;
background: white;
border: 2px solid var(--border-color);
border-radius: var(--radius);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.platform-btn.active {
border-color: var(--primary);
background: rgba(36, 129, 204, 0.1);
color: var(--primary);
}
.app-card {
background: white;
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
border: 2px solid var(--border-color);
}
.app-card.featured {
border-color: var(--primary);
background: rgba(36, 129, 204, 0.02);
}
.app-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.app-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.app-name {
font-size: 16px;
font-weight: 600;
}
.featured-badge {
display: inline-block;
background: var(--primary);
color: white;
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.app-steps {
margin-top: 12px;
}
.step {
margin-bottom: 12px;
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.step-number {
width: 20px;
height: 20px;
background: var(--primary);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.step-description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 8px;
}
.step-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.step-btn {
padding: 8px 14px;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
display: inline-block;
}
/* Error State */
.error {
text-align: center;
padding: 40px 20px;
}
.error-icon {
font-size: 48px;
margin-bottom: 12px;
}
.error-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.error-text {
font-size: 14px;
color: var(--text-secondary);
}
/* Hidden */
.hidden {
display: none !important;
}
</style>
</head>
<body>
<div class="container">
<div class="language-switcher">
<select id="languageSelect" class="language-select">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<!-- Header -->
<div class="header">
<div class="logo" data-i18n="app.name">RemnaWave VPN</div>
<div class="subtitle" data-i18n="app.subtitle">Secure & Fast Connection</div>
</div>
<!-- Loading State -->
<div id="loadingState" class="loading">
<div class="spinner"></div>
<div id="loadingText" data-i18n="app.loading">Loading your subscription...</div>
</div>
<!-- Error State -->
<div id="errorState" class="error hidden">
<div class="error-icon">⚠️</div>
<div class="error-title" id="errorTitle" data-i18n="error.default.title">Subscription Not Found</div>
<div class="error-text" id="errorText" data-i18n="error.default.message">Please contact support to activate your subscription</div>
</div>
<!-- Main Content -->
<div id="mainContent" class="hidden">
<!-- User Card -->
<div class="card">
<div class="user-header">
<div class="user-avatar" id="userAvatar">U</div>
<div class="user-info">
<div class="user-name" id="userName">Loading...</div>
<span class="status-badge status-active" id="statusBadge">Active</span>
</div>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value" id="daysLeft">-</div>
<div class="stat-label" data-i18n="stats.days_left">Days Left</div>
</div>
<div class="stat-item">
<div class="stat-value" id="serversCount">-</div>
<div class="stat-label" data-i18n="stats.servers">Servers</div>
</div>
<div class="stat-item">
<div class="stat-value" id="devicesCount">-</div>
<div class="stat-label" data-i18n="stats.devices">Devices</div>
</div>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.expires">Expires</span>
<span class="info-value" id="expiresAt">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.traffic_used">Traffic Used</span>
<span class="info-value" id="trafficUsed">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.traffic_limit">Traffic Limit</span>
<span class="info-value" id="trafficLimit">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.subscription_type">Subscription Type</span>
<span class="info-value" id="subscriptionType">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.promo_group">Promo Group</span>
<span class="info-value" id="promoGroup">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.device_limit">Device Limit</span>
<span class="info-value" id="deviceLimit">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.autopay">Auto-Pay</span>
<span class="info-value" id="autopayStatus">-</span>
</div>
</div>
<!-- Action Buttons -->
<button class="btn btn-primary" id="connectBtn">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span id="connectBtnText" data-i18n="button.connect.default">Connect to VPN</span>
</button>
<button class="btn btn-secondary" id="copyBtn" style="margin-top: 8px;">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span data-i18n="button.copy">Copy Subscription Link</span>
</button>
<!-- Balance Card -->
<div class="card" id="balanceCard">
<div class="card-title" data-i18n="card.balance.title">Balance</div>
<div class="balance-summary">
<div class="balance-amount" id="balanceAmount"></div>
<div class="balance-currency hidden" id="balanceCurrency"></div>
</div>
</div>
<!-- History Card -->
<div class="card" id="historyCard">
<div class="card-title" data-i18n="card.history.title">Transaction History</div>
<ul class="history-list" id="historyList"></ul>
<div class="empty-state hidden" id="historyEmpty" data-i18n="history.empty">No transactions yet</div>
</div>
<!-- Servers Card -->
<div class="card" id="serversCard">
<div class="card-title" data-i18n="card.servers.title">Connected Servers</div>
<ul class="server-list" id="serversList"></ul>
<div class="empty-state hidden" id="serversEmpty" data-i18n="servers.empty">No servers connected yet</div>
</div>
<div class="card" id="devicesCard">
<div class="card-title" data-i18n="card.devices.title">Connected Devices</div>
<ul class="device-list" id="devicesList"></ul>
<div class="empty-state hidden" id="devicesEmpty" data-i18n="devices.empty">No devices connected yet</div>
</div>
<!-- App Installation Section -->
<div class="app-section">
<div class="card">
<div class="card-title" data-i18n="apps.title">Installation Guide</div>
<div class="platform-selector">
<button class="platform-btn" data-platform="ios" data-i18n="platform.ios">iOS</button>
<button class="platform-btn active" data-platform="android" data-i18n="platform.android">Android</button>
<button class="platform-btn" data-platform="pc" data-i18n="platform.pc">PC</button>
<button class="platform-btn" data-platform="tv" data-i18n="platform.tv">TV</button>
</div>
<div id="appsContainer">
<!-- Apps will be rendered here -->
</div>
</div>
</div>
</div>
</div>
<script>
const tg = window.Telegram?.WebApp || {
initData: '',
initDataUnsafe: {},
expand: () => {},
ready: () => {},
themeParams: {},
showPopup: null,
};
if (typeof tg.expand === 'function') {
tg.expand();
}
if (typeof tg.ready === 'function') {
tg.ready();
}
if (tg.themeParams) {
Object.keys(tg.themeParams).forEach(key => {
document.documentElement.style.setProperty(`--tg-theme-${key.replace(/_/g, '-')}`, tg.themeParams[key]);
});
}
const LANG_STORAGE_KEY = 'remnawave-miniapp-language';
const SUPPORTED_LANGUAGES = ['en', 'ru'];
const translations = {
en: {
'app.title': 'VPN Subscription',
'app.name': 'RemnaWave VPN',
'app.subtitle': 'Secure & Fast Connection',
'app.loading': 'Loading your subscription...',
'error.default.title': 'Subscription Not Found',
'error.default.message': 'Please contact support to activate your subscription.',
'stats.days_left': 'Days left',
'stats.servers': 'Servers',
'stats.devices': 'Devices',
'info.expires': 'Expires',
'info.traffic_used': 'Traffic used',
'info.traffic_limit': 'Traffic limit',
'info.subscription_type': 'Subscription type',
'info.promo_group': 'Promo group',
'info.device_limit': 'Device limit',
'info.autopay': 'Auto-pay',
'button.connect.default': 'Connect to VPN',
'button.connect.happ': 'Connect',
'button.copy': 'Copy subscription link',
'card.balance.title': 'Balance',
'card.history.title': 'Transaction History',
'card.servers.title': 'Connected Servers',
'card.devices.title': 'Connected Devices',
'apps.title': 'Installation guide',
'apps.no_data': 'No installation guide available for this platform yet.',
'apps.featured': 'Recommended',
'apps.step.download': 'Download & install',
'apps.step.add': 'Add subscription',
'apps.step.connect': 'Connect & use',
'history.empty': 'No transactions yet',
'history.status.completed': 'Completed',
'history.status.pending': 'Processing',
'history.type.deposit': 'Top-up',
'history.type.withdrawal': 'Withdrawal',
'history.type.subscription_payment': 'Subscription payment',
'history.type.refund': 'Refund',
'history.type.referral_reward': 'Referral reward',
'servers.empty': 'No servers connected yet',
'devices.empty': 'No devices connected yet',
'language.ariaLabel': 'Select interface language',
'notifications.copy.success': 'Subscription link copied to clipboard.',
'notifications.copy.failure': 'Unable to copy the subscription link automatically. Please copy it manually.',
'notifications.copy.title.success': 'Copied',
'notifications.copy.title.failure': 'Copy failed',
'status.active': 'Active',
'status.trial': 'Trial',
'status.expired': 'Expired',
'status.disabled': 'Disabled',
'status.unknown': 'Unknown',
'subscription.type.trial': 'Trial',
'subscription.type.paid': 'Paid',
'autopay.enabled': 'Enabled',
'autopay.disabled': 'Disabled',
'platform.ios': 'iOS',
'platform.android': 'Android',
'platform.pc': 'PC',
'platform.tv': 'TV',
'units.gb': 'GB',
'values.unlimited': 'Unlimited',
'values.not_available': 'Not available'
},
ru: {
'app.title': 'Подписка VPN',
'app.name': 'RemnaWave VPN',
'app.subtitle': 'Безопасное и быстрое подключение',
'app.loading': 'Загружаем вашу подписку...',
'error.default.title': 'Подписка не найдена',
'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.',
'stats.days_left': 'Осталось дней',
'stats.servers': 'Серверы',
'stats.devices': 'Устройства',
'info.expires': 'Действует до',
'info.traffic_used': 'Использовано трафика',
'info.traffic_limit': 'Лимит трафика',
'info.subscription_type': 'Тип подписки',
'info.promo_group': 'Промогруппа',
'info.device_limit': 'Лимит устройств',
'info.autopay': 'Автоплатеж',
'button.connect.default': 'Подключиться к VPN',
'button.connect.happ': 'Подключиться',
'button.copy': 'Скопировать ссылку подписки',
'card.balance.title': 'Баланс',
'card.history.title': 'История операций',
'card.servers.title': 'Подключённые серверы',
'card.devices.title': 'Подключенные устройства',
'apps.title': 'Инструкция по установке',
'apps.no_data': 'Для этой платформы инструкция пока недоступна.',
'apps.featured': 'Рекомендуем',
'apps.step.download': 'Скачать и установить',
'apps.step.add': 'Добавить подписку',
'apps.step.connect': 'Подключиться и пользоваться',
'history.empty': 'Операции ещё не проводились',
'history.status.completed': 'Выполнено',
'history.status.pending': 'Обрабатывается',
'history.type.deposit': 'Пополнение',
'history.type.withdrawal': 'Списание',
'history.type.subscription_payment': 'Оплата подписки',
'history.type.refund': 'Возврат',
'history.type.referral_reward': 'Реферальное вознаграждение',
'servers.empty': 'Подключённых серверов пока нет',
'devices.empty': 'Подключённых устройств пока нет',
'language.ariaLabel': 'Выберите язык интерфейса',
'notifications.copy.success': 'Ссылка подписки скопирована.',
'notifications.copy.failure': 'Не удалось автоматически скопировать ссылку. Пожалуйста, сделайте это вручную.',
'notifications.copy.title.success': 'Готово',
'notifications.copy.title.failure': 'Ошибка копирования',
'status.active': 'Активна',
'status.trial': 'Пробная',
'status.expired': 'Истекла',
'status.disabled': 'Отключена',
'status.unknown': 'Неизвестно',
'subscription.type.trial': 'Триал',
'subscription.type.paid': 'Платная',
'autopay.enabled': 'Включен',
'autopay.disabled': 'Выключен',
'platform.ios': 'iOS',
'platform.android': 'Android',
'platform.pc': 'ПК',
'platform.tv': 'ТВ',
'units.gb': 'ГБ',
'values.unlimited': 'Безлимит',
'values.not_available': 'Недоступно'
}
};
let userData = null;
let appsConfig = {};
let currentPlatform = 'android';
let preferredLanguage = 'en';
let languageLockedByUser = false;
let currentErrorState = null;
function resolveLanguage(lang) {
if (!lang) {
return null;
}
const normalized = String(lang).toLowerCase();
if (SUPPORTED_LANGUAGES.includes(normalized)) {
return normalized;
}
const short = normalized.split('-')[0];
if (SUPPORTED_LANGUAGES.includes(short)) {
return short;
}
return null;
}
function safeGetStoredLanguage() {
try {
return localStorage.getItem(LANG_STORAGE_KEY);
} catch (error) {
console.warn('Unable to access localStorage:', error);
return null;
}
}
function safeSetStoredLanguage(lang) {
try {
localStorage.setItem(LANG_STORAGE_KEY, lang);
} catch (error) {
console.warn('Unable to persist language preference:', error);
}
}
function t(key) {
const language = preferredLanguage || 'en';
const chain = [];
if (translations[language]) {
chain.push(translations[language]);
}
const base = language.split('-')[0];
if (translations[base] && !chain.includes(translations[base])) {
chain.push(translations[base]);
}
if (translations.en && !chain.includes(translations.en)) {
chain.push(translations.en);
}
for (const dict of chain) {
if (dict && Object.prototype.hasOwnProperty.call(dict, key)) {
return dict[key];
}
}
return key;
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
function updateErrorTexts() {
const titleElement = document.getElementById('errorTitle');
const textElement = document.getElementById('errorText');
if (!titleElement || !textElement) {
return;
}
const title = currentErrorState?.title || t('error.default.title');
const message = currentErrorState?.message || t('error.default.message');
titleElement.textContent = title;
textElement.textContent = message;
}
function applyTranslations() {
document.title = t('app.title');
document.documentElement.setAttribute('lang', preferredLanguage);
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
if (!key) {
return;
}
element.textContent = t(key);
});
const languageSelect = document.getElementById('languageSelect');
if (languageSelect) {
languageSelect.value = preferredLanguage;
languageSelect.setAttribute('aria-label', t('language.ariaLabel'));
}
updateErrorTexts();
}
function updateConnectButtonLabel() {
const label = document.getElementById('connectBtnText');
if (!label) {
return;
}
const useHappLabel = Boolean(userData?.happ_cryptolink_redirect_link);
const key = useHappLabel ? 'button.connect.happ' : 'button.connect.default';
label.textContent = t(key);
}
function refreshAfterLanguageChange() {
applyTranslations();
if (userData) {
renderUserData();
} else {
updateConnectButtonLabel();
}
renderApps();
updateActionButtons();
}
function setLanguage(language, options = {}) {
const persist = Boolean(options.persist);
const resolved = resolveLanguage(language) || preferredLanguage;
if (!persist && languageLockedByUser && resolved !== preferredLanguage) {
return;
}
preferredLanguage = resolved;
if (persist) {
languageLockedByUser = true;
safeSetStoredLanguage(preferredLanguage);
}
refreshAfterLanguageChange();
}
const storedLanguage = resolveLanguage(safeGetStoredLanguage());
if (storedLanguage) {
preferredLanguage = storedLanguage;
languageLockedByUser = true;
} else {
const telegramLanguage = resolveLanguage(tg.initDataUnsafe?.user?.language_code);
if (telegramLanguage) {
preferredLanguage = telegramLanguage;
}
}
applyTranslations();
updateConnectButtonLabel();
document.getElementById('languageSelect')?.addEventListener('change', event => {
setLanguage(event.target.value, { persist: true });
});
function createError(title, message, status) {
const error = new Error(message || title);
if (title) {
error.title = title;
}
if (status) {
error.status = status;
}
return error;
}
async function init() {
try {
const telegramUser = tg.initDataUnsafe?.user;
if (telegramUser?.language_code && !languageLockedByUser) {
const resolved = resolveLanguage(telegramUser.language_code);
if (resolved) {
preferredLanguage = resolved;
applyTranslations();
updateConnectButtonLabel();
}
}
await loadAppsConfig();
const initData = tg.initData || '';
if (!initData) {
throw createError('Authorization Error', 'Missing Telegram init data');
}
const response = await fetch('/miniapp/subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData })
});
if (!response.ok) {
let detail = response.status === 401
? 'Authorization failed. Please open the mini app from Telegram.'
: 'Subscription not found';
let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found';
try {
const errorPayload = await response.json();
if (errorPayload?.detail) {
detail = errorPayload.detail;
}
} catch (parseError) {
// ignore
}
throw createError(title, detail, response.status);
}
userData = await response.json();
userData.subscriptionUrl = userData.subscription_url || null;
userData.subscriptionCryptoLink = userData.subscription_crypto_link || null;
const responseLanguage = resolveLanguage(userData?.user?.language);
if (responseLanguage && !languageLockedByUser) {
preferredLanguage = responseLanguage;
}
detectPlatform();
setActivePlatformButton();
refreshAfterLanguageChange();
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('mainContent').classList.remove('hidden');
} catch (error) {
console.error('Initialization error:', error);
showError(error);
}
}
async function loadAppsConfig() {
try {
const response = await fetch('/app-config.json', { cache: 'no-cache' });
if (!response.ok) {
throw new Error('Failed to load app config');
}
const data = await response.json();
appsConfig = data?.platforms || {};
} catch (error) {
console.warn('Unable to load apps configuration:', error);
appsConfig = {};
}
}
function renderUserData() {
if (!userData?.user) {
return;
}
const user = userData.user;
const rawName = user.display_name || user.username || '';
const fallbackName = rawName
|| [user.first_name, user.last_name].filter(Boolean).join(' ')
|| `User ${user.telegram_id || ''}`.trim();
const avatarChar = (fallbackName.replace(/^@/, '')[0] || 'U').toUpperCase();
document.getElementById('userAvatar').textContent = avatarChar;
document.getElementById('userName').textContent = fallbackName;
const knownStatuses = ['active', 'trial', 'expired', 'disabled'];
const statusValueRaw = (user.subscription_actual_status || user.subscription_status || 'active').toLowerCase();
const statusClass = knownStatuses.includes(statusValueRaw) ? statusValueRaw : 'unknown';
const statusBadge = document.getElementById('statusBadge');
const statusKey = `status.${statusClass}`;
const statusLabel = t(statusKey);
statusBadge.textContent = statusLabel === statusKey
? (user.status_label || statusClass.charAt(0).toUpperCase() + statusClass.slice(1))
: statusLabel;
statusBadge.className = `status-badge status-${statusClass}`;
const expiresAt = user.expires_at ? new Date(user.expires_at) : null;
let daysLeft = '—';
if (expiresAt && !Number.isNaN(expiresAt.getTime())) {
const diffDays = Math.ceil((expiresAt - new Date()) / (1000 * 60 * 60 * 24));
daysLeft = diffDays > 0 ? diffDays : '0';
}
document.getElementById('daysLeft').textContent = daysLeft;
document.getElementById('expiresAt').textContent = formatDate(user.expires_at);
const serversCount = Array.isArray(userData.connected_squads)
? userData.connected_squads.length
: Array.isArray(userData.connected_servers)
? userData.connected_servers.length
: Array.isArray(userData.links)
? userData.links.length
: 0;
document.getElementById('serversCount').textContent = serversCount;
const devicesCountRaw = Number(userData?.connected_devices_count);
const devicesCount = Number.isFinite(devicesCountRaw)
? devicesCountRaw
: Array.isArray(userData?.connected_devices)
? userData.connected_devices.length
: 0;
const devicesCountElement = document.getElementById('devicesCount');
if (devicesCountElement) {
devicesCountElement.textContent = devicesCount;
}
document.getElementById('trafficUsed').textContent =
user.traffic_used_label || formatTraffic(user.traffic_used_gb);
document.getElementById('trafficLimit').textContent =
user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb);
const deviceLimitElement = document.getElementById('deviceLimit');
if (deviceLimitElement) {
const limitValue = typeof user.device_limit === 'number'
? user.device_limit
: Number.parseInt(user.device_limit ?? '', 10);
deviceLimitElement.textContent = Number.isFinite(limitValue)
? String(limitValue)
: t('values.not_available');
}
const subscriptionTypeElement = document.getElementById('subscriptionType');
if (subscriptionTypeElement) {
const fallbackSubscriptionType = (user?.subscription_status || '').toLowerCase() === 'trial'
? 'trial'
: 'paid';
const subscriptionTypeRaw = String(
userData?.subscription_type
|| fallbackSubscriptionType
).toLowerCase();
const subscriptionTypeKey = `subscription.type.${subscriptionTypeRaw}`;
const subscriptionTypeLabel = t(subscriptionTypeKey);
subscriptionTypeElement.textContent = subscriptionTypeLabel === subscriptionTypeKey
? subscriptionTypeRaw
: subscriptionTypeLabel;
}
const promoGroupElement = document.getElementById('promoGroup');
if (promoGroupElement) {
const promoGroupName = userData?.promo_group?.name || user?.promo_group?.name;
promoGroupElement.textContent = promoGroupName || t('values.not_available');
}
const autopayElement = document.getElementById('autopayStatus');
if (autopayElement) {
const autopayKey = userData?.autopay_enabled ? 'autopay.enabled' : 'autopay.disabled';
const autopayLabel = t(autopayKey);
autopayElement.textContent = autopayLabel === autopayKey
? (userData?.autopay_enabled ? 'On' : 'Off')
: autopayLabel;
}
renderBalanceSection();
renderTransactionHistory();
renderServersList();
renderDevicesList();
updateConnectButtonLabel();
updateActionButtons();
}
function detectPlatform() {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('iphone') || userAgent.includes('ipad')) {
currentPlatform = 'ios';
} else if (userAgent.includes('android')) {
currentPlatform = 'android';
} else {
currentPlatform = 'pc';
}
}
function setActivePlatformButton() {
document.querySelectorAll('.platform-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.platform === currentPlatform);
});
}
function getPlatformKey(platform) {
const mapping = {
ios: 'ios',
android: 'android',
pc: 'windows',
tv: 'androidTV',
mac: 'macos'
};
return mapping[platform] || platform;
}
function getAppsForCurrentPlatform() {
const platformKey = getPlatformKey(currentPlatform);
return appsConfig?.[platformKey] || [];
}
function renderApps() {
const container = document.getElementById('appsContainer');
if (!container) {
return;
}
const apps = getAppsForCurrentPlatform();
if (!apps.length) {
container.innerHTML = `<div class="step-description">${escapeHtml(t('apps.no_data'))}</div>`;
return;
}
container.innerHTML = apps.map(app => {
const iconChar = (app.name?.[0] || 'A').toUpperCase();
const appName = escapeHtml(app.name || 'App');
const featuredBadge = app.isFeatured
? `<span class="featured-badge">${escapeHtml(t('apps.featured'))}</span>`
: '';
return `
<div class="app-card ${app.isFeatured ? 'featured' : ''}">
<div class="app-header">
<div class="app-icon">${escapeHtml(iconChar)}</div>
<div>
<div class="app-name">${appName}</div>
${featuredBadge}
</div>
</div>
<div class="app-steps">
${renderAppSteps(app)}
</div>
</div>
`;
}).join('');
}
function renderAppSteps(app) {
let html = '';
let stepNum = 1;
if (app.installationStep) {
const descriptionHtml = app.installationStep.description
? `<div class="step-description">${getLocalizedText(app.installationStep.description)}</div>`
: '';
const buttonsHtml = Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length
? `
<div class="step-buttons">
${app.installationStep.buttons.map(btn => {
const buttonText = escapeHtml(getLocalizedText(btn.buttonText));
return `<a href="${btn.buttonLink}" class="step-btn" target="_blank" rel="noopener">${buttonText}</a>`;
}).join('')}
</div>
`
: '';
html += `
<div class="step">
<div class="step-title">
<span class="step-number">${stepNum++}</span>
${escapeHtml(t('apps.step.download'))}
</div>
${descriptionHtml}
${buttonsHtml}
</div>
`;
}
if (app.addSubscriptionStep) {
html += `
<div class="step">
<div class="step-title">
<span class="step-number">${stepNum++}</span>
${escapeHtml(t('apps.step.add'))}
</div>
<div class="step-description">${getLocalizedText(app.addSubscriptionStep.description)}</div>
</div>
`;
}
if (app.connectAndUseStep) {
html += `
<div class="step">
<div class="step-title">
<span class="step-number">${stepNum++}</span>
${escapeHtml(t('apps.step.connect'))}
</div>
<div class="step-description">${getLocalizedText(app.connectAndUseStep.description)}</div>
</div>
`;
}
return html;
}
function getLocalizedText(textObj) {
if (!textObj) {
return '';
}
if (typeof textObj === 'string') {
return textObj;
}
const telegramLang = tg.initDataUnsafe?.user?.language_code;
const preferenceOrder = [
preferredLanguage,
preferredLanguage?.split('-')[0],
userData?.user?.language,
telegramLang,
telegramLang?.split('-')[0],
'en',
'ru'
].filter(Boolean).map(lang => lang.toLowerCase());
const seen = new Set();
for (const lang of preferenceOrder) {
if (seen.has(lang)) {
continue;
}
seen.add(lang);
if (textObj[lang]) {
return textObj[lang];
}
}
const fallback = Object.values(textObj).find(value => typeof value === 'string' && value.trim().length);
return fallback || '';
}
function formatTraffic(value) {
const numeric = typeof value === 'number' ? value : Number.parseFloat(value ?? '0');
if (!Number.isFinite(numeric)) {
return `0 ${t('units.gb')}`;
}
if (numeric >= 100) {
return `${numeric.toFixed(0)} ${t('units.gb')}`;
}
if (numeric >= 10) {
return `${numeric.toFixed(1)} ${t('units.gb')}`;
}
return `${numeric.toFixed(2)} ${t('units.gb')}`;
}
function formatTrafficLimit(limit) {
const numeric = typeof limit === 'number' ? limit : Number.parseFloat(limit ?? '0');
if (!Number.isFinite(numeric) || numeric <= 0) {
return t('values.unlimited');
}
return `${numeric.toFixed(0)} ${t('units.gb')}`;
}
function formatCurrency(value, currency = 'RUB') {
const numeric = typeof value === 'number' ? value : Number.parseFloat(value ?? '0');
if (!Number.isFinite(numeric)) {
return `0 ${currency}`;
}
try {
return new Intl.NumberFormat(preferredLanguage, {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(numeric);
} catch (error) {
return `${numeric.toFixed(2)} ${currency}`;
}
}
function formatDate(value) {
if (!value) {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
try {
return new Intl.DateTimeFormat(preferredLanguage, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
} catch (error) {
return date.toLocaleDateString();
}
}
function formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
try {
return new Intl.DateTimeFormat(preferredLanguage, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
} catch (error) {
return date.toLocaleString();
}
}
function renderBalanceSection() {
const amountElement = document.getElementById('balanceAmount');
const currencyElement = document.getElementById('balanceCurrency');
if (!amountElement) {
return;
}
const balanceRubles = typeof userData?.balance_rubles === 'number'
? userData.balance_rubles
: Number.parseFloat(userData?.balance_rubles ?? '0');
const currency = (userData?.balance_currency || 'RUB').toUpperCase();
amountElement.textContent = formatCurrency(balanceRubles, currency);
if (currencyElement) {
if (userData?.balance_currency) {
currencyElement.textContent = currency;
currencyElement.classList.remove('hidden');
} else {
currencyElement.textContent = '';
currencyElement.classList.add('hidden');
}
}
}
function renderTransactionHistory() {
const list = document.getElementById('historyList');
const emptyState = document.getElementById('historyEmpty');
if (!list || !emptyState) {
return;
}
const transactions = Array.isArray(userData?.transactions) ? userData.transactions : [];
if (!transactions.length) {
list.innerHTML = '';
emptyState.textContent = t('history.empty');
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
const currency = (userData?.balance_currency || 'RUB').toUpperCase();
const negativeTypes = new Set(['withdrawal', 'subscription_payment']);
const itemsHtml = transactions.map(tx => {
const type = (tx.type || '').toLowerCase();
const typeKey = `history.type.${type}`;
const typeLabelRaw = t(typeKey);
const typeLabel = typeLabelRaw === typeKey ? (tx.type || type || '').replace(/_/g, ' ') : typeLabelRaw;
const amountRaw = typeof tx.amount_kopeks === 'number'
? tx.amount_kopeks
: Number.parseInt(tx.amount_kopeks ?? '0', 10);
const amountValue = Number.isFinite(amountRaw) ? amountRaw / 100 : 0;
const isNegative = amountValue < 0 || negativeTypes.has(type);
const amountFormatted = `${isNegative ? '' : '+'}${formatCurrency(Math.abs(amountValue), currency)}`;
const statusKey = tx.is_completed ? 'history.status.completed' : 'history.status.pending';
const statusLabel = t(statusKey);
const metaParts = [];
const createdAt = formatDateTime(tx.created_at);
if (createdAt) {
metaParts.push(`<span>${escapeHtml(createdAt)}</span>`);
}
if (statusLabel) {
metaParts.push(`<span>${escapeHtml(statusLabel)}</span>`);
}
const metaHtml = metaParts.length ? `<div class="history-meta">${metaParts.join('')}</div>` : '';
const descriptionHtml = tx.description
? `<div class="history-description">${escapeHtml(tx.description)}</div>`
: '';
const amountClass = isNegative ? 'history-amount negative' : 'history-amount positive';
return `
<li class="history-item">
<div class="history-item-main">
<span class="${amountClass}">${amountFormatted}</span>
<span class="history-type">${escapeHtml(typeLabel)}</span>
</div>
${metaHtml}
${descriptionHtml}
</li>
`;
}).join('');
list.innerHTML = itemsHtml;
}
function renderServersList() {
const list = document.getElementById('serversList');
const emptyState = document.getElementById('serversEmpty');
if (!list || !emptyState) {
return;
}
let servers = [];
if (Array.isArray(userData?.connected_servers) && userData.connected_servers.length) {
servers = userData.connected_servers.map(server => ({
uuid: server?.uuid || '',
name: server?.name || server?.uuid || '',
}));
} else if (Array.isArray(userData?.connected_squads)) {
servers = userData.connected_squads.map(uuid => ({ uuid, name: uuid }));
}
if (!servers.length) {
list.innerHTML = '';
emptyState.textContent = t('servers.empty');
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
list.innerHTML = servers
.map(server => `<li class="server-item">${escapeHtml(server.name || server.uuid || '')}</li>`)
.join('');
}
function renderDevicesList() {
const list = document.getElementById('devicesList');
const emptyState = document.getElementById('devicesEmpty');
if (!list || !emptyState) {
return;
}
const devices = Array.isArray(userData?.connected_devices)
? userData.connected_devices
: [];
if (!devices.length) {
list.innerHTML = '';
emptyState.textContent = t('devices.empty');
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
list.innerHTML = devices.map(device => {
const platform = device?.platform ? String(device.platform) : '';
const model = device?.device_model ? String(device.device_model) : '';
const titleParts = [platform, model].filter(Boolean);
const title = titleParts.length
? titleParts.join(' — ')
: t('values.not_available');
const metaParts = [];
if (device?.app_version) {
metaParts.push(String(device.app_version));
}
if (device?.last_seen) {
const formatted = formatDateTime(device.last_seen);
if (formatted) {
metaParts.push(formatted);
}
}
if (device?.last_ip) {
metaParts.push(String(device.last_ip));
}
const metaHtml = metaParts.length
? `<div class="device-meta">${metaParts.map(part => `<span>${escapeHtml(part)}</span>`).join('')}</div>`
: '';
return `
<li class="device-item">
<div class="device-title">${escapeHtml(title)}</div>
${metaHtml}
</li>
`;
}).join('');
}
function getCurrentSubscriptionUrl() {
return userData?.subscription_url || userData?.subscriptionUrl || '';
}
function getConnectLink() {
if (!userData) {
return null;
}
if (userData.happ_cryptolink_redirect_link) {
return userData.happ_cryptolink_redirect_link;
}
const subscriptionUrl = getCurrentSubscriptionUrl();
if (!subscriptionUrl) {
return null;
}
const apps = getAppsForCurrentPlatform();
const featuredApp = apps.find(app => app.isFeatured) || apps[0];
if (featuredApp?.urlScheme) {
return `${featuredApp.urlScheme}${subscriptionUrl}`;
}
if (userData?.happ_link && featuredApp?.id === 'happ') {
return userData.happ_link;
}
return subscriptionUrl;
}
function updateActionButtons() {
const connectBtn = document.getElementById('connectBtn');
const copyBtn = document.getElementById('copyBtn');
const connectLink = getConnectLink();
if (connectBtn) {
const hasConnect = Boolean(connectLink);
connectBtn.disabled = !hasConnect;
connectBtn.classList.toggle('hidden', !hasConnect);
}
const subscriptionUrl = getCurrentSubscriptionUrl();
if (copyBtn) {
const hasUrl = Boolean(subscriptionUrl);
copyBtn.disabled = !hasUrl || !navigator.clipboard;
}
}
function showPopup(message, title = 'Info') {
if (typeof tg.showPopup === 'function') {
tg.showPopup({
title,
message,
buttons: [{ type: 'ok' }]
});
} else {
alert(message);
}
}
function showError(error) {
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('mainContent').classList.add('hidden');
currentErrorState = {
title: error?.title,
message: error?.message,
};
updateErrorTexts();
document.getElementById('errorState').classList.remove('hidden');
updateActionButtons();
}
document.querySelectorAll('.platform-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentPlatform = btn.dataset.platform;
setActivePlatformButton();
renderApps();
updateActionButtons();
});
});
document.getElementById('connectBtn')?.addEventListener('click', () => {
const link = getConnectLink();
if (link) {
window.location.href = link;
}
});
document.getElementById('copyBtn')?.addEventListener('click', async () => {
const subscriptionUrl = getCurrentSubscriptionUrl();
if (!subscriptionUrl || !navigator.clipboard) {
return;
}
try {
await navigator.clipboard.writeText(subscriptionUrl);
showPopup(t('notifications.copy.success'), t('notifications.copy.title.success'));
} catch (error) {
console.warn('Clipboard copy failed:', error);
showPopup(t('notifications.copy.failure'), t('notifications.copy.title.failure'));
}
});
init();
</script>
</body>
</html>