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

10333 lines
396 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);
--primary-rgb: 36, 129, 204;
--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-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--danger-rgb: 239, 68, 68;
--info: #3b82f6;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg-primary: #0f172a;
--bg-secondary: rgba(30, 41, 59, 0.85);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border-color: rgba(148, 163, 184, 0.25);
--shadow-sm: 0 2px 8px rgba(2, 6, 23, 0.3);
--shadow-md: 0 4px 16px rgba(2, 6, 23, 0.45);
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
}
:root[data-theme="light"] {
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--border-color: rgba(255, 255, 255, 0.1);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
line-height: 1.6;
}
body.modal-open {
overflow: hidden;
}
.container {
max-width: 480px;
margin: 0 auto;
padding: 16px;
padding-bottom: 32px;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 2000px;
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes shimmer {
to { transform: translateX(100%); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
/* Language Switcher */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px;
}
.language-switcher {
position: relative;
}
.language-select {
padding: 8px 32px 8px 12px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
appearance: none;
transition: all 0.3s ease;
position: relative;
min-width: 120px;
}
.language-select:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.language-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.1);
}
.language-switcher::after {
content: '▼';
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 10px;
color: var(--text-secondary);
}
/* Theme Toggle */
.theme-toggle {
width: 44px;
height: 44px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.theme-toggle:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Header */
.header {
text-align: center;
margin-bottom: 32px;
position: relative;
}
.header::before {
content: '';
position: absolute;
top: -50%;
left: 50%;
transform: translateX(-50%);
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(var(--primary-rgb), 0.1) 0%, transparent 70%);
pointer-events: none;
z-index: -1;
}
.logo-container {
width: 80px;
height: 80px;
margin: 0 auto 16px;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
}
.logo-container::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transform: rotate(45deg);
animation: shimmer 3s infinite;
}
.logo-icon {
font-size: 40px;
color: white;
z-index: 1;
}
.logo {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.subtitle {
font-size: 15px;
color: var(--text-secondary);
font-weight: 500;
}
/* Loading State */
.loading {
text-align: center;
padding: 80px 20px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--bg-secondary);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 20px;
}
.loading-text {
font-size: 16px;
color: var(--text-secondary);
font-weight: 500;
}
/* Error State */
.error {
text-align: center;
padding: 60px 20px;
background: var(--bg-secondary);
border-radius: var(--radius-xl);
margin: 20px 0;
}
.error-icon {
font-size: 64px;
margin-bottom: 16px;
animation: pulse 2s infinite;
}
.error-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-primary);
}
.error-text {
font-size: 15px;
color: var(--text-secondary);
line-height: 1.6;
}
.error-actions {
margin-top: 24px;
display: flex;
justify-content: center;
}
.error-actions .btn {
width: auto;
min-width: 220px;
}
/* Cards */
.card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
transition: all 0.3s ease;
overflow: hidden;
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.card-header {
padding: 16px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.3s ease;
}
.card.expandable .card-header:hover {
background: rgba(var(--primary-rgb), 0.05);
}
.card-title {
font-size: 14px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.card-icon {
width: 20px;
height: 20px;
color: var(--primary);
}
.expand-icon {
width: 24px;
height: 24px;
color: var(--text-secondary);
transition: transform 0.3s ease;
}
.card.expanded .expand-icon {
transform: rotate(180deg);
}
.card-content {
padding: 0 16px;
max-height: 0;
opacity: 0;
overflow: hidden;
transition: all 0.3s ease;
}
.card.expanded .card-content,
.card:not(.expandable) .card-content {
max-height: 2000px;
opacity: 1;
padding: 0 16px 16px;
}
.subscription-settings-card {
position: relative;
}
.subscription-settings-summary {
margin-left: auto;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.subscription-settings-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
}
.subscription-settings-chip span {
color: inherit;
}
.subscription-settings-content {
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 12px;
}
.subscription-settings-section {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid var(--border-color);
}
.subscription-settings-section:last-child {
border-bottom: none;
}
.subscription-settings-section-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.subscription-settings-section-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.subscription-settings-section-description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.45;
}
.subscription-settings-section-meta {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subscription-settings-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.subscription-settings-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 140px;
}
.subscription-settings-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius);
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 0;
}
.subscription-settings-toggle:hover:not(.disabled) {
border-color: rgba(var(--primary-rgb), 0.5);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.subscription-settings-toggle.active {
border-color: var(--primary);
color: var(--primary);
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--primary-rgb), 0.02));
box-shadow: var(--shadow-sm);
}
.subscription-settings-toggle.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.subscription-settings-toggle-label {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.subscription-settings-toggle-title {
font-weight: 700;
font-size: 14px;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subscription-settings-toggle-meta {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.subscription-settings-toggle.active .subscription-settings-toggle-meta {
color: inherit;
}
.subscription-settings-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.subscription-settings-apply {
padding: 12px 18px;
border-radius: var(--radius);
border: none;
background: var(--primary);
color: #fff;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.subscription-settings-apply:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.subscription-settings-apply:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.subscription-settings-inline-hint {
font-size: 12px;
color: var(--text-secondary);
}
.subscription-settings-loading {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 0 12px;
}
.subscription-settings-loading-line {
height: 12px;
border-radius: 999px;
background: rgba(var(--primary-rgb), 0.08);
position: relative;
overflow: hidden;
}
.subscription-settings-loading-line::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(var(--primary-rgb), 0.2), transparent);
animation: shimmer 1.5s infinite;
}
.subscription-settings-error {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
color: var(--danger);
font-size: 14px;
}
.subscription-settings-retry {
align-self: flex-start;
padding: 10px 16px;
border-radius: var(--radius);
border: 1px solid rgba(var(--danger-rgb, 239, 68, 68), 0.4);
background: transparent;
color: var(--danger);
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.subscription-settings-retry:hover {
background: rgba(var(--danger-rgb, 239, 68, 68), 0.1);
transform: translateY(-1px);
}
.subscription-settings-stepper {
display: flex;
align-items: center;
gap: 12px;
}
.subscription-settings-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;
}
.subscription-settings-stepper button:hover:not(:disabled) {
border-color: rgba(var(--primary-rgb), 0.5);
box-shadow: var(--shadow-sm);
}
.subscription-settings-stepper button:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.subscription-settings-stepper-value {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.subscription-settings-price-note {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.subscription-settings-disabled-note {
font-size: 13px;
color: var(--text-secondary);
font-style: italic;
}
.subscription-settings-empty {
font-size: 13px;
color: var(--text-secondary);
padding: 6px 0;
}
.subscription-settings-status-text {
font-size: 13px;
color: var(--text-secondary);
}
:root[data-theme="dark"] .subscription-settings-toggle {
background: rgba(15, 23, 42, 0.6);
}
:root[data-theme="dark"] .subscription-settings-chip {
background: rgba(var(--primary-rgb), 0.22);
color: #fff;
}
.promo-offers {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
.promo-offer-card {
position: relative;
padding: 18px;
border-radius: var(--radius-lg);
border: 1px solid rgba(var(--primary-rgb), 0.12);
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.12), rgba(var(--primary-rgb), 0.04));
color: var(--text-primary);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.promo-offer-card.active {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.24), rgba(var(--primary-rgb), 0.08));
border-color: rgba(var(--primary-rgb), 0.2);
}
.promo-offer-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.promo-offer-icon {
width: 44px;
height: 44px;
border-radius: 14px;
background: rgba(var(--primary-rgb), 0.12);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.promo-offer-card.active .promo-offer-icon {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.promo-offer-heading {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.promo-offer-title {
font-size: 18px;
font-weight: 700;
}
.promo-offer-subtitle {
font-size: 13px;
color: var(--text-secondary);
}
.promo-offer-card.active .promo-offer-subtitle {
color: rgba(255, 255, 255, 0.85);
}
.promo-offer-badge {
font-weight: 700;
font-size: 16px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(var(--primary-rgb), 0.16);
color: var(--text-primary);
}
.promo-offer-card.active .promo-offer-badge {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.promo-offer-message {
font-size: 14px;
line-height: 1.55;
color: inherit;
}
.promo-offer-details {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.promo-offer-chip {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: rgba(var(--primary-rgb), 0.15);
color: var(--text-primary);
}
.promo-offer-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
flex-wrap: wrap;
}
.promo-offer-timer {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.promo-offer-card.active .promo-offer-timer {
color: rgba(255, 255, 255, 0.9);
}
.promo-offer-action {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.promo-offer-btn {
padding: 10px 20px;
border-radius: var(--radius-lg);
border: none;
font-weight: 700;
font-size: 14px;
cursor: pointer;
background: var(--primary);
color: var(--tg-theme-button-text-color);
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.promo-offer-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.promo-offer-btn:disabled {
opacity: 0.6;
cursor: default;
box-shadow: none;
}
.promo-offer-progress {
position: relative;
height: 6px;
border-radius: 999px;
overflow: hidden;
background: rgba(var(--primary-rgb), 0.18);
margin-top: 12px;
}
.promo-offer-progress-bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.55));
transform-origin: left;
transition: width 0.4s ease;
}
.promo-offer-card:not(.active) .promo-offer-progress {
background: rgba(var(--primary-rgb), 0.12);
}
.promo-offer-card:not(.active) .promo-offer-progress-bar {
background: linear-gradient(90deg, rgba(var(--primary-rgb), 0.6), rgba(var(--primary-rgb), 0.3));
}
:root[data-theme="dark"] .promo-offer-card {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.24), rgba(255, 255, 255, 0.04));
border-color: rgba(255, 255, 255, 0.08);
}
:root[data-theme="dark"] .promo-offer-card.active {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.4), rgba(var(--primary-rgb), 0.2));
border-color: rgba(255, 255, 255, 0.18);
}
:root[data-theme="dark"] .promo-offer-icon {
background: rgba(255, 255, 255, 0.12);
}
:root[data-theme="dark"] .promo-offer-card.active .promo-offer-icon {
background: rgba(255, 255, 255, 0.2);
}
:root[data-theme="dark"] .promo-offer-chip {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.9);
}
/* User Card Specific */
.user-card {
background: linear-gradient(135deg, var(--bg-secondary), rgba(var(--primary-rgb), 0.05));
border: 2px solid rgba(var(--primary-rgb), 0.1);
}
.user-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
}
.user-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.6));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 700;
flex-shrink: 0;
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
}
.user-avatar::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 3s infinite;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 20px;
font-weight: 700;
margin-bottom: 6px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
transition: all 0.3s ease;
}
.status-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s infinite;
}
.status-active {
background: linear-gradient(135deg, #d4f4dd, #e6f7ed);
color: #0f5132;
}
.status-expired {
background: linear-gradient(135deg, #f8d7da, #fce4e6);
color: #842029;
}
.status-trial {
background: linear-gradient(135deg, #fff3cd, #fff8e1);
color: #664d03;
}
.status-disabled {
background: linear-gradient(135deg, #e2e3e5, #eeeff1);
color: #41464b;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 12px;
padding: 0 20px 20px;
}
.stat-item {
text-align: center;
padding: 16px 8px;
background: var(--bg-primary);
border-radius: var(--radius);
border: 2px solid var(--border-color);
transition: all 0.3s ease;
}
.stat-item:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.stat-value {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Info Items */
.info-list {
padding: 0 20px 20px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.info-item:last-child {
border-bottom: none;
}
.info-item.promo-group-info {
align-items: flex-start;
}
.info-item:hover {
padding-left: 8px;
background: rgba(var(--primary-rgb), 0.02);
margin: 0 -8px;
padding-right: 8px;
border-radius: var(--radius-sm);
}
.info-label {
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.info-value {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
text-align: right;
}
/* Promo card */
.promo-card .card-header {
gap: 12px;
}
.promo-card-summary {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.promo-card-summary-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.promo-card-summary-value {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.promo-card-summary-discount {
font-size: 12px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
padding: 4px 8px;
border-radius: 999px;
}
.promo-section {
padding: 16px 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.promo-section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.promo-section-title {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.promo-section-value {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-align: right;
}
.promo-section-meta {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
}
.promo-section-meta-label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.promo-section-meta-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.promo-divider {
border-top: 1px solid var(--border-color);
margin: 0 -16px;
}
.promo-discount-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.promo-periods {
display: flex;
flex-direction: column;
gap: 8px;
}
.promo-periods-title,
.promo-periods-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.promo-period-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.promo-period-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(var(--primary-rgb), 0.12);
background: rgba(var(--primary-rgb), 0.05);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.promo-period-badge .promo-period-value {
color: var(--primary);
}
.promo-period-label {
color: var(--text-secondary);
}
.promo-level-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.promo-level-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 14px 16px;
border-radius: var(--radius);
border: 1px solid var(--border-color);
background: var(--bg-secondary);
transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
}
.promo-level-item:hover {
border-color: rgba(var(--primary-rgb), 0.4);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.promo-level-item.current {
border-color: var(--primary);
box-shadow: var(--shadow-sm);
}
.promo-level-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.promo-level-name {
font-weight: 700;
color: var(--text-primary);
font-size: 15px;
}
.promo-level-threshold {
font-size: 13px;
color: var(--text-secondary);
}
.promo-level-benefits {
display: flex;
flex-direction: column;
gap: 6px;
}
.promo-level-badge {
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
}
.promo-level-item.current .promo-level-badge {
background: rgba(var(--primary-rgb), 0.18);
color: var(--primary);
}
.promo-level-item.reached .promo-level-badge {
background: rgba(16, 185, 129, 0.18);
color: var(--success);
}
.promo-level-item.locked .promo-level-badge {
background: rgba(148, 163, 184, 0.18);
color: var(--text-secondary);
}
.promo-discount-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(var(--primary-rgb), 0.12);
background: rgba(var(--primary-rgb), 0.08);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.promo-discount-badge .promo-discount-value {
color: var(--text-primary);
}
.promo-discount-badge.muted {
background: rgba(var(--primary-rgb), 0.05);
border-color: rgba(var(--primary-rgb), 0.08);
}
.promo-discount-badge.muted .promo-discount-value {
color: var(--text-secondary);
}
.promo-level-item.current .promo-discount-badge {
border-color: rgba(var(--primary-rgb), 0.35);
color: var(--primary);
}
.promo-level-item.current .promo-discount-badge .promo-discount-value {
color: var(--primary);
}
.promo-level-item.reached .promo-discount-badge {
border-color: rgba(16, 185, 129, 0.35);
color: var(--success);
}
.promo-level-item.reached .promo-discount-badge .promo-discount-value {
color: var(--success);
}
.promo-level-item.locked .promo-discount-badge {
opacity: 0.8;
}
/* Balance Card */
.balance-card {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--primary-rgb), 0.05));
border: 2px solid rgba(var(--primary-rgb), 0.2);
}
.balance-content {
padding: 20px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.balance-amount {
font-size: 36px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.balance-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.balance-actions {
margin-top: 16px;
display: flex;
justify-content: center;
}
.topup-button {
padding: 10px 20px;
border-radius: var(--radius);
border: none;
background: var(--primary);
color: var(--tg-theme-button-text-color);
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.topup-button:hover {
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.topup-button:active {
transform: scale(0.98);
}
.topup-button:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-backdrop.hidden {
display: none;
}
.modal {
width: 100%;
max-width: 420px;
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
.modal-header {
text-align: center;
}
.modal-title {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 4px;
}
.modal-subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.payment-methods-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.payment-option-group {
margin-top: 16px;
}
.payment-option-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
}
.payment-option-list {
display: grid;
gap: 8px;
}
.payment-option-button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
background: var(--bg-primary);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.payment-option-button:hover {
border-color: rgba(var(--primary-rgb), 0.4);
box-shadow: var(--shadow-sm);
}
.payment-option-button.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
box-shadow: var(--shadow-sm);
}
.payment-option-icon {
font-size: 20px;
}
.payment-option-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
}
.payment-option-label {
font-size: 14px;
font-weight: 600;
}
.payment-option-description {
font-size: 13px;
color: var(--text-secondary);
}
.payment-method-card {
border: 2px solid var(--border-color);
border-radius: var(--radius);
padding: 14px 16px;
background: var(--bg-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.payment-method-card:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
border-color: var(--primary);
}
.payment-method-card:active {
transform: scale(0.99);
}
.payment-method-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.payment-method-icon {
font-size: 26px;
}
.payment-method-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.payment-method-label {
font-weight: 700;
color: var(--text-primary);
}
.payment-method-description {
font-size: 13px;
color: var(--text-secondary);
}
.amount-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.amount-input {
width: 100%;
padding: 12px 14px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.amount-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--shadow-sm);
}
.amount-hint {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.amount-hint.warning {
color: var(--warning);
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.modal-button {
flex: 1;
padding: 12px;
border-radius: var(--radius);
border: none;
font-weight: 700;
font-size: 15px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.modal-button.primary {
background: var(--primary);
color: var(--tg-theme-button-text-color);
}
.modal-button.secondary {
background: transparent;
color: var(--text-primary);
border: 2px solid var(--border-color);
}
.modal-button:hover:not(:disabled) {
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.modal-button:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.modal-error {
font-size: 13px;
color: var(--danger);
text-align: center;
}
.payment-summary {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.payment-status {
margin-top: 16px;
padding: 14px 16px;
border-radius: var(--radius);
border: 1px solid rgba(var(--primary-rgb), 0.2);
background: rgba(var(--primary-rgb), 0.06);
display: flex;
gap: 12px;
align-items: flex-start;
}
.payment-status.success {
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.1);
}
.payment-status.error {
border-color: rgba(var(--danger-rgb), 0.35);
background: rgba(var(--danger-rgb), 0.1);
}
.payment-status-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.payment-status-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(var(--primary-rgb), 0.2);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.payment-status-content {
flex: 1;
}
.payment-status-title {
font-size: 14px;
font-weight: 600;
}
.payment-status-description {
margin-top: 4px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.payment-summary-amount {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
}
.promo-code-card {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.promo-code-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.promo-code-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.promo-code-subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.promo-code-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.promo-code-input-group {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: rgba(var(--primary-rgb), 0.04);
border: 2px solid rgba(var(--primary-rgb), 0.12);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
flex-wrap: wrap;
}
.promo-code-input-group:focus-within {
border-color: rgba(var(--primary-rgb), 0.45);
box-shadow: 0 6px 20px rgba(var(--primary-rgb), 0.18);
}
.promo-code-input {
flex: 1 1 auto;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
min-width: 0;
}
.promo-code-input::placeholder {
color: var(--text-secondary);
opacity: 0.75;
}
.promo-code-input:focus {
outline: none;
}
.promo-code-button {
border: none;
border-radius: var(--radius);
background: var(--primary);
color: var(--tg-theme-button-text-color);
font-weight: 700;
font-size: 14px;
padding: 10px 18px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
max-width: 100%;
}
.promo-code-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(var(--primary-rgb), 0.35);
}
.promo-code-button:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.promo-code-feedback {
font-size: 14px;
border-radius: var(--radius);
padding: 12px 14px;
line-height: 1.5;
}
.promo-code-feedback.hidden {
display: none;
}
.promo-code-feedback.error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.promo-code-feedback.success {
background: rgba(16, 185, 129, 0.12);
color: #047857;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.promo-code-result {
display: flex;
flex-direction: column;
gap: 10px;
border-radius: var(--radius);
padding: 14px 16px;
background: rgba(var(--primary-rgb), 0.06);
border: 1px solid rgba(var(--primary-rgb), 0.1);
}
.promo-code-result.hidden {
display: none;
}
.promo-code-result-title {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.promo-code-result-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
}
.promo-code-result-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
color: var(--text-primary);
}
.promo-code-result-icon {
font-size: 18px;
}
/* Referral Section */
.referral-card-summary {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.referral-card-summary-item {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
padding: 8px 12px;
border-radius: var(--radius-sm);
background: rgba(var(--primary-rgb), 0.08);
}
.referral-card-summary-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.referral-card-summary-value {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.referral-content {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.referral-link-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
background: rgba(var(--primary-rgb), 0.04);
}
.referral-link-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.referral-link-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.referral-link-value {
font-size: 15px;
font-weight: 600;
color: var(--primary);
word-break: break-word;
}
.referral-copy-btn {
border: none;
border-radius: var(--radius-sm);
padding: 8px 12px;
background: var(--primary);
color: var(--tg-theme-button-text-color);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.referral-copy-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.referral-copy-btn:not(:disabled):active {
transform: scale(0.98);
}
.referral-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 12px;
}
.referral-stat-card {
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 12px;
background: rgba(var(--primary-rgb), 0.03);
}
.referral-stat-label {
font-size: 12px;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.04em;
margin-bottom: 6px;
display: block;
}
.referral-stat-value {
font-size: 18px;
font-weight: 700;
}
.referral-terms {
display: flex;
flex-direction: column;
gap: 8px;
}
.referral-section-title {
font-size: 13px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.referral-terms-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
}
.referral-term-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
background: rgba(var(--primary-rgb), 0.02);
}
.referral-term-label {
font-size: 13px;
color: var(--text-secondary);
}
.referral-term-value {
font-size: 14px;
font-weight: 600;
}
.referral-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 10px 14px;
background: transparent;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.referral-toggle-btn:hover {
background: rgba(var(--primary-rgb), 0.06);
}
.referral-toggle-btn:active {
transform: scale(0.98);
}
.referral-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 0;
}
.referral-item {
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
background: rgba(var(--primary-rgb), 0.02);
}
.referral-item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.referral-item-name {
font-size: 15px;
font-weight: 600;
}
.referral-item-username {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.referral-status {
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.referral-status.active {
background: rgba(16, 185, 129, 0.12);
color: #047857;
}
.referral-status.inactive {
background: rgba(148, 163, 184, 0.12);
color: #475569;
}
.referral-status.new {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.referral-item-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.referral-item-metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.referral-item-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.referral-item-value {
font-size: 14px;
font-weight: 600;
}
.referral-item-dates {
display: flex;
flex-direction: column;
gap: 4px;
}
.referral-item-date {
font-size: 12px;
color: var(--text-secondary);
}
.referral-item-date strong {
color: var(--text-primary);
font-weight: 600;
}
/* Transaction History */
.history-list {
list-style: none;
padding: 0;
margin: 0;
}
.history-item {
padding: 16px;
border-bottom: 1px solid var(--border-color);
transition: all 0.3s ease;
cursor: pointer;
}
.history-item:last-child {
border-bottom: none;
}
.history-item:hover {
background: rgba(var(--primary-rgb), 0.03);
padding-left: 20px;
}
.history-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.history-amount {
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
gap: 4px;
}
.history-amount.positive {
color: var(--success);
}
.history-amount.negative {
color: var(--danger);
}
.history-amount-icon {
width: 20px;
height: 20px;
}
.history-type {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
padding: 4px 10px;
background: var(--bg-primary);
border-radius: var(--radius-sm);
}
.history-meta {
font-size: 13px;
color: var(--text-secondary);
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.history-description {
font-size: 13px;
color: var(--text-secondary);
margin-top: 8px;
padding: 8px;
background: var(--bg-primary);
border-radius: var(--radius-sm);
line-height: 1.5;
}
/* Servers & Devices */
.server-list,
.device-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 12px;
}
.server-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: linear-gradient(135deg, var(--bg-primary), rgba(var(--primary-rgb), 0.02));
border-radius: var(--radius-lg);
border: 2px solid var(--border-color);
transition: all 0.3s ease;
cursor: pointer;
position: relative;
overflow: hidden;
}
.server-item::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(var(--primary-rgb), 0.05));
transform: translateX(100%);
transition: transform 0.5s ease;
}
.server-item:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
background: linear-gradient(135deg, var(--bg-primary), rgba(var(--primary-rgb), 0.05));
}
.server-item:hover::after {
transform: translateX(0);
}
.server-flag {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.15), rgba(var(--primary-rgb), 0.05));
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
flex-shrink: 0;
box-shadow: var(--shadow-sm);
}
.server-info {
flex: 1;
min-width: 0;
}
.server-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.server-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
animation: pulse 2s infinite;
}
.device-item {
padding: 18px;
background: linear-gradient(135deg, var(--bg-primary), rgba(var(--primary-rgb), 0.02));
border-radius: var(--radius-lg);
border: 2px solid var(--border-color);
transition: all 0.3s ease;
cursor: default;
position: relative;
overflow: hidden;
}
.device-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(180deg, var(--primary), rgba(var(--primary-rgb), 0.3));
opacity: 0;
transition: opacity 0.3s ease;
}
.device-item:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
background: linear-gradient(135deg, var(--bg-primary), rgba(var(--primary-rgb), 0.05));
}
.device-item:hover::before {
opacity: 1;
}
.device-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.device-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
flex: 1;
word-break: break-word;
}
.device-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.device-remove-button {
width: 36px;
height: 36px;
border-radius: var(--radius);
border: 2px solid rgba(var(--danger-rgb), 0.4);
background: rgba(var(--danger-rgb), 0.08);
color: var(--danger);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.device-remove-button:hover {
background: rgba(var(--danger-rgb), 0.16);
border-color: rgba(var(--danger-rgb), 0.6);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.device-remove-button:active {
transform: scale(0.95);
}
.device-remove-button:disabled,
.device-remove-button.is-removing {
opacity: 0.6;
cursor: default;
box-shadow: none;
transform: none;
}
.device-remove-button span {
line-height: 1;
transition: opacity 0.2s ease;
}
.device-remove-button.is-removing span {
opacity: 0;
}
.device-remove-button.is-removing::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
animation: spin 0.8s linear infinite;
}
.device-type-badge {
padding: 4px 10px;
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.device-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.device-meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.device-meta-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
.device-meta-value {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
/* Empty States */
.empty-state {
text-align: center;
padding: 32px 20px;
color: var(--text-secondary);
font-size: 14px;
}
.empty-state::before {
content: '📭';
display: block;
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
/* Buttons */
.btn {
width: 100%;
padding: 16px 24px;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
color: white;
box-shadow: var(--shadow-md);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-primary:active {
transform: scale(0.98);
}
.btn-secondary {
background: var(--bg-primary);
color: var(--primary);
border: 2px solid var(--primary);
}
.btn-secondary:hover:not(:disabled) {
background: rgba(var(--primary-rgb), 0.1);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.btn-icon {
width: 20px;
height: 20px;
stroke-width: 2.5;
}
.btn-group {
display: flex;
flex-direction: column;
gap: 12px;
margin: 20px 0;
}
/* App Installation */
.platform-selector {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 20px;
}
.platform-btn {
padding: 14px 8px;
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: var(--radius);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.platform-btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.platform-btn.active {
border-color: var(--primary);
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--primary-rgb), 0.05));
color: var(--primary);
}
.platform-icon {
font-size: 20px;
}
.app-card {
background: var(--bg-primary);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
border: 2px solid var(--border-color);
transition: all 0.3s ease;
}
.app-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.app-card.featured {
border-color: var(--primary);
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.05), rgba(var(--primary-rgb), 0.02));
}
.app-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.app-icon {
width: 48px;
height: 48px;
border-radius: var(--radius);
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
box-shadow: var(--shadow-sm);
}
.app-info {
flex: 1;
}
.app-name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.featured-badge {
display: inline-block;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
color: white;
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.app-steps {
margin-top: 16px;
}
.step {
margin-bottom: 16px;
padding-left: 32px;
position: relative;
}
.step-number {
position: absolute;
left: 0;
top: 2px;
width: 24px;
height: 24px;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.step-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.step-description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 10px;
}
.step-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.step-btn {
padding: 10px 16px;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
}
.step-btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
/* FAQ Section */
.faq-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.faq-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 0 16px;
transition: all 0.3s ease;
}
.faq-item[open] {
border-color: rgba(var(--primary-rgb), 0.35);
box-shadow: var(--shadow-sm);
}
.faq-question {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
font-weight: 700;
color: var(--text-primary);
padding: 16px 0;
font-size: 15px;
outline: none;
}
.faq-question::-webkit-details-marker {
display: none;
}
.faq-question-text {
flex: 1;
}
.faq-toggle-icon {
width: 20px;
height: 20px;
color: var(--text-secondary);
transition: transform 0.3s ease;
flex-shrink: 0;
}
.faq-item[open] .faq-toggle-icon {
transform: rotate(180deg);
}
.faq-answer {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.6;
padding-bottom: 16px;
border-top: 1px solid var(--border-color);
}
.faq-answer > *:first-child {
margin-top: 16px;
}
.faq-answer p {
margin: 0 0 12px;
}
.faq-answer ul,
.faq-answer ol {
margin: 8px 0 16px 20px;
}
.faq-answer li + li {
margin-top: 6px;
}
/* Legal Documents */
.legal-doc-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.legal-doc-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 0 16px;
transition: all 0.3s ease;
}
.legal-doc-item[open] {
border-color: rgba(var(--primary-rgb), 0.35);
box-shadow: var(--shadow-sm);
}
.legal-doc-summary {
list-style: none;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-weight: 700;
color: var(--text-primary);
padding: 16px 0;
font-size: 15px;
outline: none;
}
.legal-doc-summary::-webkit-details-marker {
display: none;
}
.legal-doc-icon {
font-size: 20px;
line-height: 1;
}
.legal-doc-title {
flex: 1;
}
.legal-doc-toggle {
width: 20px;
height: 20px;
color: var(--text-secondary);
transition: transform 0.3s ease;
flex-shrink: 0;
}
.legal-doc-item[open] .legal-doc-toggle {
transform: rotate(180deg);
}
.legal-doc-body {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.6;
padding-bottom: 16px;
border-top: 1px solid var(--border-color);
}
.legal-doc-body > *:first-child {
margin-top: 16px;
}
.legal-doc-updated {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 12px;
}
.legal-doc-content p {
margin: 0 0 12px;
}
.legal-doc-content ul,
.legal-doc-content ol {
margin: 8px 0 16px 20px;
}
.legal-doc-content li + li {
margin-top: 6px;
}
.legal-doc-content table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.legal-doc-content th,
.legal-doc-content td {
border: 1px solid var(--border-color);
padding: 8px;
text-align: left;
}
.legal-doc-content a {
color: var(--primary);
text-decoration: none;
}
.legal-doc-content a:hover {
text-decoration: underline;
}
.faq-answer a {
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
.faq-answer a:hover {
text-decoration: underline;
}
.faq-answer code {
background: rgba(var(--primary-rgb), 0.08);
padding: 2px 4px;
border-radius: 4px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
}
.faq-answer pre {
background: rgba(var(--primary-rgb), 0.08);
padding: 12px;
border-radius: var(--radius);
overflow-x: auto;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
margin: 16px 0;
}
:root[data-theme="dark"] .faq-item {
border-color: rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.75);
}
:root[data-theme="dark"] .faq-item[open] {
border-color: rgba(var(--primary-rgb), 0.45);
box-shadow: var(--shadow-md);
}
:root[data-theme="dark"] .faq-answer pre {
background: rgba(148, 163, 184, 0.12);
}
:root[data-theme="dark"] .faq-answer code {
background: rgba(148, 163, 184, 0.12);
}
.faq-question:focus-visible {
outline: 2px solid rgba(var(--primary-rgb), 0.35);
border-radius: var(--radius);
}
/* Hidden */
.hidden {
display: none !important;
}
/* Mobile Optimizations */
@media (max-width: 480px) {
.container {
padding: 12px;
}
.promo-offer-card {
padding: 16px;
}
.promo-offer-title {
font-size: 16px;
}
.promo-offer-badge {
font-size: 14px;
padding: 5px 10px;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.stat-value {
font-size: 24px;
}
.promo-code-input-group {
flex-direction: column;
align-items: stretch;
padding: 16px;
gap: 10px;
}
.promo-code-button {
width: 100%;
}
.platform-selector {
grid-template-columns: repeat(2, 1fr);
}
.referral-card-summary {
margin-left: 0;
justify-content: space-between;
}
.user-header {
padding: 16px;
}
.user-avatar {
width: 56px;
height: 56px;
font-size: 24px;
}
.user-name {
font-size: 18px;
}
.balance-amount {
font-size: 32px;
}
}
/* Dark Mode Adjustments */
@media (prefers-color-scheme: dark) {
.stat-item {
background: var(--bg-secondary);
}
.history-type {
background: var(--bg-secondary);
}
.history-description {
background: var(--bg-secondary);
}
.server-item,
.device-item {
background: var(--bg-secondary);
}
.app-card {
background: var(--bg-secondary);
}
.platform-btn {
background: var(--bg-secondary);
}
.theme-toggle .icon-sun {
display: none;
}
.theme-toggle .icon-moon {
display: block;
}
}
:root[data-theme="dark"] .stat-item,
:root[data-theme="dark"] .history-type,
:root[data-theme="dark"] .history-description,
:root[data-theme="dark"] .server-item,
:root[data-theme="dark"] .device-item,
:root[data-theme="dark"] .app-card,
:root[data-theme="dark"] .platform-btn {
background: var(--bg-secondary);
}
:root[data-theme="dark"] .theme-toggle .icon-sun {
display: none;
}
:root[data-theme="dark"] .theme-toggle .icon-moon {
display: block;
}
:root[data-theme="dark"] .device-remove-button {
background: rgba(var(--danger-rgb), 0.12);
border-color: rgba(var(--danger-rgb), 0.3);
color: #fca5a5;
}
:root[data-theme="dark"] .device-remove-button:hover {
background: rgba(var(--danger-rgb), 0.2);
border-color: rgba(var(--danger-rgb), 0.5);
color: #fecaca;
}
:root[data-theme="light"] .theme-toggle .icon-moon {
display: none;
}
:root[data-theme="light"] .theme-toggle .icon-sun {
display: block;
}
:root[data-theme="dark"] .history-meta,
:root[data-theme="dark"] .history-description,
:root[data-theme="dark"] .platform-btn,
:root[data-theme="dark"] .info-label,
:root[data-theme="dark"] .info-value,
:root[data-theme="dark"] .card-title,
:root[data-theme="dark"] .stat-label,
:root[data-theme="dark"] .subtitle,
:root[data-theme="dark"] .loading-text {
color: var(--text-secondary);
}
:root[data-theme="dark"] .promo-discount-badge {
background: rgba(var(--primary-rgb), 0.12);
border-color: rgba(var(--primary-rgb), 0.2);
}
:root[data-theme="dark"] .promo-discount-badge.muted {
background: rgba(148, 163, 184, 0.15);
border-color: rgba(148, 163, 184, 0.25);
color: rgba(226, 232, 240, 0.75);
}
:root[data-theme="dark"] .referral-card-summary-item {
background: rgba(var(--primary-rgb), 0.2);
}
:root[data-theme="dark"] .promo-discount-badge.muted .promo-discount-value {
color: rgba(226, 232, 240, 0.75);
}
:root[data-theme="dark"] .promo-period-badge {
background: rgba(var(--primary-rgb), 0.12);
border-color: rgba(var(--primary-rgb), 0.2);
}
:root[data-theme="dark"] .promo-period-badge .promo-period-value {
color: var(--primary);
}
:root[data-theme="dark"] body,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .user-card,
:root[data-theme="dark"] .balance-card,
:root[data-theme="dark"] .card.expandable,
:root[data-theme="dark"] .language-select,
:root[data-theme="dark"] .theme-toggle {
background: var(--bg-primary);
}
:root[data-theme="dark"] .language-select,
:root[data-theme="dark"] .theme-toggle,
:root[data-theme="dark"] .stat-item,
:root[data-theme="dark"] .server-item,
:root[data-theme="dark"] .device-item,
:root[data-theme="dark"] .app-card {
border-color: rgba(148, 163, 184, 0.25);
}
:root[data-theme="dark"] .modal {
background: rgba(15, 23, 42, 0.96);
}
:root[data-theme="dark"] .payment-method-card {
background: rgba(15, 23, 42, 0.85);
border-color: rgba(148, 163, 184, 0.28);
}
:root[data-theme="dark"] .amount-input {
background: rgba(15, 23, 42, 0.85);
border-color: rgba(148, 163, 184, 0.28);
}
:root[data-theme="dark"] .modal-button.secondary {
border-color: rgba(148, 163, 184, 0.35);
}
:root[data-theme="dark"] .promo-code-input-group {
background: rgba(15, 23, 42, 0.85);
border-color: rgba(148, 163, 184, 0.35);
box-shadow: none;
}
:root[data-theme="dark"] .promo-code-feedback.error {
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.18);
}
:root[data-theme="dark"] .promo-code-feedback.success {
color: #34d399;
border-color: rgba(16, 185, 129, 0.4);
background: rgba(16, 185, 129, 0.18);
}
:root[data-theme="dark"] .promo-code-result {
background: rgba(37, 99, 235, 0.12);
border-color: rgba(59, 130, 246, 0.25);
}
:root[data-theme="dark"] .btn-primary {
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.45);
}
/* Responsive Design */
@media (max-width: 360px) {
.logo {
font-size: 24px;
}
.balance-amount {
font-size: 28px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.promo-code-input-group {
flex-direction: column;
align-items: stretch;
}
.promo-code-button {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Top Bar -->
<div class="top-bar">
<div class="language-switcher">
<select id="languageSelect" class="language-select">
<option value="en">🇬🇧 English</option>
<option value="ru">🇷🇺 Русский</option>
</select>
</div>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme" aria-pressed="false">
<svg class="icon-sun" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 18a6 6 0 100-12 6 6 0 000 12zM12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="icon-moon" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
</button>
</div>
<!-- Header -->
<div class="header animate-in">
<div class="logo-container">
<div class="logo-icon"></div>
</div>
<div class="logo" data-i18n="app.name">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 class="loading-text" 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 class="error-actions">
<button
id="purchaseBtn"
class="btn btn-primary hidden"
type="button"
data-i18n="button.buy_subscription"
>Buy subscription</button>
</div>
</div>
<!-- Main Content -->
<div id="mainContent" class="hidden">
<!-- Promo Offers -->
<div id="promoOffersContainer" class="promo-offers hidden"></div>
<!-- User Card -->
<div class="card user-card animate-in">
<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-list">
<div class="info-item">
<span class="info-label" data-i18n="info.expires">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>Expires</span>
</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">Type</span>
<span class="info-value" id="subscriptionType">-</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>
</div>
<!-- Subscription Settings -->
<div class="card expandable subscription-settings-card hidden" id="subscriptionSettingsCard">
<div class="card-header">
<div class="card-title">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 110 4 2 2 0 010-4zm6 2a2 2 0 110 4 2 2 0 010-4zM6 8a2 2 0 110 4 2 2 0 010-4zm12 6a2 2 0 110 4 2 2 0 010-4zm-6 2a2 2 0 110 4 2 2 0 010-4zm-6-2a2 2 0 110 4 2 2 0 010-4zm6-4v8"/>
</svg>
<span data-i18n="subscription_settings.title">Настройка подписки</span>
</div>
<div class="subscription-settings-summary" id="subscriptionSettingsSummary"></div>
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="card-content">
<div class="subscription-settings-content" id="subscriptionSettingsContent">
<div class="subscription-settings-loading" id="subscriptionSettingsLoading">
<div class="subscription-settings-loading-line"></div>
<div class="subscription-settings-loading-line" style="width: 70%;"></div>
<div class="subscription-settings-loading-line" style="width: 55%;"></div>
</div>
<div class="subscription-settings-status-text hidden" id="subscriptionSettingsStatus"></div>
<div class="subscription-settings-section" id="subscriptionSettingsServersSection">
<div class="subscription-settings-section-header">
<div>
<div class="subscription-settings-section-title" data-i18n="subscription_settings.servers.title">Серверы</div>
<div class="subscription-settings-section-description" data-i18n="subscription_settings.servers.subtitle">Выберите нужные регионы подключения.</div>
</div>
<div class="subscription-settings-section-meta" id="subscriptionSettingsServersMeta"></div>
</div>
<div class="subscription-settings-list" id="subscriptionSettingsServersList"></div>
<div class="subscription-settings-empty hidden" id="subscriptionSettingsServersEmpty" data-i18n="subscription_settings.servers.empty">Нет доступных серверов</div>
<div class="subscription-settings-disabled-note hidden" id="subscriptionSettingsServersDisabled" data-i18n="subscription_settings.section.disabled">Эта опция временно недоступна.</div>
<div class="subscription-settings-actions">
<button class="subscription-settings-apply" id="subscriptionSettingsServersApply" type="button" disabled data-i18n="subscription_settings.servers.manage">Обновить серверы</button>
<div class="subscription-settings-inline-hint" id="subscriptionSettingsServersHint"></div>
</div>
</div>
<div class="subscription-settings-section" id="subscriptionSettingsTrafficSection">
<div class="subscription-settings-section-header">
<div>
<div class="subscription-settings-section-title" data-i18n="subscription_settings.traffic.title">Трафик</div>
<div class="subscription-settings-section-description" data-i18n="subscription_settings.traffic.subtitle">Выберите месячный лимит трафика.</div>
</div>
<div class="subscription-settings-section-meta" id="subscriptionSettingsTrafficMeta"></div>
</div>
<div class="subscription-settings-list" id="subscriptionSettingsTrafficList"></div>
<div class="subscription-settings-empty hidden" id="subscriptionSettingsTrafficEmpty" data-i18n="subscription_settings.traffic.empty">Нет вариантов</div>
<div class="subscription-settings-disabled-note hidden" id="subscriptionSettingsTrafficDisabled" data-i18n="subscription_settings.section.disabled">Эта опция временно недоступна.</div>
<div class="subscription-settings-actions">
<button class="subscription-settings-apply" id="subscriptionSettingsTrafficApply" type="button" disabled data-i18n="subscription_settings.traffic.apply">Обновить трафик</button>
<div class="subscription-settings-inline-hint" id="subscriptionSettingsTrafficHint"></div>
</div>
</div>
<div class="subscription-settings-section" id="subscriptionSettingsDevicesSection">
<div class="subscription-settings-section-header">
<div>
<div class="subscription-settings-section-title" data-i18n="subscription_settings.devices.title">Устройства</div>
<div class="subscription-settings-section-description" data-i18n="subscription_settings.devices.subtitle">Количество одновременно подключённых устройств.</div>
</div>
<div class="subscription-settings-section-meta" id="subscriptionSettingsDevicesMeta"></div>
</div>
<div class="subscription-settings-stepper">
<button type="button" id="subscriptionSettingsDevicesDecrease"></button>
<div class="subscription-settings-stepper-value" id="subscriptionSettingsDevicesValue">0</div>
<button type="button" id="subscriptionSettingsDevicesIncrease">+</button>
</div>
<div class="subscription-settings-price-note" id="subscriptionSettingsDevicesPrice"></div>
<div class="subscription-settings-disabled-note hidden" id="subscriptionSettingsDevicesDisabled" data-i18n="subscription_settings.section.disabled">Эта опция временно недоступна.</div>
<div class="subscription-settings-actions">
<button class="subscription-settings-apply" id="subscriptionSettingsDevicesApply" type="button" disabled data-i18n="subscription_settings.devices.apply">Обновить устройства</button>
<div class="subscription-settings-inline-hint" id="subscriptionSettingsDevicesHint"></div>
</div>
</div>
</div>
<div class="subscription-settings-error hidden" id="subscriptionSettingsError">
<div id="subscriptionSettingsErrorText" data-i18n="subscription_settings.status.error">Не удалось загрузить настройки подписки.</div>
<button class="subscription-settings-retry" id="subscriptionSettingsRetry" type="button" data-i18n="subscription_settings.action.retry">Повторить</button>
</div>
</div>
</div>
<!-- Promo Card -->
<div class="card expandable promo-card hidden" id="promoCard">
<div class="card-header">
<div class="card-title">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm0 0v13m-4-5l4 2 4-2"/>
</svg>
<span data-i18n="card.promo.title">Promo benefits</span>
</div>
<div class="promo-card-summary" id="promoCardSummary">
<span class="promo-card-summary-label" data-i18n="promo.summary.current_group">Current group</span>
<span class="promo-card-summary-value" id="promoCardGroupName"></span>
<span class="promo-card-summary-discount hidden" id="promoCardTopDiscount"></span>
</div>
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="card-content">
<div class="promo-section" id="promoCurrentGroupSection">
<div class="promo-section-header">
<div class="promo-section-title" data-i18n="promo.current_group.title">Current promo group</div>
<div class="promo-section-value" id="promoGroupValue"></div>
</div>
<div class="promo-discount-list" id="promoGroupDiscounts"></div>
<div class="promo-periods hidden" id="promoGroupPeriods">
<div class="promo-periods-title" data-i18n="promo.periods.title">Subscription period discounts</div>
<div class="promo-period-list" id="promoGroupPeriodList"></div>
</div>
</div>
<div class="promo-divider hidden" id="promoDivider"></div>
<div class="promo-section hidden" id="promoLevelsSection">
<div class="promo-section-header">
<div class="promo-section-title" data-i18n="promo.levels.title">Automatic promo levels</div>
<div class="promo-section-meta">
<span class="promo-section-meta-label" data-i18n="promo_levels.total_spent">Total spent</span>
<span class="promo-section-meta-value" id="promoLevelsSpent"></span>
</div>
</div>
<ul class="promo-level-list" id="promoLevelsList"></ul>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="btn-group">
<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">
<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>
</div>
<!-- Balance Card -->
<div class="card balance-card" id="balanceCard">
<div class="balance-content">
<div class="balance-amount" id="balanceAmount"></div>
<div class="balance-label" data-i18n="card.balance.title">Balance</div>
<div class="balance-actions">
<button class="topup-button" type="button" id="topupBalanceBtn">
<span aria-hidden="true"></span>
<span data-i18n="button.topup_balance">Top up balance</span>
</button>
</div>
</div>
</div>
<div class="modal-backdrop hidden" id="topupModal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="topupModalTitle">
<div class="modal-header">
<div class="modal-title" id="topupModalTitle" data-i18n="topup.title">Top up balance</div>
<div class="modal-subtitle" id="topupModalSubtitle" data-i18n="topup.subtitle">Choose a payment method</div>
</div>
<div id="topupModalBody"></div>
<div class="modal-error hidden" id="topupModalError"></div>
<div class="modal-actions" id="topupModalFooter">
<button class="modal-button secondary" type="button" id="topupModalCancelBtn" data-i18n="topup.cancel">Cancel</button>
</div>
</div>
</div>
<div class="card promo-code-card" id="promoCodeCard">
<div class="promo-code-header">
<div class="promo-code-title" data-i18n="promo_code.title">Activate promo code</div>
<div class="promo-code-subtitle" data-i18n="promo_code.subtitle">Enter a promo code to unlock rewards instantly.</div>
</div>
<form class="promo-code-form" id="promoCodeForm">
<div class="promo-code-input-group">
<input
type="text"
id="promoCodeInput"
class="promo-code-input"
data-i18n-placeholder="promo_code.placeholder"
placeholder="Enter promo code"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
maxlength="32"
>
<button type="submit" class="promo-code-button" id="promoCodeSubmit" data-i18n="promo_code.button.default">Activate</button>
</div>
</form>
<div class="promo-code-feedback hidden" id="promoCodeFeedback"></div>
<div class="promo-code-result hidden" id="promoCodeResult">
<div class="promo-code-result-title" data-i18n="promo_code.result.title">You received</div>
<ul class="promo-code-result-list" id="promoCodeResultList"></ul>
</div>
</div>
<!-- Referral Card (Expandable) -->
<div class="card expandable" id="referralCard">
<div class="card-header">
<div class="card-title">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14c-4.418 0-8 2.239-8 5v1h16v-1c0-2.761-3.582-5-8-5z" />
</svg>
<span data-i18n="card.referral.title">Referral Program</span>
</div>
<div class="referral-card-summary hidden" id="referralCardSummary">
<div class="referral-card-summary-item">
<span class="referral-card-summary-label" data-i18n="referral.stats.invited">Invited</span>
<span class="referral-card-summary-value" id="referralSummaryInvited">0</span>
</div>
<div class="referral-card-summary-item">
<span class="referral-card-summary-label" data-i18n="referral.stats.total">Total earned</span>
<span class="referral-card-summary-value" id="referralSummaryEarned"></span>
</div>
</div>
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="card-content">
<div class="referral-content hidden" id="referralContent">
<div class="referral-link-section">
<div class="referral-link-header">
<span class="referral-link-label" data-i18n="referral.link.label">Your referral link</span>
<button class="referral-copy-btn" type="button" id="referralCopyBtn" data-i18n="referral.link.copy">Copy link</button>
</div>
<div class="referral-link-value" id="referralLinkValue"></div>
</div>
<div class="referral-stats" id="referralStats"></div>
<div class="referral-terms">
<div class="referral-section-title" data-i18n="referral.terms.title">Program terms</div>
<ul class="referral-terms-list" id="referralTermsList"></ul>
</div>
<button class="referral-toggle-btn" type="button" id="referralToggleBtn" data-i18n="referral.toggle.open">My referrals</button>
<ul class="referral-list hidden" id="referralList"></ul>
<div class="empty-state hidden" id="referralListEmpty" data-i18n="referral.list.empty">You have no referrals yet</div>
</div>
<div class="empty-state hidden" id="referralEmpty" data-i18n="referral.empty">Referral information is unavailable</div>
</div>
</div>
<!-- History Card (Expandable) -->
<div class="card expandable" id="historyCard">
<div class="card-header">
<div class="card-title">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span data-i18n="card.history.title">Transaction History</span>
</div>
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="card-content">
<ul class="history-list" id="historyList"></ul>
<div class="empty-state hidden" id="historyEmpty" data-i18n="history.empty">No transactions yet</div>
</div>
</div>
<!-- Servers Card (Expandable) -->
<div class="card expandable" id="serversCard">
<div class="card-header">
<div class="card-title">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
</svg>
<span data-i18n="card.servers.title">Connected Servers</span>
</div>
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="card-content">
<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>
<!-- Devices Card (Expandable) -->
<div class="card expandable" id="devicesCard">
<div class="card-header">
<div class="card-title">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<span data-i18n="card.devices.title">Connected Devices</span>
</div>
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="card-content">
<ul class="device-list" id="devicesList"></ul>
<div class="empty-state hidden" id="devicesEmpty" data-i18n="devices.empty">No devices connected yet</div>
</div>
</div>
<!-- App Installation Section -->
<div class="card">
<div class="card-header">
<div class="card-title" data-i18n="apps.title">Installation Guide</div>
</div>
<div class="card-content" style="opacity: 1; max-height: 2000px; padding-bottom: 16px;">
<div class="platform-selector">
<button class="platform-btn" data-platform="ios">
<span class="platform-icon">🍎</span>
<span data-i18n="platform.ios">iOS</span>
</button>
<button class="platform-btn active" data-platform="android">
<span class="platform-icon">🤖</span>
<span data-i18n="platform.android">Android</span>
</button>
<button class="platform-btn" data-platform="pc">
<span class="platform-icon">💻</span>
<span data-i18n="platform.pc">PC</span>
</button>
<button class="platform-btn" data-platform="tv">
<span class="platform-icon">📺</span>
<span data-i18n="platform.tv">TV</span>
</button>
</div>
<div id="appsContainer">
<!-- Apps will be rendered here -->
</div>
</div>
</div>
<!-- FAQ Section -->
<div class="card hidden" id="faqCard">
<div class="card-header">
<div class="card-title" data-i18n="faq.title">FAQ</div>
</div>
<div class="card-content">
<div class="faq-list" id="faqList"></div>
</div>
</div>
<!-- Legal Documents Section -->
<div class="card hidden" id="legalDocsCard">
<div class="card-header">
<div class="card-title" data-i18n="legal.title">Legal documents</div>
</div>
<div class="card-content">
<div class="legal-doc-list" id="legalDocsList"></div>
</div>
</div>
</div>
</div>
<script>
// Copy all the original JavaScript code here
const tg = window.Telegram?.WebApp || {
initData: '',
initDataUnsafe: {},
expand: () => {},
ready: () => {},
themeParams: {},
showPopup: null,
};
let hasAnimatedCards = false;
let promoOfferTimers = [];
let promoOfferTimerHandle = null;
let referralListExpanded = false;
let referralCopyResetHandle = 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]);
});
}
// Add expandable card functionality
document.querySelectorAll('.card.expandable .card-header').forEach(header => {
header.addEventListener('click', () => {
const card = header.parentElement;
card.classList.toggle('expanded');
});
});
setupSubscriptionSettingsEvents();
const themeToggle = document.getElementById('themeToggle');
const THEME_STORAGE_KEY = 'remnawave-miniapp-theme';
let currentTheme = 'light';
let themeLockedByUser = false;
function resolveTheme(theme) {
if (!theme) {
return null;
}
const normalized = String(theme).toLowerCase();
if (normalized === 'dark') {
return 'dark';
}
if (normalized === 'light') {
return 'light';
}
return null;
}
function safeGetStoredTheme() {
try {
return localStorage.getItem(THEME_STORAGE_KEY);
} catch (error) {
console.warn('Unable to access theme preference:', error);
return null;
}
}
function safeSetStoredTheme(theme) {
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
console.warn('Unable to persist theme preference:', error);
}
}
function updateThemeToggleState() {
if (!themeToggle) {
return;
}
themeToggle.setAttribute('aria-pressed', currentTheme === 'dark' ? 'true' : 'false');
}
function applyTheme(theme, options = {}) {
const resolved = resolveTheme(theme) || 'light';
currentTheme = resolved;
if (options.persist) {
themeLockedByUser = true;
safeSetStoredTheme(resolved);
}
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.style.setProperty('color-scheme', resolved);
updateThemeToggleState();
}
function initializeTheme() {
const storedTheme = resolveTheme(safeGetStoredTheme());
if (storedTheme) {
themeLockedByUser = true;
applyTheme(storedTheme);
return;
}
const telegramTheme = resolveTheme(tg.colorScheme);
if (telegramTheme) {
applyTheme(telegramTheme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
applyTheme('dark');
} else {
applyTheme('light');
}
}
initializeTheme();
if (typeof tg.onEvent === 'function') {
tg.onEvent('themeChanged', () => {
if (!themeLockedByUser) {
applyTheme(tg.colorScheme);
}
});
}
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleMediaChange = event => {
if (!themeLockedByUser) {
applyTheme(event.matches ? 'dark' : 'light');
}
};
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', handleMediaChange);
} else if (typeof mediaQuery.addListener === 'function') {
mediaQuery.addListener(handleMediaChange);
}
}
themeToggle?.addEventListener('click', () => {
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme, { persist: true });
});
// All original constants and functions
const LANG_STORAGE_KEY = 'remnawave-miniapp-language';
const SUPPORTED_LANGUAGES = ['en', 'ru'];
const translations = {
en: {
'app.title': 'VPN Subscription',
'app.name': 'VPN',
'values.not_available': 'Not available',
'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',
'button.topup_balance': 'Top up balance',
'topup.title': 'Top up balance',
'topup.subtitle': 'Choose a payment method',
'topup.methods.subtitle': 'Select how you want to pay',
'topup.method.stars.title': 'Telegram Stars',
'topup.method.stars.description': 'Pay using Telegram Stars',
'topup.method.stars.invoice_hint': 'Telegram Stars payments use whole stars. Invoice: {stars} ⭐.',
'topup.method.stars.adjusted': 'Requested {requested}. Actual charge: {amount} ({stars} ⭐).',
'topup.method.yookassa.title': 'Bank card (YooKassa)',
'topup.method.yookassa.description': 'Pay securely with a bank card',
'topup.method.mulenpay.title': 'Bank card (Mulen Pay)',
'topup.method.mulenpay.description': 'Fast payment with bank card',
'topup.method.pal24.title': 'SBP (PayPalych)',
'topup.method.pal24.description': 'Pay via Faster Payments System',
'topup.method.pal24.option.sbp.title': 'Faster Payments (SBP)',
'topup.method.pal24.option.sbp.description': 'Instant transfer via SBP with no extra fees',
'topup.method.pal24.option.card.title': 'Bank card payment',
'topup.method.pal24.option.card.description': 'Pay with a bank card via PayPalych',
'topup.method.cryptobot.title': 'Cryptocurrency (CryptoBot)',
'topup.method.cryptobot.description': 'Pay with crypto assets',
'topup.method.tribute.title': 'Bank card (Tribute)',
'topup.method.tribute.description': 'Redirect to Tribute payment page',
'topup.amount.title': 'Enter amount',
'topup.amount.subtitle': 'Specify how much you want to top up',
'topup.amount.placeholder': 'Amount in {currency}',
'topup.amount.hint.range': 'Available range: {min} — {max}',
'topup.amount.hint.single_min': 'Minimum top-up: {min}',
'topup.amount.hint.single_max': 'Maximum top-up: {max}',
'topup.submit': 'Continue',
'topup.cancel': 'Close',
'topup.back': 'Back',
'topup.open_link': 'Open payment page',
'topup.loading': 'Preparing payment…',
'topup.error.generic': 'Unable to start the payment. Please try again later.',
'topup.error.amount': 'Enter a valid amount within the limits.',
'topup.error.unavailable': 'This payment method is temporarily unavailable.',
'topup.status.pending.title': 'Waiting for confirmation',
'topup.status.pending.description': 'Complete the payment in the selected provider. We will update this window automatically.',
'topup.status.refreshing.description': 'Updating payment status…',
'topup.status.success.title': 'Payment received',
'topup.status.success.description': 'Funds have been credited to your balance.',
'topup.status.failed.title': 'Payment not confirmed',
'topup.status.failed.description': 'We could not confirm the payment automatically. Please check later or contact support.',
'topup.status.retry': 'Try again',
'topup.done': 'Done',
'button.buy_subscription': 'Buy Subscription',
'card.balance.title': 'Balance',
'subscription_settings.title': 'Subscription settings',
'subscription_settings.summary.servers': 'Servers: {count}',
'subscription_settings.summary.servers_one': 'Server: {count}',
'subscription_settings.summary.traffic': 'Traffic: {amount}',
'subscription_settings.summary.devices': 'Devices: {count}',
'subscription_settings.summary.devices_one': 'Device: {count}',
'subscription_settings.summary.unlimited': 'Unlimited',
'subscription_settings.status.loading': 'Loading subscription settings…',
'subscription_settings.status.error': 'Unable to load subscription settings.',
'subscription_settings.action.retry': 'Try again',
'subscription_settings.section.disabled': 'This action is temporarily unavailable.',
'subscription_settings.servers.title': 'Servers',
'subscription_settings.servers.subtitle': 'Choose the regions you need.',
'subscription_settings.servers.manage': 'Update servers',
'subscription_settings.servers.empty': 'No servers available',
'subscription_settings.servers.limit': 'Selected: {count}',
'subscription_settings.servers.hint': 'Discounts are applied automatically.',
'subscription_settings.traffic.title': 'Traffic',
'subscription_settings.traffic.subtitle': 'Select your monthly traffic limit.',
'subscription_settings.traffic.apply': 'Update traffic',
'subscription_settings.traffic.empty': 'No traffic options available',
'subscription_settings.traffic.current': 'Current: {limit}',
'subscription_settings.devices.title': 'Devices',
'subscription_settings.devices.subtitle': 'Number of simultaneous connections.',
'subscription_settings.devices.apply': 'Update devices',
'subscription_settings.devices.value': '{count} devices',
'subscription_settings.devices.value_one': '{count} device',
'subscription_settings.devices.unlimited': 'Unlimited',
'subscription_settings.price.included': 'Included',
'subscription_settings.success.servers': 'Servers updated successfully.',
'subscription_settings.success.traffic': 'Traffic limit updated successfully.',
'subscription_settings.success.devices': 'Device limit updated successfully.',
'subscription_settings.error.generic': 'Unable to update subscription settings. Please try again later.',
'subscription_settings.error.unauthorized': 'Authorization failed. Please reopen the mini app from Telegram.',
'subscription_settings.error.validation': 'Please review your selection and try again.',
'subscription_settings.pending_action': 'Saving…',
'promo_code.title': 'Activate promo code',
'promo_code.subtitle': 'Enter a promo code to unlock rewards instantly.',
'promo_code.placeholder': 'Enter promo code',
'promo_code.button.default': 'Activate',
'promo_code.button.loading': 'Activating…',
'promo_code.success.title': 'Promo code activated',
'promo_code.success.default': 'Rewards credited to your account.',
'promo_code.result.title': 'You received',
'promo_code.result.balance': 'Balance bonus: {amount}',
'promo_code.result.subscription_days': 'Subscription extended by {days} days',
'promo_code.result.trial': 'Trial subscription for {days} days',
'promo_code.error.empty': 'Please enter a promo code.',
'promo_code.error.invalid': 'Enter a valid promo code.',
'promo_code.error.not_found': 'Promo code not found.',
'promo_code.error.expired': 'Promo code has expired.',
'promo_code.error.used': 'Promo code already used.',
'promo_code.error.already_used_by_user': 'You have already activated this promo code.',
'promo_code.error.user_not_found': 'User not found.',
'promo_code.error.server_error': 'Failed to activate the promo code. Please try again later.',
'promo_code.error.generic': 'Unable to activate the promo code.',
'promo_code.error.unauthorized': 'Authorization failed. Please reopen the mini app from Telegram.',
'promo_code.error.network': 'Network error. Please try again later.',
'card.referral.title': 'Referral Program',
'card.history.title': 'Transaction History',
'card.servers.title': 'Connected Servers',
'card.devices.title': 'Connected Devices',
'card.promo_levels.title': 'Promo Levels',
'card.promo.title': 'Promo benefits',
'promo.summary.current_group': 'Current group',
'promo.summary.no_group': 'No promo group',
'promo.summary.up_to': 'Up to {value}',
'promo.current_group.title': 'Current promo group',
'promo.levels.title': 'Automatic promo levels',
'promo.periods.title': 'Subscription period discounts',
'promo.periods.title_short': 'Periods',
'promo.periods.label': '{months} mo',
'promo.periods.month_suffix': 'mo',
'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',
'faq.title': 'FAQ',
'faq.item_default_title': 'Question {index}',
'faq.item_empty': 'Answer will be added soon.',
'legal.title': 'Legal documents',
'legal.public_offer.title': 'Public offer',
'legal.service_rules.title': 'Service rules',
'legal.privacy_policy.title': 'Privacy policy',
'legal.updated_at': 'Updated {date}',
'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',
'referral.link.label': 'Your referral link',
'referral.link.copy': 'Copy link',
'referral.link.copied': 'Link copied',
'referral.empty': 'Referral information is unavailable',
'referral.stats.invited': 'Invited',
'referral.stats.active': 'Active referrals',
'referral.stats.paid': 'Paying referrals',
'referral.stats.total': 'Total earned',
'referral.stats.month': 'Earned this month',
'referral.stats.conversion': 'Conversion',
'referral.terms.title': 'Program terms',
'referral.terms.minimum_topup': 'Invitee minimum top-up to earn bonus',
'referral.terms.first_topup': 'Invitee bonus for first top-up',
'referral.terms.inviter_bonus': 'Your bonus for their top-up',
'referral.terms.commission': 'Commission from each referral top-up',
'referral.toggle.open': 'My referrals',
'referral.toggle.close': 'Hide referrals',
'referral.list.empty': 'You have no referrals yet',
'referral.meta.earned': 'Earned',
'referral.meta.topups': 'Top-ups',
'referral.meta.joined': 'Joined',
'referral.meta.last_activity': 'Last activity',
'referral.copy.success': 'Referral link copied to clipboard.',
'referral.copy.failure': 'Unable to copy the referral link automatically. Please copy it manually: {value}',
'referral.copy.unavailable': 'Copying is unavailable. Please copy the link manually.',
'referral.status.active': 'Active',
'referral.status.inactive': 'Inactive',
'referral.status.paying': 'Paying',
'referral.referrals.unknown': 'Referral',
'servers.empty': 'No servers connected yet',
'devices.empty': 'No devices connected yet',
'devices.remove_button_label': 'Reset device',
'devices.remove_confirm.title': 'Reset device',
'devices.remove_confirm.message': 'Do you really want to reset the device “{device}”?',
'devices.remove_confirm.confirm': 'Reset',
'devices.remove_confirm.cancel': 'Cancel',
'devices.remove_success.title': 'Device reset',
'devices.remove_success': 'The device has been reset successfully.',
'devices.remove_error.title': 'Unable to reset device',
'devices.remove_error.generic': 'Failed to reset the device. Please try again later.',
'devices.remove_error.network': 'Network error. Please try again later.',
'devices.remove_error.unauthorized': 'Authorization failed. Please reopen the mini app from Telegram and try again.',
'promo_levels.total_spent': 'Total spent',
'promo_levels.threshold': 'from {amount}',
'promo_levels.badge.current': 'Current level',
'promo_levels.badge.unlocked': 'Unlocked',
'promo_levels.badge.locked': 'Locked',
'promo_levels.discounts.server': 'Servers',
'promo_levels.discounts.traffic': 'Traffic',
'promo_levels.discounts.devices': 'Devices',
'promo_levels.discounts.none': 'No discounts',
'promo_levels.empty': 'Automatic promo levels are not configured 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',
'promo_offer.status.active': 'Active promo offer',
'promo_offer.status.pending': 'Special offer',
'promo_offer.accept': 'Activate',
'promo_offer.accepting': 'Activating…',
'promo_offer.accept.success': 'Offer activated successfully!',
'promo_offer.timer.active': 'Ends in {time}',
'promo_offer.timer.pending': 'Expires in {time}',
'promo_offer.timer.expired': 'Expired',
'promo_offer.error.offer_not_found': 'Offer not found',
'promo_offer.error.already_claimed': 'Offer already activated',
'promo_offer.error.offer_expired': 'Offer has expired',
'promo_offer.error.subscription_missing': 'An active subscription is required.',
'promo_offer.error.squads_missing': 'No servers available for this test access.',
'promo_offer.error.already_connected': 'Servers are already connected.',
'promo_offer.error.remnawave_sync_failed': 'Failed to activate servers. Try again later.',
'promo_offer.error.claim_failed': 'Unable to activate the offer right now.',
'promo_offer.error.invalid_discount': 'Offer does not contain a discount.',
'promo_offer.error.generic': 'Failed to activate the offer. Please try again.',
'promo_offer.type.extend_discount': 'Renewal discount',
'promo_offer.type.purchase_discount': 'Welcome back discount',
'promo_offer.type.test_access': 'Test access',
'promo_offer.type.percent_discount': 'Discount offer',
'time.days_short': '{value}d',
'time.hours_short': '{value}h',
'time.minutes_short': '{value}m',
'time.less_than_minute': '<1m'
},
ru: {
'app.title': 'Подписка VPN',
'app.name': 'VPN',
'values.not_available': 'Закрыто',
'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': 'Скопировать ссылку подписки',
'button.topup_balance': 'Пополнить баланс',
'topup.title': 'Пополнение баланса',
'topup.subtitle': 'Выберите способ оплаты',
'topup.methods.subtitle': 'Выберите удобный способ оплаты',
'topup.method.stars.title': 'Telegram Stars',
'topup.method.stars.description': 'Оплата звёздами Telegram',
'topup.method.stars.invoice_hint': 'Оплата проходит целыми звёздами. Счёт: {stars} ⭐.',
'topup.method.stars.adjusted': 'Вы запросили {requested}. Итог к оплате: {amount} ({stars} ⭐).',
'topup.method.yookassa.title': 'Банковская карта (YooKassa)',
'topup.method.yookassa.description': 'Безопасная оплата банковской картой',
'topup.method.mulenpay.title': 'Банковская карта (Mulen Pay)',
'topup.method.mulenpay.description': 'Мгновенное списание с карты',
'topup.method.pal24.title': 'СБП (PayPalych)',
'topup.method.pal24.description': 'Оплата через Систему быстрых платежей',
'topup.method.pal24.option.sbp.title': 'СБП (рекомендуется)',
'topup.method.pal24.option.sbp.description': 'Мгновенный перевод без комиссии через СБП',
'topup.method.pal24.option.card.title': 'Банковская карта',
'topup.method.pal24.option.card.description': 'Оплата картой через PayPalych',
'topup.method.cryptobot.title': 'Криптовалюта (CryptoBot)',
'topup.method.cryptobot.description': 'Оплата в USDT, TON и других активах',
'topup.method.tribute.title': 'Банковская карта (Tribute)',
'topup.method.tribute.description': 'Переход на страницу оплаты Tribute',
'topup.amount.title': 'Введите сумму',
'topup.amount.subtitle': 'Укажите сумму пополнения',
'topup.amount.placeholder': 'Сумма в {currency}',
'topup.amount.hint.range': 'Доступный диапазон: {min} — {max}',
'topup.amount.hint.single_min': 'Минимальная сумма: {min}',
'topup.amount.hint.single_max': 'Максимальная сумма: {max}',
'topup.submit': 'Продолжить',
'topup.cancel': 'Закрыть',
'topup.back': 'Назад',
'topup.open_link': 'Перейти к оплате',
'topup.loading': 'Готовим платеж…',
'topup.error.generic': 'Не удалось создать платеж. Попробуйте ещё раз позже.',
'topup.error.amount': 'Введите корректную сумму в пределах лимитов.',
'topup.error.unavailable': 'Способ оплаты временно недоступен.',
'topup.status.pending.title': 'Ожидаем подтверждение',
'topup.status.pending.description': 'Завершите оплату у выбранного провайдера. Это окно обновится автоматически.',
'topup.status.refreshing.description': 'Обновляем статус платежа…',
'topup.status.success.title': 'Платеж зачислен',
'topup.status.success.description': 'Средства успешно поступили на ваш баланс.',
'topup.status.failed.title': 'Платеж не подтверждён',
'topup.status.failed.description': 'Не удалось подтвердить платеж автоматически. Проверьте позже или обратитесь в поддержку.',
'topup.status.retry': 'Повторить попытку',
'topup.done': 'Готово',
'button.buy_subscription': 'Купить подписку',
'card.balance.title': 'Баланс',
'subscription_settings.title': 'Настройка подписки',
'subscription_settings.summary.servers': 'Серверов: {count}',
'subscription_settings.summary.servers_one': 'Сервер: {count}',
'subscription_settings.summary.traffic': 'Трафик: {amount}',
'subscription_settings.summary.devices': 'Устройств: {count}',
'subscription_settings.summary.devices_one': 'Устройство: {count}',
'subscription_settings.summary.unlimited': 'Безлимит',
'subscription_settings.status.loading': 'Загружаем настройки подписки…',
'subscription_settings.status.error': 'Не удалось загрузить настройки подписки.',
'subscription_settings.action.retry': 'Попробовать снова',
'subscription_settings.section.disabled': 'Действие временно недоступно.',
'subscription_settings.servers.title': 'Серверы',
'subscription_settings.servers.subtitle': 'Выберите нужные регионы подключения.',
'subscription_settings.servers.manage': 'Обновить серверы',
'subscription_settings.servers.empty': 'Нет доступных серверов',
'subscription_settings.servers.limit': 'Выбрано: {count}',
'subscription_settings.servers.hint': 'Скидки применяются автоматически.',
'subscription_settings.traffic.title': 'Трафик',
'subscription_settings.traffic.subtitle': 'Выберите месячный лимит трафика.',
'subscription_settings.traffic.apply': 'Обновить трафик',
'subscription_settings.traffic.empty': 'Нет доступных вариантов трафика',
'subscription_settings.traffic.current': 'Текущий: {limit}',
'subscription_settings.devices.title': 'Устройства',
'subscription_settings.devices.subtitle': 'Количество одновременных подключений.',
'subscription_settings.devices.apply': 'Обновить устройства',
'subscription_settings.devices.value': '{count} устройств',
'subscription_settings.devices.value_one': '{count} устройство',
'subscription_settings.devices.unlimited': 'Безлимит',
'subscription_settings.price.included': 'Включено',
'subscription_settings.success.servers': 'Серверы успешно обновлены.',
'subscription_settings.success.traffic': 'Лимит трафика обновлён.',
'subscription_settings.success.devices': 'Лимит устройств обновлён.',
'subscription_settings.error.generic': 'Не удалось обновить настройки подписки. Попробуйте позже.',
'subscription_settings.error.unauthorized': 'Не удалось пройти авторизацию. Откройте мини-приложение заново из Telegram.',
'subscription_settings.error.validation': 'Проверьте выбранные параметры и попробуйте ещё раз.',
'subscription_settings.pending_action': 'Сохраняем…',
'promo_code.title': 'Активировать промокод',
'promo_code.subtitle': 'Введите промокод и сразу получите бонусы.',
'promo_code.placeholder': 'Введите промокод',
'promo_code.button.default': 'Активировать',
'promo_code.button.loading': 'Активация…',
'promo_code.success.title': 'Промокод активирован',
'promo_code.success.default': 'Бонусы зачислены на ваш аккаунт.',
'promo_code.result.title': 'Вы получили',
'promo_code.result.balance': 'Пополнение баланса: {amount}',
'promo_code.result.subscription_days': 'Подписка продлена на {days} дн.',
'promo_code.result.trial': 'Триал подписка на {days} дн.',
'promo_code.error.empty': 'Введите промокод.',
'promo_code.error.invalid': 'Введите корректный промокод.',
'promo_code.error.not_found': 'Промокод не найден.',
'promo_code.error.expired': 'Срок действия промокода истёк.',
'promo_code.error.used': 'Промокод уже использован.',
'promo_code.error.already_used_by_user': 'Вы уже активировали этот промокод.',
'promo_code.error.user_not_found': 'Пользователь не найден.',
'promo_code.error.server_error': 'Не удалось активировать промокод. Попробуйте позже.',
'promo_code.error.generic': 'Не удалось активировать промокод.',
'promo_code.error.unauthorized': 'Ошибка авторизации. Откройте мини-приложение из Telegram.',
'promo_code.error.network': 'Ошибка сети. Попробуйте ещё раз.',
'card.referral.title': 'Реферальная программа',
'card.history.title': 'История операций',
'card.servers.title': 'Подключённые серверы',
'card.devices.title': 'Подключенные устройства',
'card.promo_levels.title': 'Уровни скидок',
'card.promo.title': 'Скидки за траты',
'promo.summary.current_group': 'Текущий уровень',
'promo.summary.no_group': 'Нет скидок',
'promo.summary.up_to': 'До {value}',
'promo.current_group.title': 'Текущий уровень',
'promo.levels.title': 'Уровни скидок за траты',
'promo.periods.title': 'Скидки на периоды подписки',
'promo.periods.title_short': 'Периоды',
'promo.periods.label': '{months} мес.',
'promo.periods.month_suffix': 'мес.',
'apps.title': 'Инструкция по установке',
'apps.no_data': 'Для этой платформы инструкция пока недоступна.',
'apps.featured': 'Рекомендуем',
'apps.step.download': 'Скачать и установить',
'apps.step.add': 'Добавить подписку',
'apps.step.connect': 'Подключиться и пользоваться',
'faq.title': 'FAQ',
'faq.item_default_title': 'Вопрос {index}',
'faq.item_empty': 'Ответ будет добавлен позже.',
'legal.title': 'Правовые документы',
'legal.public_offer.title': 'Публичная оферта',
'legal.service_rules.title': 'Правила сервиса',
'legal.privacy_policy.title': 'Политика конфиденциальности',
'legal.updated_at': 'Обновлено {date}',
'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': 'Реферальное вознаграждение',
'referral.link.label': 'Ваша реферальная ссылка',
'referral.link.copy': 'Скопировать ссылку',
'referral.link.copied': 'Ссылка скопирована',
'referral.empty': 'Данные по партнёрской программе недоступны',
'referral.stats.invited': 'Приглашено',
'referral.stats.active': 'Активных',
'referral.stats.paid': 'Платящих',
'referral.stats.total': 'Всего заработано',
'referral.stats.month': 'Заработано за месяц',
'referral.stats.conversion': 'Конверсия',
'referral.terms.title': 'Условия программы',
'referral.terms.minimum_topup': 'Пополнение реферала для активации бонуса',
'referral.terms.first_topup': 'Бонус приглашенному за первое пополнение',
'referral.terms.inviter_bonus': 'Ваш бонус за его пополнение',
'referral.terms.commission': 'Комиссия с каждого пополнения реферала',
'referral.toggle.open': 'Мои рефералы',
'referral.toggle.close': 'Скрыть список',
'referral.list.empty': 'У вас пока нет рефералов',
'referral.meta.earned': 'Заработано',
'referral.meta.topups': 'Пополнения',
'referral.meta.joined': 'Регистрация',
'referral.meta.last_activity': 'Последняя активность',
'referral.copy.success': 'Реферальная ссылка скопирована в буфер.',
'referral.copy.failure': 'Не удалось скопировать ссылку автоматически. Скопируйте вручную: {value}',
'referral.copy.unavailable': 'Копирование недоступно. Скопируйте ссылку вручную.',
'referral.status.active': 'Активен',
'referral.status.inactive': 'Неактивен',
'referral.status.paying': 'Оплачивает',
'referral.referrals.unknown': 'Реферал',
'servers.empty': 'Подключённых серверов пока нет',
'devices.empty': 'Подключённых устройств пока нет',
'devices.remove_button_label': 'Сбросить устройство',
'devices.remove_confirm.title': 'Сброс устройства',
'devices.remove_confirm.message': 'Сбросить устройство «{device}»?',
'devices.remove_confirm.confirm': 'Сбросить',
'devices.remove_confirm.cancel': 'Отмена',
'devices.remove_success.title': 'Устройство сброшено',
'devices.remove_success': 'Устройство успешно сброшено.',
'devices.remove_error.title': 'Не удалось сбросить устройство',
'devices.remove_error.generic': 'Не удалось сбросить устройство. Попробуйте позже.',
'devices.remove_error.network': 'Ошибка сети. Попробуйте позже.',
'devices.remove_error.unauthorized': 'Ошибка авторизации. Откройте мини-приложение из Telegram и повторите попытку.',
'promo_levels.total_spent': 'Всего потрачено',
'promo_levels.threshold': 'от {amount}',
'promo_levels.badge.current': 'Активно',
'promo_levels.badge.unlocked': 'Получен',
'promo_levels.badge.locked': 'Закрыто',
'promo_levels.discounts.server': 'Серверы',
'promo_levels.discounts.traffic': 'Трафик',
'promo_levels.discounts.devices': 'Устройства',
'promo_levels.discounts.none': 'Скидок нет',
'promo_levels.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': 'Недоступно',
'promo_offer.status.active': 'Активное предложение',
'promo_offer.status.pending': 'Специальное предложение',
'promo_offer.accept': 'Активировать',
'promo_offer.accepting': 'Активируем…',
'promo_offer.accept.success': 'Предложение успешно активировано!',
'promo_offer.timer.active': 'Действует ещё {time}',
'promo_offer.timer.pending': 'Истекает через {time}',
'promo_offer.timer.expired': 'Истекло',
'promo_offer.error.offer_not_found': 'Предложение не найдено',
'promo_offer.error.already_claimed': 'Предложение уже активировано',
'promo_offer.error.offer_expired': 'Срок действия предложения истёк',
'promo_offer.error.subscription_missing': 'Нужна активная подписка.',
'promo_offer.error.squads_missing': 'Для тестового доступа не настроены серверы.',
'promo_offer.error.already_connected': 'Серверы уже подключены.',
'promo_offer.error.remnawave_sync_failed': 'Не удалось подключить серверы. Попробуйте позже.',
'promo_offer.error.claim_failed': 'Не удалось активировать предложение.',
'promo_offer.error.invalid_discount': 'В предложении нет скидки.',
'promo_offer.error.generic': 'Не удалось активировать предложение. Попробуйте позже.',
'promo_offer.type.extend_discount': 'Скидка на продление',
'promo_offer.type.purchase_discount': 'Скидка на возвращение',
'promo_offer.type.test_access': 'Тестовый доступ',
'promo_offer.type.percent_discount': 'Скидка',
'time.days_short': '{value}д',
'time.hours_short': '{value}ч',
'time.minutes_short': '{value}м',
'time.less_than_minute': '<1м'
}
};
const pcTranslations = {
en: {
'pc.happ.title': 'Open Subscription',
'pc.happ.message': 'The Happ application doesn\'t appear to be installed on your PC. How would you like to proceed?',
'pc.happ.copy': 'Copy subscription link',
'pc.happ.redirect': 'Open in browser',
},
ru: {
'pc.happ.title': 'Открыть подписку',
'pc.happ.message': 'Приложение Happ не установлено на вашем ПК. Как вы хотите продолжить?',
'pc.happ.copy': 'Скопировать ссылку подписки',
'pc.happ.redirect': 'Открыть в браузере',
}
};
Object.keys(pcTranslations).forEach(lang => {
Object.assign(translations[lang], pcTranslations[lang]);
});
const LEGAL_DOCUMENT_CONFIG = {
public_offer: {
icon: '📜',
titleKey: 'legal.public_offer.title',
fallback: 'Public offer',
},
service_rules: {
icon: '📘',
titleKey: 'legal.service_rules.title',
fallback: 'Service rules',
},
privacy_policy: {
icon: '🔐',
titleKey: 'legal.privacy_policy.title',
fallback: 'Privacy policy',
},
};
// Include all the original JavaScript functions
function applyBrandingOverrides(branding) {
if (!branding || typeof branding !== 'object') {
return;
}
const {
service_name: rawServiceName = {},
service_description: rawServiceDescription = {}
} = branding;
function normalizeMap(map) {
const normalized = {};
Object.entries(map || {}).forEach(([lang, value]) => {
if (typeof value !== 'string') {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
normalized[lang.toLowerCase()] = trimmed;
});
return normalized;
}
function applyKey(key, map) {
const normalized = normalizeMap(map);
if (!Object.keys(normalized).length) {
return;
}
const defaultValue = normalized.default
|| normalized.en
|| normalized.ru
|| null;
const languages = new Set(
Object.keys(translations).map(lang => lang.toLowerCase())
);
Object.keys(normalized).forEach(lang => {
if (lang !== 'default') {
languages.add(lang);
}
});
languages.forEach(lang => {
const value = Object.prototype.hasOwnProperty.call(normalized, lang)
? normalized[lang]
: defaultValue;
if (!value) {
return;
}
const targetLang = lang.toLowerCase();
if (!translations[targetLang]) {
translations[targetLang] = {};
}
translations[targetLang][key] = value;
});
}
applyKey('app.name', rawServiceName);
applyKey('app.title', rawServiceName);
applyKey('app.subtitle', rawServiceDescription);
}
let userData = null;
let appsConfig = {};
let currentPlatform = 'android';
let configPurchaseUrl = null;
let subscriptionPurchaseUrl = null;
let preferredLanguage = 'en';
let languageLockedByUser = false;
let currentErrorState = null;
let paymentMethodsCache = null;
let paymentMethodsPromise = null;
let activePaymentMethod = null;
const paymentMethodSelections = {};
const activePaymentMonitors = new Map();
let paymentStatusPollTimer = null;
let isPaymentStatusPolling = false;
let subscriptionSettingsData = null;
let subscriptionSettingsPromise = null;
let subscriptionSettingsError = null;
let subscriptionSettingsLoading = false;
let subscriptionSettingsAction = null;
const subscriptionSettingsSelections = {
servers: new Set(),
traffic: null,
devices: null,
};
const PAYMENT_STATUS_INITIAL_DELAY_MS = 2000;
const PAYMENT_STATUS_POLL_INTERVAL_MS = 5000;
const PAYMENT_STATUS_TIMEOUT_MS = 180000;
function cleanupPaymentPollersIfIdle() {
if (paymentStatusPollTimer) {
clearTimeout(paymentStatusPollTimer);
paymentStatusPollTimer = null;
}
isPaymentStatusPolling = false;
}
function stopAllPaymentMonitors() {
activePaymentMonitors.clear();
cleanupPaymentPollersIfIdle();
}
function createPaymentStatusView() {
const container = document.createElement('div');
container.className = 'payment-status';
const icon = document.createElement('div');
icon.className = 'payment-status-icon';
const content = document.createElement('div');
content.className = 'payment-status-content';
const title = document.createElement('div');
title.className = 'payment-status-title';
const description = document.createElement('div');
description.className = 'payment-status-description';
content.appendChild(title);
content.appendChild(description);
container.appendChild(icon);
container.appendChild(content);
return { element: container, icon, title, description };
}
function updatePaymentStatusView(view, options = {}) {
if (!view) {
return;
}
const { state = 'pending', message = null, extraLines = [] } = options;
const normalizedState = state === 'refreshing' ? 'pending' : state;
const fallbackTitles = {
pending: 'Waiting for confirmation',
success: 'Payment received',
failed: 'Payment not confirmed',
};
const fallbackDescriptions = {
pending: 'Complete the payment in the selected provider. We will update this window automatically.',
refreshing: 'Updating payment status…',
success: 'Funds have been credited to your balance.',
failed: 'We could not confirm the payment automatically. Please check later or contact support.',
};
view.element.classList.remove('success', 'error');
view.icon.innerHTML = '';
if (state === 'success') {
view.element.classList.add('success');
view.icon.textContent = '✅';
} else if (state === 'failed') {
view.element.classList.add('error');
view.icon.textContent = '⚠️';
} else {
const spinner = document.createElement('div');
spinner.className = 'payment-status-spinner';
view.icon.appendChild(spinner);
}
const titleKey = `topup.status.${normalizedState}.title`;
const translatedTitle = t(titleKey);
view.title.textContent = translatedTitle && translatedTitle !== titleKey
? translatedTitle
: (fallbackTitles[normalizedState] || 'Payment status');
let descriptionKey = `topup.status.${state}.description`;
if (state === 'refreshing') {
descriptionKey = 'topup.status.refreshing.description';
}
const translatedDescription = t(descriptionKey);
const defaultDescription = translatedDescription && translatedDescription !== descriptionKey
? translatedDescription
: (fallbackDescriptions[state] || fallbackDescriptions[normalizedState] || '');
const lines = [];
if (message) {
lines.push(message);
}
if (Array.isArray(extraLines)) {
extraLines.filter(Boolean).forEach(line => lines.push(line));
}
if (!message && defaultDescription) {
lines.push(defaultDescription);
} else if (message && defaultDescription && defaultDescription !== message) {
lines.push(defaultDescription);
}
view.description.textContent = lines.filter(Boolean).join(' ');
}
function applyPaymentSubtitle(monitor, state) {
if (!monitor || !monitor.method) {
return;
}
if (state === 'success') {
setTopupModalSubtitle('topup.status.success.title', 'Payment received');
return;
}
if (state === 'failed') {
setTopupModalSubtitle('topup.status.failed.title', 'Payment not confirmed');
return;
}
if (monitor.method.id === 'pal24') {
const option = (monitor.option || 'sbp').toLowerCase();
const optionKey = option === 'card' ? 'card' : 'sbp';
const fallback = optionKey === 'card'
? 'Bank card payment'
: 'Faster Payments (SBP)';
setTopupModalSubtitle(`topup.method.pal24.option.${optionKey}.title`, fallback);
return;
}
setTopupModalSubtitle(`topup.method.${monitor.method.id}.title`, monitor.method.id);
}
function buildPaymentStatusQuery(methodId, amountKopeks, extra = {}) {
if (!methodId) {
return null;
}
const query = { method: methodId };
const identifiers = {};
if (extra.local_payment_id !== undefined && extra.local_payment_id !== null) {
query.localPaymentId = extra.local_payment_id;
identifiers.localPaymentId = extra.local_payment_id;
}
const invoiceIdentifier = extra.invoice_id || extra.bill_id || extra.order_id || extra.invoiceId;
if (invoiceIdentifier) {
query.invoiceId = invoiceIdentifier;
identifiers.invoiceId = invoiceIdentifier;
}
if (extra.payment_id) {
query.paymentId = extra.payment_id;
identifiers.paymentId = extra.payment_id;
}
const payloadValue = extra.payload || extra.invoice_payload;
if (payloadValue) {
query.payload = payloadValue;
identifiers.payload = payloadValue;
}
if (Number.isFinite(amountKopeks)) {
query.amountKopeks = amountKopeks;
identifiers.amountKopeks = amountKopeks;
}
const startedAtRaw = extra.started_at || extra.requested_at || extra.startedAt;
const startedAt = startedAtRaw ? String(startedAtRaw) : new Date().toISOString();
query.startedAt = startedAt;
identifiers.startedAt = startedAt;
if (extra.external_id) {
identifiers.externalId = extra.external_id;
}
return { query, identifiers, startedAt };
}
function applyPaymentFooterState(monitor, state) {
if (!monitor || !monitor.method) {
return;
}
const method = monitor.method;
const fallbackRawInput = monitor.rawInput ?? (
Number.isFinite(monitor.amountKopeks)
? (monitor.amountKopeks / 100).toString()
: ''
);
const goBack = () => {
stopAllPaymentMonitors();
if (method.requires_amount) {
renderTopupAmountForm(method, {
rawInput: fallbackRawInput,
selectedOption: monitor.option || paymentMethodSelections[method.id],
});
} else {
renderTopupMethodsView();
}
};
const buttons = [];
if (state === 'pending') {
buttons.push({
id: 'topupBackButton',
labelKey: 'topup.back',
fallbackLabel: 'Back',
variant: 'secondary',
onClick: goBack,
});
buttons.push({
id: 'topupOpenLinkButton',
labelKey: 'topup.open_link',
fallbackLabel: 'Open payment page',
variant: 'primary',
onClick: () => openExternalLink(monitor.paymentUrl || monitor.originalUrl),
});
} else if (state === 'success') {
buttons.push({
id: 'topupBackButton',
labelKey: 'topup.back',
fallbackLabel: 'Back',
variant: 'secondary',
onClick: () => {
stopAllPaymentMonitors();
renderTopupMethodsView();
},
});
buttons.push({
id: 'topupCloseButton',
labelKey: 'topup.cancel',
fallbackLabel: 'Close',
variant: 'primary',
onClick: () => {
stopAllPaymentMonitors();
closeTopupModal();
},
});
} else if (state === 'failed') {
buttons.push({
id: 'topupBackButton',
labelKey: 'topup.back',
fallbackLabel: 'Back',
variant: 'secondary',
onClick: goBack,
});
buttons.push({
id: 'topupRetryButton',
labelKey: 'topup.status.retry',
fallbackLabel: 'Try again',
variant: 'primary',
onClick: () => {
stopAllPaymentMonitors();
startPaymentForMethod(method, monitor.amountKopeks, {
rawInput: monitor.rawInput ?? fallbackRawInput,
providerOption: monitor.option || monitor.extra?.selected_option,
});
},
});
}
setTopupFooter(buttons);
monitor.backButton = document.getElementById('topupBackButton');
monitor.openButton = document.getElementById('topupOpenLinkButton');
monitor.retryButton = document.getElementById('topupRetryButton');
monitor.closeButton = document.getElementById('topupCloseButton');
if (state === 'success' && monitor.openButton) {
monitor.openButton.disabled = true;
}
}
function startPaymentStatusMonitor(context) {
if (!context || !context.method) {
return null;
}
const { query, identifiers } = buildPaymentStatusQuery(
context.method.id,
Number.isFinite(context.amountKopeks) ? context.amountKopeks : null,
context.extra || {},
) || {};
const statusView = context.statusView || createPaymentStatusView();
updatePaymentStatusView(statusView, { state: 'pending' });
const monitorId = `monitor_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const monitor = {
id: monitorId,
method: context.method,
methodId: context.method.id,
statusView,
amountKopeks: Number.isFinite(context.amountKopeks) ? context.amountKopeks : null,
option: (context.option || context.extra?.selected_option || null) || null,
paymentUrl: context.paymentUrl || context.originalUrl || null,
originalUrl: context.originalUrl || context.paymentUrl || null,
rawInput: context.rawInput || null,
extra: context.extra || {},
query: query || { method: context.method.id },
identifiers: identifiers || {},
state: 'pending',
createdAt: Date.now(),
timeoutMs: PAYMENT_STATUS_TIMEOUT_MS,
refreshed: false,
pollingDisabled: !(tg.initData || '').length,
};
activePaymentMonitors.set(monitorId, monitor);
applyPaymentSubtitle(monitor, 'pending');
applyPaymentFooterState(monitor, 'pending');
if (!monitor.pollingDisabled) {
schedulePaymentStatusPoll(true);
}
return monitor;
}
function schedulePaymentStatusPoll(immediate = false) {
const hasPollableMonitor = Array.from(activePaymentMonitors.values()).some(
monitor => monitor && !monitor.pollingDisabled && monitor.state !== 'success' && monitor.state !== 'failed'
);
if (!hasPollableMonitor) {
cleanupPaymentPollersIfIdle();
return;
}
if (paymentStatusPollTimer) {
return;
}
const delay = immediate ? PAYMENT_STATUS_INITIAL_DELAY_MS : PAYMENT_STATUS_POLL_INTERVAL_MS;
paymentStatusPollTimer = setTimeout(() => {
paymentStatusPollTimer = null;
pollPaymentStatuses();
}, Math.max(0, delay));
}
async function pollPaymentStatuses() {
if (isPaymentStatusPolling) {
return;
}
const initData = tg.initData || '';
if (!initData) {
cleanupPaymentPollersIfIdle();
return;
}
const now = Date.now();
const pendingMonitors = [];
activePaymentMonitors.forEach(monitor => {
if (!monitor || monitor.pollingDisabled || monitor.state === 'success' || monitor.state === 'failed') {
return;
}
if (monitor.createdAt + monitor.timeoutMs < now) {
finalizePaymentMonitor(monitor, { state: 'failed' });
return;
}
pendingMonitors.push(monitor);
});
if (!pendingMonitors.length) {
cleanupPaymentPollersIfIdle();
return;
}
isPaymentStatusPolling = true;
pendingMonitors.forEach(monitor => {
monitor.state = 'refreshing';
updatePaymentStatusView(monitor.statusView, { state: 'refreshing' });
});
try {
const payload = {
initData,
payments: pendingMonitors.map(monitor => ({ ...monitor.query })),
};
const response = await fetch('/miniapp/payments/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !Array.isArray(data?.results)) {
throw new Error('Failed to fetch payment statuses');
}
handlePaymentStatusResults(data.results);
} catch (error) {
console.warn('Payment status polling failed:', error);
pendingMonitors.forEach(monitor => {
if (monitor.state === 'refreshing') {
monitor.state = 'pending';
updatePaymentStatusView(monitor.statusView, { state: 'pending' });
}
});
} finally {
isPaymentStatusPolling = false;
schedulePaymentStatusPoll(false);
}
}
function normalizeIdentifier(value) {
if (value === undefined || value === null) {
return null;
}
return String(value);
}
function findMonitorForResult(result) {
if (!result || !result.method) {
return null;
}
const methodId = String(result.method).toLowerCase();
const monitors = Array.from(activePaymentMonitors.values()).filter(
monitor => monitor && monitor.methodId === methodId
);
if (!monitors.length) {
return null;
}
const extra = result.extra || {};
const localId = normalizeIdentifier(extra.local_payment_id);
const invoiceId = normalizeIdentifier(extra.invoice_id || extra.bill_id);
const paymentId = normalizeIdentifier(extra.payment_id);
const payload = normalizeIdentifier(extra.payload);
const externalId = normalizeIdentifier(result.external_id);
const amount = Number.isFinite(result.amount_kopeks) ? Number(result.amount_kopeks) : null;
let fallback = null;
for (const monitor of monitors) {
const ids = monitor.identifiers || {};
if (localId && normalizeIdentifier(ids.localPaymentId) === localId) {
return monitor;
}
if (invoiceId && normalizeIdentifier(ids.invoiceId) === invoiceId) {
return monitor;
}
if (paymentId && normalizeIdentifier(ids.paymentId) === paymentId) {
return monitor;
}
if (payload && normalizeIdentifier(ids.payload) === payload) {
return monitor;
}
if (externalId && normalizeIdentifier(ids.externalId) === externalId) {
return monitor;
}
if (amount !== null && monitor.amountKopeks === amount) {
fallback = monitor;
}
}
return fallback || monitors[0] || null;
}
function handlePaymentStatusResults(results) {
if (!Array.isArray(results)) {
return;
}
results.forEach(result => {
const monitor = findMonitorForResult(result);
if (!monitor) {
return;
}
const extra = result.extra || {};
if (extra.local_payment_id && !monitor.query.localPaymentId) {
monitor.query.localPaymentId = extra.local_payment_id;
monitor.identifiers.localPaymentId = extra.local_payment_id;
}
if ((extra.invoice_id || extra.bill_id) && !monitor.query.invoiceId) {
const invoiceId = extra.invoice_id || extra.bill_id;
monitor.query.invoiceId = invoiceId;
monitor.identifiers.invoiceId = invoiceId;
}
if (extra.payment_id && !monitor.query.paymentId) {
monitor.query.paymentId = extra.payment_id;
monitor.identifiers.paymentId = extra.payment_id;
}
if (extra.payload && !monitor.query.payload) {
monitor.query.payload = extra.payload;
monitor.identifiers.payload = extra.payload;
}
if (extra.external_id) {
monitor.identifiers.externalId = extra.external_id;
}
const status = String(result.status || '').toLowerCase();
const message = typeof result.message === 'string' ? result.message : null;
if (status === 'paid' || result.is_paid) {
finalizePaymentMonitor(monitor, { state: 'success', result, message });
return;
}
if (status === 'failed') {
const failureMessage = message || (extra.remote_status ? `Status: ${extra.remote_status}` : null);
finalizePaymentMonitor(monitor, { state: 'failed', result, message: failureMessage });
return;
}
monitor.state = 'pending';
updatePaymentStatusView(monitor.statusView, { state: 'pending', message });
});
}
function finalizePaymentMonitor(monitor, options = {}) {
if (!monitor) {
return;
}
const { state, message } = options;
if (state === 'success') {
monitor.state = 'success';
updatePaymentStatusView(monitor.statusView, { state: 'success', message });
applyPaymentSubtitle(monitor, 'success');
applyPaymentFooterState(monitor, 'success');
if (!monitor.refreshed) {
monitor.refreshed = true;
refreshSubscriptionData({ silent: true }).catch(error => {
console.warn('Failed to refresh subscription data:', error);
});
}
} else if (state === 'failed') {
monitor.state = 'failed';
updatePaymentStatusView(monitor.statusView, { state: 'failed', message });
applyPaymentSubtitle(monitor, 'failed');
applyPaymentFooterState(monitor, 'failed');
}
activePaymentMonitors.delete(monitor.id);
if (!activePaymentMonitors.size) {
cleanupPaymentPollersIfIdle();
}
}
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 normalizeUrl(value) {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function getEffectivePurchaseUrl() {
const candidates = [
currentErrorState?.purchaseUrl,
subscriptionPurchaseUrl,
configPurchaseUrl,
];
for (const candidate of candidates) {
const normalized = normalizeUrl(candidate);
if (normalized) {
return normalized;
}
}
return null;
}
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;
const purchaseButton = document.getElementById('purchaseBtn');
if (purchaseButton) {
const link = getEffectivePurchaseUrl();
purchaseButton.classList.toggle('hidden', !link);
purchaseButton.disabled = !link;
}
}
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);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
if (!key) {
return;
}
element.setAttribute('placeholder', 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(getHappCryptoLink() || 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;
}
function animateCardsOnce() {
if (hasAnimatedCards) {
return;
}
const cards = document.querySelectorAll('.card');
if (!cards.length) {
hasAnimatedCards = true;
return;
}
cards.forEach((card, index) => {
card.classList.remove('animate-in');
void card.offsetWidth;
setTimeout(() => {
card.classList.add('animate-in');
}, index * 100);
});
hasAnimatedCards = true;
}
async function fetchSubscriptionPayload(initData) {
const response = await fetch('/miniapp/subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData })
});
if (response.ok) {
return response.json();
}
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';
let purchaseUrl = null;
try {
const errorPayload = await response.json();
if (errorPayload?.detail) {
if (typeof errorPayload.detail === 'string') {
detail = errorPayload.detail;
} else if (typeof errorPayload.detail === 'object') {
if (typeof errorPayload.detail.message === 'string') {
detail = errorPayload.detail.message;
}
purchaseUrl = errorPayload.detail.purchase_url
|| errorPayload.detail.purchaseUrl
|| purchaseUrl;
}
} else if (typeof errorPayload?.message === 'string') {
detail = errorPayload.message;
}
if (typeof errorPayload?.title === 'string') {
title = errorPayload.title;
}
purchaseUrl = purchaseUrl
|| errorPayload?.purchase_url
|| errorPayload?.purchaseUrl
|| null;
} catch (parseError) {
// ignore JSON parsing errors
}
const errorObject = createError(title, detail, response.status);
const normalizedPurchaseUrl = normalizeUrl(purchaseUrl);
if (normalizedPurchaseUrl) {
errorObject.purchaseUrl = normalizedPurchaseUrl;
}
throw errorObject;
}
function applySubscriptionData(payload) {
if (!payload || typeof payload !== 'object') {
return null;
}
userData = payload;
userData.subscriptionUrl = userData.subscription_url || null;
userData.subscriptionCryptoLink = userData.subscription_crypto_link || null;
userData.referral = userData.referral || null;
const normalizedPurchaseUrl = normalizeUrl(
userData.subscription_purchase_url
|| userData.subscriptionPurchaseUrl
);
subscriptionPurchaseUrl = normalizedPurchaseUrl;
userData.subscriptionPurchaseUrl = normalizedPurchaseUrl || null;
if (userData.branding) {
applyBrandingOverrides(userData.branding);
}
const responseLanguage = resolveLanguage(userData?.user?.language);
if (responseLanguage && !languageLockedByUser) {
preferredLanguage = responseLanguage;
}
currentErrorState = null;
updateErrorTexts();
const errorState = document.getElementById('errorState');
if (errorState) {
errorState.classList.add('hidden');
}
const loadingState = document.getElementById('loadingState');
if (loadingState) {
loadingState.classList.add('hidden');
}
const mainContent = document.getElementById('mainContent');
if (mainContent) {
mainContent.classList.remove('hidden');
}
detectPlatform();
setActivePlatformButton();
refreshAfterLanguageChange();
animateCardsOnce();
return userData;
}
async function refreshSubscriptionData(options = {}) {
const { silent = false } = options;
const initData = tg.initData || '';
if (!initData) {
throw createError('Authorization Error', 'Missing Telegram ID');
}
if (!silent) {
document.getElementById('errorState')?.classList.add('hidden');
document.getElementById('mainContent')?.classList.add('hidden');
document.getElementById('loadingState')?.classList.remove('hidden');
}
const payload = await fetchSubscriptionPayload(initData);
return applySubscriptionData(payload);
}
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();
await refreshSubscriptionData();
} 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 || {};
const configData = data?.config || {};
const configUrl = normalizeUrl(
configData.subscriptionPurchaseUrl
|| configData.subscription_purchase_url
|| configData.purchaseUrl
|| configData.purchase_url
|| configData.miniappPurchaseUrl
|| configData.miniapp_purchase_url
|| data?.subscriptionPurchaseUrl
|| data?.subscription_purchase_url
);
if (configUrl) {
configPurchaseUrl = configUrl;
}
} 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 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;
}
renderSubscriptionSettingsCard();
renderPromoOffers();
renderPromoSection();
renderBalanceSection();
renderReferralSection();
renderTransactionHistory();
renderServersList();
renderDevicesList();
renderFaqSection();
renderLegalDocuments();
updateConnectButtonLabel();
updateActionButtons();
}
function resolvePromoOfferIcon(offer) {
if (offer?.icon && typeof offer.icon === 'string') {
return offer.icon;
}
const offerType = String(offer?.offer_type || '').toLowerCase();
const effectType = String(offer?.effect_type || '').toLowerCase();
if (offerType === 'extend_discount') {
return '💎';
}
if (offerType === 'purchase_discount') {
return '🎯';
}
if (offerType === 'test_access' || effectType === 'test_access') {
return '🧪';
}
if (effectType === 'percent_discount') {
return '🎁';
}
return '🎉';
}
function resolvePromoOfferTitle(offer) {
if (offer?.title) {
return offer.title;
}
if (offer?.template_name) {
return offer.template_name;
}
const offerType = String(offer?.offer_type || '').toLowerCase();
if (offerType) {
const key = `promo_offer.type.${offerType}`;
const value = t(key);
if (value !== key) {
return value;
}
}
const effectType = String(offer?.effect_type || '').toLowerCase();
if (effectType) {
const key = `promo_offer.type.${effectType}`;
const value = t(key);
if (value !== key) {
return value;
}
}
const fallbackKey = offer?.status === 'active'
? 'promo_offer.status.active'
: 'promo_offer.status.pending';
const fallback = t(fallbackKey);
if (fallback !== fallbackKey) {
return fallback;
}
return offerType || effectType || (offer?.status === 'active' ? 'Active offer' : 'Offer');
}
function parseDate(value) {
if (!value) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
let candidate = value;
if (typeof candidate === 'number') {
const direct = new Date(candidate);
if (!Number.isNaN(direct.getTime())) {
return direct;
}
const fromSeconds = new Date(candidate * 1000);
return Number.isNaN(fromSeconds.getTime()) ? null : fromSeconds;
}
if (typeof candidate !== 'string') {
return null;
}
let normalized = candidate.trim();
if (!normalized) {
return null;
}
if (/^\d+$/.test(normalized)) {
const timestamp = Number(normalized);
if (Number.isFinite(timestamp)) {
const millis = normalized.length === 10 ? timestamp * 1000 : timestamp;
const asDate = new Date(millis);
if (!Number.isNaN(asDate.getTime())) {
return asDate;
}
}
}
normalized = normalized.replace(' ', 'T');
normalized = normalized.replace('+00:00+00:00', '+00:00');
const hasTimezone = /([zZ]|[+-]\d{2}:?\d{2})$/.test(normalized);
if (!hasTimezone && /T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(normalized)) {
normalized = `${normalized}Z`;
}
const parsed = new Date(normalized);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
return null;
}
function sanitizePromoOfferHtml(input) {
if (!input || typeof input !== 'string') {
return '';
}
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${input}</div>`, 'text/html');
const allowedTags = new Set(['B', 'STRONG', 'I', 'EM', 'U', 'BR', 'SPAN', 'CODE', 'A']);
const elements = [];
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, null);
while (walker.nextNode()) {
elements.push(walker.currentNode);
}
elements.forEach(node => {
const tagName = node.tagName;
if (!allowedTags.has(tagName)) {
node.replaceWith(document.createTextNode(node.textContent || ''));
return;
}
if (tagName === 'A') {
const href = node.getAttribute('href') || '';
if (!/^https?:\/\//i.test(href)) {
node.replaceWith(document.createTextNode(node.textContent || ''));
return;
}
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
} else {
Array.from(node.attributes).forEach(attr => node.removeAttribute(attr.name));
}
});
return doc.body.innerHTML.replace(/\n/g, '<br>');
}
function sanitizeFaqHtml(input) {
if (!input || typeof input !== 'string') {
return '';
}
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${input}</div>`, 'text/html');
const allowedTags = new Set([
'P',
'BR',
'UL',
'OL',
'LI',
'STRONG',
'B',
'EM',
'I',
'U',
'A',
'CODE',
'PRE',
'SPAN',
'DIV',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'BLOCKQUOTE',
]);
const elements = [];
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, null);
while (walker.nextNode()) {
elements.push(walker.currentNode);
}
elements.forEach(node => {
const tagName = node.tagName;
if (!allowedTags.has(tagName)) {
const replacementText = node.textContent || '';
node.replaceWith(doc.createTextNode(replacementText));
return;
}
if (tagName === 'A') {
const href = node.getAttribute('href') || '';
if (!/^https?:\/\//i.test(href)) {
node.replaceWith(doc.createTextNode(node.textContent || ''));
return;
}
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
Array.from(node.attributes).forEach(attr => {
if (!['href', 'target', 'rel'].includes(attr.name)) {
node.removeAttribute(attr.name);
}
});
return;
}
Array.from(node.attributes).forEach(attr => node.removeAttribute(attr.name));
if (tagName === 'PRE') {
const codeChild = node.querySelector('code');
if (codeChild) {
Array.from(codeChild.attributes).forEach(attr => codeChild.removeAttribute(attr.name));
}
}
});
return doc.body.innerHTML.trim();
}
function sanitizeLegalDocumentHtml(input) {
if (!input || typeof input !== 'string') {
return '';
}
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${input}</div>`, 'text/html');
const allowedTags = new Set([
'P',
'BR',
'UL',
'OL',
'LI',
'STRONG',
'B',
'EM',
'I',
'U',
'A',
'CODE',
'PRE',
'SPAN',
'DIV',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'BLOCKQUOTE',
'TABLE',
'THEAD',
'TBODY',
'TFOOT',
'TR',
'TD',
'TH',
'COLGROUP',
'COL',
'HR',
'DL',
'DT',
'DD',
'SUP',
'SUB',
]);
const elements = [];
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, null);
while (walker.nextNode()) {
elements.push(walker.currentNode);
}
elements.forEach(node => {
const tagName = node.tagName;
if (!allowedTags.has(tagName)) {
const replacementText = node.textContent || '';
node.replaceWith(doc.createTextNode(replacementText));
return;
}
if (tagName === 'A') {
const href = (node.getAttribute('href') || '').trim();
const isHttpLink = /^https?:\/\//i.test(href);
const isMailto = href.toLowerCase().startsWith('mailto:');
if (!isHttpLink && !isMailto) {
node.replaceWith(doc.createTextNode(node.textContent || ''));
return;
}
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
Array.from(node.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
if (!['href', 'target', 'rel'].includes(name)) {
node.removeAttribute(attr.name);
}
});
return;
}
if (tagName === 'TD' || tagName === 'TH') {
Array.from(node.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
if (!['colspan', 'rowspan', 'scope'].includes(name)) {
node.removeAttribute(attr.name);
}
});
return;
}
Array.from(node.attributes).forEach(attr => node.removeAttribute(attr.name));
});
return doc.body.innerHTML.trim();
}
function formatShortDuration(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return t('time.less_than_minute');
}
if (seconds < 60) {
return t('time.less_than_minute');
}
const totalMinutes = Math.round(seconds / 60);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
const parts = [];
if (days) {
parts.push(t('time.days_short').replace('{value}', days));
}
if (hours) {
parts.push(t('time.hours_short').replace('{value}', hours));
}
if (minutes || !parts.length) {
parts.push(t('time.minutes_short').replace('{value}', minutes || 1));
}
return parts.join(' ');
}
function registerPromoOfferTimers(timers) {
promoOfferTimers = timers.filter(timer => timer && timer.expiresAt instanceof Date && !Number.isNaN(timer.expiresAt.getTime()));
if (promoOfferTimerHandle) {
clearInterval(promoOfferTimerHandle);
promoOfferTimerHandle = null;
}
if (!promoOfferTimers.length) {
return;
}
const tick = () => {
const now = new Date();
promoOfferTimers = promoOfferTimers.filter(timer => {
const diffSeconds = Math.floor((timer.expiresAt.getTime() - now.getTime()) / 1000);
if (!Number.isFinite(diffSeconds) || diffSeconds <= 0) {
timer.element.textContent = t('promo_offer.timer.expired');
if (timer.progressElement) {
timer.progressElement.style.width = '0%';
}
if (typeof timer.onExpire === 'function') {
try {
timer.onExpire();
} catch (error) {
console.warn('Failed to handle promo offer timer expire:', error);
}
}
return false;
}
const formatted = formatShortDuration(diffSeconds);
const templateKey = timer.labelKey || 'promo_offer.timer.pending';
const template = t(templateKey);
timer.element.textContent = template.includes('{time}')
? template.replace('{time}', formatted)
: `${template} ${formatted}`;
if (timer.progressElement && Number.isFinite(timer.totalSeconds) && timer.totalSeconds > 0) {
const ratio = Math.max(0, Math.min(1, diffSeconds / timer.totalSeconds));
timer.progressElement.style.width = `${ratio * 100}%`;
}
return true;
});
if (!promoOfferTimers.length && promoOfferTimerHandle) {
clearInterval(promoOfferTimerHandle);
promoOfferTimerHandle = null;
}
};
tick();
promoOfferTimerHandle = window.setInterval(tick, 1000);
}
function buildPromoOfferCard(offer, options = {}) {
if (!offer) {
return null;
}
const isActive = Boolean(options.isActive || String(offer.status || '').toLowerCase() === 'active');
const timers = options.timers || [];
const card = document.createElement('div');
card.className = `promo-offer-card ${isActive ? 'active' : 'pending'}`;
const header = document.createElement('div');
header.className = 'promo-offer-header';
const icon = document.createElement('div');
icon.className = 'promo-offer-icon';
icon.textContent = resolvePromoOfferIcon(offer);
header.appendChild(icon);
const heading = document.createElement('div');
heading.className = 'promo-offer-heading';
const title = document.createElement('div');
title.className = 'promo-offer-title';
title.textContent = resolvePromoOfferTitle(offer);
heading.appendChild(title);
const subtitle = document.createElement('div');
subtitle.className = 'promo-offer-subtitle';
const statusKey = isActive ? 'promo_offer.status.active' : 'promo_offer.status.pending';
const statusLabel = t(statusKey);
subtitle.textContent = statusLabel === statusKey ? (isActive ? 'Active offer' : 'Special offer') : statusLabel;
heading.appendChild(subtitle);
header.appendChild(heading);
const badgeText = (() => {
const discountValue = Number(offer.discount_percent || 0);
if (Number.isFinite(discountValue) && discountValue > 0) {
return `-${Math.round(discountValue)}%`;
}
if (offer.bonus_amount_label) {
return offer.bonus_amount_label;
}
if (String(offer.effect_type || '').toLowerCase() === 'test_access') {
const label = t('promo_offer.type.test_access');
return label === 'promo_offer.type.test_access' ? 'Test access' : label;
}
return null;
})();
if (badgeText) {
const badge = document.createElement('div');
badge.className = 'promo-offer-badge';
badge.textContent = badgeText;
header.appendChild(badge);
}
card.appendChild(header);
if (offer.message_text) {
const message = document.createElement('div');
message.className = 'promo-offer-message';
message.innerHTML = sanitizePromoOfferHtml(offer.message_text);
card.appendChild(message);
}
const details = document.createElement('div');
details.className = 'promo-offer-details';
if (offer.bonus_amount_label && offer.bonus_amount_label !== badgeText) {
const chip = document.createElement('span');
chip.className = 'promo-offer-chip';
chip.textContent = offer.bonus_amount_label;
details.appendChild(chip);
}
if (Array.isArray(offer.test_squads)) {
offer.test_squads.slice(0, 3).forEach(server => {
if (!server) {
return;
}
const chip = document.createElement('span');
chip.className = 'promo-offer-chip';
chip.textContent = server.name || server.uuid || '';
details.appendChild(chip);
});
}
if (details.children.length) {
card.appendChild(details);
}
const expiresAt = parseDate(isActive ? offer.active_discount_expires_at : offer.expires_at);
const startedAt = isActive ? parseDate(offer.active_discount_started_at) : null;
let totalSeconds = Number(offer.active_discount_duration_seconds);
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
totalSeconds = null;
}
if ((totalSeconds === null || totalSeconds <= 0) && expiresAt && startedAt) {
totalSeconds = Math.max(0, Math.floor((expiresAt.getTime() - startedAt.getTime()) / 1000));
}
let progressElement = null;
if (isActive && expiresAt) {
const progress = document.createElement('div');
progress.className = 'promo-offer-progress';
const bar = document.createElement('div');
bar.className = 'promo-offer-progress-bar';
progress.appendChild(bar);
progressElement = bar;
card.appendChild(progress);
}
const footer = document.createElement('div');
footer.className = 'promo-offer-footer';
const timerLabel = document.createElement('div');
timerLabel.className = 'promo-offer-timer';
footer.appendChild(timerLabel);
let button = null;
if (!isActive && offer.id) {
const action = document.createElement('div');
action.className = 'promo-offer-action';
button = document.createElement('button');
button.className = 'promo-offer-btn';
button.type = 'button';
button.textContent = t('promo_offer.accept');
button.addEventListener('click', () => handlePromoOfferAccept(offer.id, button));
action.appendChild(button);
footer.appendChild(action);
}
if (expiresAt) {
const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
if (diffSeconds > 0) {
const templateKey = isActive ? 'promo_offer.timer.active' : 'promo_offer.timer.pending';
const template = t(templateKey);
const durationText = formatShortDuration(diffSeconds);
timerLabel.textContent = template.includes('{time}')
? template.replace('{time}', durationText)
: `${template} ${durationText}`;
} else {
timerLabel.textContent = t('promo_offer.timer.expired');
}
timers.push({
element: timerLabel,
expiresAt,
labelKey: isActive ? 'promo_offer.timer.active' : 'promo_offer.timer.pending',
progressElement,
totalSeconds,
onExpire: () => {
if (button) {
button.disabled = true;
button.textContent = t('promo_offer.timer.expired');
}
card.classList.add('expired');
},
});
} else {
timerLabel.textContent = '';
}
if (footer.children.length) {
card.appendChild(footer);
}
return card;
}
function renderPromoOffers() {
const container = document.getElementById('promoOffersContainer');
if (!container) {
return;
}
const offers = Array.isArray(userData?.promo_offers) ? userData.promo_offers : [];
container.innerHTML = '';
const timers = [];
let hasContent = false;
offers.forEach(offer => {
const isActive = String(offer?.status || '').toLowerCase() === 'active';
const card = buildPromoOfferCard(offer, { isActive, timers });
if (card) {
container.appendChild(card);
hasContent = true;
}
});
container.classList.toggle('hidden', !hasContent);
registerPromoOfferTimers(hasContent ? timers : []);
}
async function handlePromoOfferAccept(offerId, button) {
if (!offerId) {
return;
}
const initData = tg.initData || '';
if (!initData) {
return;
}
const originalText = button?.textContent;
if (button) {
button.disabled = true;
button.textContent = t('promo_offer.accepting');
}
try {
const response = await fetch(`/miniapp/promo-offers/${offerId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const code = payload?.detail?.code || payload?.code;
const messageKey = code ? `promo_offer.error.${code}` : 'promo_offer.error.generic';
const translated = t(messageKey);
const message = translated === messageKey
? (payload?.detail?.message || t('promo_offer.error.generic'))
: translated;
showPopup(message);
if (button) {
button.disabled = false;
button.textContent = originalText || t('promo_offer.accept');
}
return;
}
const successKey = payload?.code === 'test_access_claimed'
? 'promo_offer.accept.success'
: 'promo_offer.accept.success';
const successMessage = t(successKey);
showPopup(successMessage === successKey ? 'Offer activated successfully!' : successMessage);
await refreshSubscriptionData({ silent: true });
} catch (error) {
console.error('Failed to activate promo offer:', error);
const message = t('promo_offer.error.generic');
showPopup(message === 'promo_offer.error.generic' ? 'Failed to activate the offer. Please try again.' : message);
if (button) {
button.disabled = false;
button.textContent = originalText || t('promo_offer.accept');
}
return;
}
}
function setPromoCodeFeedback(type, message) {
const feedback = document.getElementById('promoCodeFeedback');
if (!feedback) {
return;
}
feedback.classList.remove('error', 'success');
if (!message) {
feedback.textContent = '';
feedback.classList.add('hidden');
return;
}
if (type === 'error') {
feedback.classList.add('error');
} else if (type === 'success') {
feedback.classList.add('success');
}
feedback.textContent = message;
feedback.classList.remove('hidden');
}
function clearPromoCodeResult() {
const container = document.getElementById('promoCodeResult');
const list = document.getElementById('promoCodeResultList');
if (container) {
container.classList.add('hidden');
}
if (list) {
list.innerHTML = '';
}
}
function renderPromoCodeResult(rewards = [], fallbackEntries = []) {
const container = document.getElementById('promoCodeResult');
const list = document.getElementById('promoCodeResultList');
if (!container || !list) {
return;
}
list.innerHTML = '';
const entries = [];
if (Array.isArray(rewards)) {
rewards.filter(item => item && item.text).forEach(item => {
entries.push({
icon: item.icon || '🎉',
text: item.text,
});
});
}
if (Array.isArray(fallbackEntries)) {
fallbackEntries.filter(item => item && item.text).forEach(item => {
if (!entries.some(entry => entry.text === item.text)) {
entries.push({
icon: item.icon || '🎉',
text: item.text,
});
}
});
}
if (!entries.length) {
container.classList.add('hidden');
return;
}
entries.forEach(entry => {
const listItem = document.createElement('li');
listItem.className = 'promo-code-result-item';
const icon = document.createElement('span');
icon.className = 'promo-code-result-icon';
icon.textContent = entry.icon || '🎉';
const text = document.createElement('span');
text.textContent = entry.text;
listItem.appendChild(icon);
listItem.appendChild(text);
list.appendChild(listItem);
});
container.classList.remove('hidden');
}
async function handlePromoCodeSubmit(event) {
event.preventDefault();
const input = document.getElementById('promoCodeInput');
const button = document.getElementById('promoCodeSubmit');
clearPromoCodeResult();
setPromoCodeFeedback(null, null);
const rawCode = (input?.value || '').trim();
if (!rawCode) {
setPromoCodeFeedback('error', t('promo_code.error.empty'));
input?.focus();
return;
}
const normalizedCode = rawCode.toUpperCase();
if (input) {
input.value = normalizedCode;
}
const initData = tg.initData || '';
if (!initData) {
setPromoCodeFeedback('error', t('promo_code.error.unauthorized'));
return;
}
if (button) {
button.disabled = true;
button.textContent = t('promo_code.button.loading');
}
try {
const response = await fetch('/miniapp/promo-codes/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData, code: normalizedCode }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const code = payload?.detail?.code || payload?.code;
const messageKey = code ? `promo_code.error.${code}` : 'promo_code.error.generic';
const translated = t(messageKey);
const message = translated === messageKey
? (payload?.detail?.message || t('promo_code.error.generic'))
: translated;
setPromoCodeFeedback('error', message);
return;
}
const promocode = payload?.promocode || {};
const rewards = [];
const fallback = [];
const balanceKopeks = Number.parseInt(promocode?.balance_bonus_kopeks, 10);
if (!Number.isNaN(balanceKopeks) && balanceKopeks > 0) {
const currency = (userData?.balance_currency || 'RUB').toUpperCase();
const amount = formatCurrency(balanceKopeks / 100, currency);
const template = t('promo_code.result.balance');
rewards.push({
icon: '💰',
text: template.replace('{amount}', amount),
});
}
const subscriptionDays = Number.parseInt(promocode?.subscription_days, 10);
if (!Number.isNaN(subscriptionDays) && subscriptionDays > 0) {
const type = String(promocode?.type || '').toLowerCase();
const key = type === 'trial_subscription'
? 'promo_code.result.trial'
: 'promo_code.result.subscription_days';
const template = t(key);
rewards.push({
icon: type === 'trial_subscription' ? '🎁' : '⏰',
text: template.replace('{days}', subscriptionDays),
});
}
if (typeof payload?.description === 'string' && payload.description.trim()) {
const lines = payload.description.split(/\n+/).map(line => line.trim()).filter(Boolean);
lines.forEach(line => {
fallback.push({ icon: '🎉', text: line });
});
}
renderPromoCodeResult(rewards, fallback);
setPromoCodeFeedback('success', t('promo_code.success.default'));
if (input) {
input.value = '';
}
try {
await refreshSubscriptionData({ silent: true });
} catch (refreshError) {
console.warn('Failed to refresh subscription after promo code activation:', refreshError);
}
} catch (error) {
console.error('Failed to activate promo code:', error);
setPromoCodeFeedback('error', t('promo_code.error.network'));
} finally {
if (button) {
button.disabled = false;
button.textContent = t('promo_code.button.default');
}
}
}
function initializePromoCodeForm() {
const form = document.getElementById('promoCodeForm');
if (!form) {
return;
}
form.addEventListener('submit', handlePromoCodeSubmit);
const input = document.getElementById('promoCodeInput');
if (input) {
input.addEventListener('input', () => {
setPromoCodeFeedback(null, null);
clearPromoCodeResult();
});
}
}
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 class="app-info">
<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">
<span class="step-number">${stepNum++}</span>
<div>
<div class="step-title">${escapeHtml(t('apps.step.download'))}</div>
${descriptionHtml}
${buttonsHtml}
</div>
</div>
`;
}
if (app.addSubscriptionStep) {
html += `
<div class="step">
<span class="step-number">${stepNum++}</span>
<div>
<div class="step-title">${escapeHtml(t('apps.step.add'))}</div>
<div class="step-description">${getLocalizedText(app.addSubscriptionStep.description)}</div>
</div>
</div>
`;
}
if (app.connectAndUseStep) {
html += `
<div class="step">
<span class="step-number">${stepNum++}</span>
<div>
<div class="step-title">${escapeHtml(t('apps.step.connect'))}</div>
<div class="step-description">${getLocalizedText(app.connectAndUseStep.description)}</div>
</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 ensureArray(value) {
return Array.isArray(value) ? value : [];
}
function coerceNumber(value, fallback = null) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : fallback;
}
if (typeof value === 'string') {
const normalized = value.trim();
if (!normalized) {
return fallback;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function coercePositiveInt(value, fallback = null) {
const numeric = coerceNumber(value, null);
if (numeric === null || Number.isNaN(numeric)) {
return fallback;
}
const intValue = Math.trunc(numeric);
return Number.isFinite(intValue) && intValue >= 0 ? intValue : fallback;
}
function coerceBoolean(value, fallback = false) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return fallback;
}
if (['true', '1', 'yes', 'on', 'enabled'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'off', 'disabled'].includes(normalized)) {
return false;
}
}
return fallback;
}
async function parseJsonSafe(response) {
try {
return await response.json();
} catch (error) {
return null;
}
}
function isSameSet(a, b) {
if (!(a instanceof Set) || !(b instanceof Set)) {
return false;
}
if (a.size !== b.size) {
return false;
}
for (const item of a) {
if (!b.has(item)) {
return false;
}
}
return true;
}
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 formatPriceFromKopeks(kopeks, currency) {
const normalized = typeof kopeks === 'number'
? kopeks
: Number.parseInt(String(kopeks ?? '').trim() || '0', 10);
const currencyCode = currency
? String(currency).toUpperCase()
: String(userData?.balance_currency || 'RUB').toUpperCase();
if (!Number.isFinite(normalized)) {
return formatCurrency(0, currencyCode);
}
return formatCurrency(normalized / 100, currencyCode);
}
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 formatLegalUpdatedLabel(value) {
const formatted = formatDateTime(value);
if (!formatted) {
return '';
}
const template = t('legal.updated_at');
if (!template || template === 'legal.updated_at') {
return formatted;
}
if (template.includes('{date}')) {
return template.replace('{date}', formatted);
}
return `${template} ${formatted}`;
}
function renderBalanceSection() {
const amountElement = document.getElementById('balanceAmount');
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);
}
function getTopupElements() {
return {
backdrop: document.getElementById('topupModal'),
title: document.getElementById('topupModalTitle'),
subtitle: document.getElementById('topupModalSubtitle'),
body: document.getElementById('topupModalBody'),
error: document.getElementById('topupModalError'),
footer: document.getElementById('topupModalFooter'),
};
}
function setTopupModalTitle(key, fallback) {
const { title } = getTopupElements();
if (!title) {
return;
}
const value = t(key);
title.textContent = value === key ? (fallback || key) : value;
}
function setTopupModalSubtitle(key, fallback) {
const { subtitle } = getTopupElements();
if (!subtitle) {
return;
}
const value = t(key);
subtitle.textContent = value === key ? (fallback || key) : value;
}
function setTopupFooter(buttons = []) {
const { footer } = getTopupElements();
if (!footer) {
return;
}
footer.innerHTML = '';
buttons.forEach(config => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `modal-button ${config.variant || 'primary'}`;
if (config.id) {
btn.id = config.id;
}
const label = config.labelKey ? t(config.labelKey) : config.label;
btn.textContent = label && label !== config.labelKey
? label
: (config.fallbackLabel || config.labelKey || config.label || '');
if (typeof config.onClick === 'function') {
btn.addEventListener('click', config.onClick);
}
if (config.disabled) {
btn.disabled = true;
}
footer.appendChild(btn);
});
}
function showTopupError(messageKey, fallback) {
const { error } = getTopupElements();
if (!error) {
return;
}
const message = t(messageKey);
error.textContent = message === messageKey ? (fallback || messageKey) : message;
error.classList.remove('hidden');
}
function clearTopupError() {
const { error } = getTopupElements();
if (error) {
error.classList.add('hidden');
error.textContent = '';
}
}
async function loadPaymentMethods(force = false) {
if (!force && paymentMethodsCache) {
return paymentMethodsCache;
}
if (!force && paymentMethodsPromise) {
return paymentMethodsPromise;
}
const initData = tg.initData || '';
if (!initData) {
throw new Error('Missing init data');
}
paymentMethodsPromise = fetch('/miniapp/payments/methods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData }),
}).then(async response => {
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const detail = payload?.detail;
throw new Error(typeof detail === 'string' ? detail : 'Failed to load payment methods');
}
const data = await response.json().catch(() => ({}));
return Array.isArray(data?.methods) ? data.methods : [];
}).finally(() => {
paymentMethodsPromise = null;
});
try {
paymentMethodsCache = await paymentMethodsPromise;
} catch (error) {
paymentMethodsCache = null;
throw error;
}
return paymentMethodsCache;
}
function openTopupModal() {
const { backdrop } = getTopupElements();
if (!backdrop) {
return;
}
backdrop.classList.remove('hidden');
document.body.classList.add('modal-open');
renderTopupMethodsView();
}
function closeTopupModal() {
const { backdrop } = getTopupElements();
if (backdrop) {
backdrop.classList.add('hidden');
}
document.body.classList.remove('modal-open');
activePaymentMethod = null;
stopAllPaymentMonitors();
clearTopupError();
}
function renderTopupLoading(messageKey = 'topup.loading') {
const { body } = getTopupElements();
if (!body) {
return;
}
body.innerHTML = '';
const loadingText = document.createElement('div');
loadingText.className = 'amount-hint';
const text = t(messageKey);
loadingText.textContent = text === messageKey ? 'Loading…' : text;
body.appendChild(loadingText);
}
async function renderTopupMethodsView() {
activePaymentMethod = null;
stopAllPaymentMonitors();
clearTopupError();
setTopupModalTitle('topup.title', 'Top up balance');
setTopupModalSubtitle('topup.subtitle', 'Choose a payment method');
setTopupFooter([
{
labelKey: 'topup.cancel',
fallbackLabel: 'Close',
variant: 'secondary',
onClick: closeTopupModal,
},
]);
try {
renderTopupLoading('topup.loading');
const methods = await loadPaymentMethods();
const { body } = getTopupElements();
if (!body) {
return;
}
body.innerHTML = '';
if (!methods.length) {
const empty = document.createElement('div');
empty.className = 'amount-hint';
const message = t('topup.error.unavailable');
empty.textContent = message === 'topup.error.unavailable'
? 'Payment methods are temporarily unavailable.'
: message;
body.appendChild(empty);
return;
}
const list = document.createElement('div');
list.className = 'payment-methods-list';
methods.forEach(method => {
const card = createPaymentMethodCard(method);
if (card) {
list.appendChild(card);
}
});
body.appendChild(list);
} catch (error) {
console.error('Failed to load payment methods:', error);
const { body } = getTopupElements();
if (!body) {
return;
}
body.innerHTML = '';
const errorMessage = document.createElement('div');
errorMessage.className = 'amount-hint';
const text = t('topup.error.unavailable');
errorMessage.textContent = text === 'topup.error.unavailable'
? 'Payment methods are temporarily unavailable.'
: text;
body.appendChild(errorMessage);
}
}
function createPaymentMethodCard(method) {
if (!method || !method.id) {
return null;
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'payment-method-card';
button.dataset.methodId = method.id;
const info = document.createElement('div');
info.className = 'payment-method-info';
const icon = document.createElement('div');
icon.className = 'payment-method-icon';
icon.textContent = method.icon || '💳';
const textContainer = document.createElement('div');
textContainer.className = 'payment-method-text';
const label = document.createElement('div');
label.className = 'payment-method-label';
const labelKey = `topup.method.${method.id}.title`;
const labelValue = t(labelKey);
label.textContent = labelValue === labelKey ? method.id : labelValue;
const description = document.createElement('div');
description.className = 'payment-method-description';
const descriptionKey = `topup.method.${method.id}.description`;
const descriptionValue = t(descriptionKey);
if (descriptionValue && descriptionValue !== descriptionKey) {
description.textContent = descriptionValue;
textContainer.appendChild(description);
}
textContainer.insertBefore(label, textContainer.firstChild);
info.appendChild(icon);
info.appendChild(textContainer);
button.appendChild(info);
button.addEventListener('click', () => handlePaymentMethodSelection(method));
return button;
}
function handlePaymentMethodSelection(method) {
if (!method) {
return;
}
if (method.requires_amount) {
renderTopupAmountForm(method, { selectedOption: paymentMethodSelections[method.id] });
} else {
startPaymentForMethod(method);
}
}
function renderTopupAmountForm(method, options = {}) {
activePaymentMethod = method;
stopAllPaymentMonitors();
clearTopupError();
setTopupModalTitle('topup.amount.title', 'Enter amount');
setTopupModalSubtitle('topup.amount.subtitle', 'Specify how much you want to top up');
const { body } = getTopupElements();
if (!body) {
return;
}
body.innerHTML = '';
const form = document.createElement('form');
form.className = 'amount-form';
form.id = 'topupAmountForm';
const currency = (method.currency || userData?.balance_currency || 'RUB').toUpperCase();
const input = document.createElement('input');
input.type = 'text';
input.id = 'topupAmountInput';
input.className = 'amount-input';
input.autocomplete = 'off';
input.inputMode = 'decimal';
input.placeholder = (t('topup.amount.placeholder') || 'Amount').replace('{currency}', currency);
form.appendChild(input);
const hint = document.createElement('div');
hint.className = 'amount-hint';
const limitsText = buildAmountHint(method, currency);
if (limitsText) {
hint.textContent = limitsText;
form.appendChild(hint);
}
if (method.id === 'pal24') {
const optionsConfig = [
{
id: 'sbp',
icon: '🏦',
titleKey: 'topup.method.pal24.option.sbp.title',
descriptionKey: 'topup.method.pal24.option.sbp.description',
fallbackTitle: 'Faster Payments (SBP)',
fallbackDescription: 'Instant SBP transfer with no fees.',
},
{
id: 'card',
icon: '💳',
titleKey: 'topup.method.pal24.option.card.title',
descriptionKey: 'topup.method.pal24.option.card.description',
fallbackTitle: 'Bank card',
fallbackDescription: 'Pay with a bank card via PayPalych.',
},
];
const selectedDefault = options.selectedOption
|| paymentMethodSelections[method.id]
|| 'sbp';
let currentOption = optionsConfig.some(option => option.id === selectedDefault)
? selectedDefault
: 'sbp';
paymentMethodSelections[method.id] = currentOption;
form.dataset.paymentOption = currentOption;
const optionGroup = document.createElement('div');
optionGroup.className = 'payment-option-group';
const optionTitle = document.createElement('div');
optionTitle.className = 'payment-option-title';
const titleKey = 'topup.method.pal24.title';
const titleValue = t(titleKey);
optionTitle.textContent = titleValue === titleKey ? 'Choose payment type' : titleValue;
optionGroup.appendChild(optionTitle);
const optionList = document.createElement('div');
optionList.className = 'payment-option-list';
optionsConfig.forEach(config => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'payment-option-button';
button.dataset.optionId = config.id;
if (config.id === currentOption) {
button.classList.add('active');
}
const icon = document.createElement('div');
icon.className = 'payment-option-icon';
icon.textContent = config.icon;
const text = document.createElement('div');
text.className = 'payment-option-text';
const label = document.createElement('div');
label.className = 'payment-option-label';
const labelValue = t(config.titleKey);
label.textContent = labelValue === config.titleKey ? config.fallbackTitle : labelValue;
const description = document.createElement('div');
description.className = 'payment-option-description';
const descriptionValue = t(config.descriptionKey);
const finalDescription = descriptionValue === config.descriptionKey
? config.fallbackDescription
: descriptionValue;
description.textContent = finalDescription;
text.appendChild(label);
if (finalDescription) {
text.appendChild(description);
}
button.appendChild(icon);
button.appendChild(text);
button.addEventListener('click', () => {
currentOption = config.id;
paymentMethodSelections[method.id] = currentOption;
form.dataset.paymentOption = currentOption;
optionList.querySelectorAll('.payment-option-button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.optionId === currentOption);
});
});
optionList.appendChild(button);
});
optionGroup.appendChild(optionList);
form.appendChild(optionGroup);
} else {
delete form.dataset.paymentOption;
}
form.addEventListener('submit', event => {
event.preventDefault();
submitTopupAmount(method);
});
body.appendChild(form);
if (options.rawInput) {
input.value = options.rawInput;
}
setTopupFooter([
{
labelKey: 'topup.back',
fallbackLabel: 'Back',
variant: 'secondary',
onClick: renderTopupMethodsView,
},
{
labelKey: 'topup.submit',
fallbackLabel: 'Continue',
variant: 'primary',
id: 'topupSubmitButton',
onClick: () => submitTopupAmount(method),
},
]);
input.focus({ preventScroll: true });
}
function buildAmountHint(method, currency) {
const min = Number.isFinite(method?.min_amount_kopeks)
? method.min_amount_kopeks
: null;
const max = Number.isFinite(method?.max_amount_kopeks)
? method.max_amount_kopeks
: null;
if (min && max) {
const template = t('topup.amount.hint.range');
const minLabel = formatCurrency(min / 100, currency);
const maxLabel = formatCurrency(max / 100, currency);
if (template && template !== 'topup.amount.hint.range') {
return template.replace('{min}', minLabel).replace('{max}', maxLabel);
}
return `Available range: ${minLabel}${maxLabel}`;
}
if (min) {
const template = t('topup.amount.hint.single_min');
const minLabel = formatCurrency(min / 100, currency);
if (template && template !== 'topup.amount.hint.single_min') {
return template.replace('{min}', minLabel);
}
return `Minimum top-up: ${minLabel}`;
}
if (max) {
const template = t('topup.amount.hint.single_max');
const maxLabel = formatCurrency(max / 100, currency);
if (template && template !== 'topup.amount.hint.single_max') {
return template.replace('{max}', maxLabel);
}
return `Maximum top-up: ${maxLabel}`;
}
return '';
}
function parseAmountInput(value) {
if (typeof value !== 'string') {
return NaN;
}
const normalized = value.replace(',', '.').replace(/[^0-9.]/g, '');
return Number.parseFloat(normalized);
}
async function submitTopupAmount(method) {
const input = document.getElementById('topupAmountInput');
if (!input) {
return;
}
const rawValue = input.value.trim();
const numeric = parseAmountInput(rawValue);
if (!Number.isFinite(numeric) || numeric <= 0) {
showTopupError('topup.error.amount', 'Enter a valid amount.');
input.focus({ preventScroll: true });
return;
}
const amountKopeks = Math.round(numeric * 100);
const min = Number.isFinite(method?.min_amount_kopeks) ? method.min_amount_kopeks : null;
const max = Number.isFinite(method?.max_amount_kopeks) ? method.max_amount_kopeks : null;
if ((min && amountKopeks < min) || (max && amountKopeks > max)) {
showTopupError('topup.error.amount', 'Enter a valid amount.');
input.focus({ preventScroll: true });
return;
}
clearTopupError();
const form = document.getElementById('topupAmountForm');
const providerOption = form?.dataset?.paymentOption || paymentMethodSelections[method.id] || null;
await startPaymentForMethod(method, amountKopeks, {
rawInput: rawValue,
providerOption,
});
}
async function startPaymentForMethod(method, amountKopeks = null, options = {}) {
clearTopupError();
renderTopupLoading();
const footerButtons = [];
if (method?.requires_amount) {
footerButtons.push({
labelKey: 'topup.back',
fallbackLabel: 'Back',
variant: 'secondary',
onClick: renderTopupMethodsView,
});
}
footerButtons.push({
labelKey: 'topup.cancel',
fallbackLabel: 'Close',
variant: 'secondary',
onClick: closeTopupModal,
});
setTopupFooter(footerButtons);
const initData = tg.initData || '';
if (!initData) {
showTopupError('topup.error.generic', 'Unable to start payment.');
if (method?.requires_amount) {
renderTopupAmountForm(method, {
rawInput: options.rawInput,
selectedOption: options.providerOption || paymentMethodSelections[method?.id],
});
const input = document.getElementById('topupAmountInput');
if (input && options.rawInput) {
input.value = options.rawInput;
}
}
return;
}
const payload = {
initData,
method: method.id,
};
if (Number.isFinite(amountKopeks) && amountKopeks > 0) {
payload.amountKopeks = amountKopeks;
}
if (options.providerOption) {
payload.option = options.providerOption;
}
try {
const response = await fetch('/miniapp/payments/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = data?.detail;
const message = typeof detail === 'string' ? detail : null;
throw new Error(message || 'Failed to create payment');
}
const paymentUrl = data?.payment_url;
if (!paymentUrl) {
throw new Error('Payment link is missing');
}
renderTopupPaymentLink(
method,
paymentUrl,
data?.amount_kopeks ?? amountKopeks,
data?.extra || {},
{
rawInput: options.rawInput,
providerOption: options.providerOption,
},
);
} catch (error) {
console.error('Failed to create payment:', error);
if (method?.requires_amount) {
renderTopupAmountForm(method, {
rawInput: options.rawInput,
selectedOption: options.providerOption || paymentMethodSelections[method?.id],
});
const input = document.getElementById('topupAmountInput');
if (input && options.rawInput) {
input.value = options.rawInput;
}
showTopupError('topup.error.generic', error?.message || 'Unable to start payment.');
} else {
await renderTopupMethodsView();
showTopupError('topup.error.generic', error?.message || 'Unable to start payment.');
}
}
}
function renderTopupPaymentLink(method, paymentUrl, amountKopeks, extra = {}, options = {}) {
stopAllPaymentMonitors();
clearTopupError();
setTopupModalTitle('topup.title', 'Top up balance');
const { body } = getTopupElements();
if (!body) {
return;
}
const normalizedAmount = Number.isFinite(amountKopeks) ? Number(amountKopeks) : null;
const monitorExtra = { ...extra };
let option = null;
if (method.id === 'pal24') {
option = (options.providerOption || monitorExtra.selected_option || paymentMethodSelections[method.id] || 'sbp').toLowerCase();
if (!['card', 'sbp'].includes(option)) {
option = 'sbp';
}
paymentMethodSelections[method.id] = option;
monitorExtra.selected_option = option;
}
const titleKey = method.id === 'pal24' && option
? `topup.method.pal24.option.${option}.title`
: `topup.method.${method.id}.title`;
const titleFallback = method.id === 'pal24'
? (option === 'card' ? 'Bank card payment' : 'Faster Payments (SBP)')
: method.id;
setTopupModalSubtitle(titleKey, titleFallback);
body.innerHTML = '';
const summary = document.createElement('div');
summary.className = 'payment-summary';
const titleValue = t(titleKey);
const title = document.createElement('div');
title.className = 'payment-method-label';
title.textContent = titleValue && titleValue !== titleKey ? titleValue : titleFallback;
summary.appendChild(title);
if (normalizedAmount && normalizedAmount > 0) {
const amount = document.createElement('div');
amount.className = 'payment-summary-amount';
const currency = (method.currency || userData?.balance_currency || 'RUB').toUpperCase();
amount.textContent = formatCurrency(normalizedAmount / 100, currency);
summary.appendChild(amount);
if (method.id === 'stars') {
const starsAmount = Number.isFinite(monitorExtra?.stars_amount)
? Number(monitorExtra.stars_amount)
: null;
const requestedAmount = Number.isFinite(monitorExtra?.requested_amount_kopeks)
? Number(monitorExtra.requested_amount_kopeks)
: null;
if (starsAmount && starsAmount > 0) {
const template = t('topup.method.stars.invoice_hint');
const replacement = template && template !== 'topup.method.stars.invoice_hint'
? template.replace('{stars}', String(starsAmount))
: `${starsAmount}`;
const hint = document.createElement('div');
hint.className = 'amount-hint';
hint.textContent = replacement;
summary.appendChild(hint);
}
if (
requestedAmount &&
requestedAmount > 0 &&
requestedAmount !== normalizedAmount
) {
const template = t('topup.method.stars.adjusted');
const requestedLabel = formatCurrency(requestedAmount / 100, currency);
const normalizedLabel = formatCurrency(normalizedAmount / 100, currency);
let replacement = template;
if (replacement && replacement !== 'topup.method.stars.adjusted') {
replacement = replacement
.replace('{requested}', requestedLabel)
.replace('{amount}', normalizedLabel)
.replace('{stars}', String(starsAmount ?? ''));
} else {
replacement = `Requested ${requestedLabel}, invoice ${normalizedLabel}`;
}
const hint = document.createElement('div');
hint.className = 'amount-hint warning';
hint.textContent = replacement;
summary.appendChild(hint);
}
}
}
if (Number.isFinite(monitorExtra?.amount_usd) && monitorExtra.amount_usd > 0) {
const usdAmount = document.createElement('div');
usdAmount.className = 'amount-hint';
usdAmount.textContent = `${formatCurrency(monitorExtra.amount_usd, 'USD')}`;
summary.appendChild(usdAmount);
}
const descriptionKey = method.id === 'pal24' && option
? `topup.method.pal24.option.${option}.description`
: `topup.method.${method.id}.description`;
const descriptionValue = t(descriptionKey);
if (descriptionValue && descriptionValue !== descriptionKey) {
const description = document.createElement('div');
description.className = 'amount-hint';
description.textContent = descriptionValue;
summary.appendChild(description);
}
body.appendChild(summary);
let effectiveUrl = paymentUrl;
if (method.id === 'pal24') {
const urls = [];
if (option === 'sbp') {
urls.push(monitorExtra.sbp_url, monitorExtra.transfer_url);
}
if (option === 'card') {
urls.push(monitorExtra.card_url);
}
urls.push(
monitorExtra.link_url,
monitorExtra.link_page_url,
monitorExtra.transfer_url,
paymentUrl,
);
effectiveUrl = urls.find(url => typeof url === 'string' && url.length) || paymentUrl;
}
const statusView = createPaymentStatusView();
body.appendChild(statusView.element);
updatePaymentStatusView(statusView, { state: 'pending' });
startPaymentStatusMonitor({
method,
amountKopeks: normalizedAmount,
extra: monitorExtra,
statusView,
paymentUrl: effectiveUrl,
originalUrl: paymentUrl,
rawInput: options.rawInput || null,
option,
});
}
function updateReferralToggleState() {
const list = document.getElementById('referralList');
const empty = document.getElementById('referralListEmpty');
const toggle = document.getElementById('referralToggleBtn');
const hasItems = Boolean(list && list.childElementCount);
const shouldShowList = hasItems && referralListExpanded;
const shouldShowEmpty = !hasItems && referralListExpanded;
if (list) {
list.classList.toggle('hidden', !shouldShowList);
}
if (empty) {
empty.classList.toggle('hidden', !shouldShowEmpty);
}
if (toggle) {
const labelKey = referralListExpanded ? 'referral.toggle.close' : 'referral.toggle.open';
const label = t(labelKey);
toggle.textContent = label === labelKey ? (referralListExpanded ? 'Hide referrals' : 'My referrals') : label;
toggle.disabled = false;
}
}
function renderReferralSection() {
const card = document.getElementById('referralCard');
const content = document.getElementById('referralContent');
const emptyState = document.getElementById('referralEmpty');
const data = userData?.referral;
const summaryContainer = document.getElementById('referralCardSummary');
const summaryInvited = document.getElementById('referralSummaryInvited');
const summaryEarned = document.getElementById('referralSummaryEarned');
if (!card || !content || !emptyState) {
return;
}
referralListExpanded = false;
if (referralCopyResetHandle) {
clearTimeout(referralCopyResetHandle);
referralCopyResetHandle = null;
}
const copyBtn = document.getElementById('referralCopyBtn');
if (copyBtn) {
copyBtn.disabled = !navigator.clipboard;
copyBtn.dataset.copyValue = '';
copyBtn.textContent = t('referral.link.copy');
}
if (summaryContainer && summaryInvited && summaryEarned) {
summaryContainer.classList.add('hidden');
summaryInvited.textContent = '0';
summaryEarned.textContent = '—';
}
if (!data) {
content.classList.add('hidden');
emptyState.classList.remove('hidden');
emptyState.textContent = t('referral.empty');
updateReferralToggleState();
card.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
content.classList.remove('hidden');
card.classList.remove('hidden');
const linkValue = document.getElementById('referralLinkValue');
const link = data.referral_link || '';
const code = data.referral_code || '';
let copyTarget = '';
if (link) {
copyTarget = link;
} else if (code) {
copyTarget = code;
}
if (linkValue) {
linkValue.textContent = copyTarget || '—';
}
if (copyBtn) {
copyBtn.disabled = !navigator.clipboard || !copyTarget;
copyBtn.dataset.copyValue = copyTarget;
copyBtn.textContent = t('referral.link.copy');
}
const statsData = data.stats || null;
const statsContainer = document.getElementById('referralStats');
if (statsContainer) {
statsContainer.innerHTML = '';
const stats = statsData || {};
const entries = [
{ key: 'invited_count', label: 'referral.stats.invited', formatter: value => String(value ?? 0) },
{ key: 'active_referrals_count', label: 'referral.stats.active', formatter: value => String(value ?? 0) },
{ key: 'paid_referrals_count', label: 'referral.stats.paid', formatter: value => String(value ?? 0) },
{
key: 'total_earned_label',
label: 'referral.stats.total',
formatter: (_, full) => full.total_earned_label
|| formatPriceFromKopeks(full.total_earned_kopeks || 0),
},
{
key: 'month_earned_label',
label: 'referral.stats.month',
formatter: (_, full) => full.month_earned_label
|| formatPriceFromKopeks(full.month_earned_kopeks || 0),
},
{
key: 'conversion_rate',
label: 'referral.stats.conversion',
formatter: value => `${Number.parseFloat(value ?? 0).toFixed(1)}%`,
},
];
entries.forEach(entry => {
const { key, label, formatter } = entry;
const rawValue = key === 'total_earned_label' || key === 'month_earned_label'
? stats
: stats[key];
const hasValue = key === 'total_earned_label' || key === 'month_earned_label'
? Boolean((stats[key] ?? stats[key.replace('_label', '_kopeks')]) !== undefined)
: rawValue !== undefined && rawValue !== null;
if (!hasValue) {
if (key === 'conversion_rate') {
// Show conversion even if zero
} else if (key === 'total_earned_label' || key === 'month_earned_label') {
// always display totals
} else {
return;
}
}
const cardElement = document.createElement('div');
cardElement.className = 'referral-stat-card';
const labelElement = document.createElement('span');
labelElement.className = 'referral-stat-label';
labelElement.textContent = t(label);
const valueElement = document.createElement('span');
valueElement.className = 'referral-stat-value';
const formatted = formatter(rawValue, stats);
valueElement.textContent = formatted;
cardElement.appendChild(labelElement);
cardElement.appendChild(valueElement);
statsContainer.appendChild(cardElement);
});
}
if (summaryContainer && summaryInvited && summaryEarned) {
if (statsData) {
summaryInvited.textContent = String(statsData.invited_count ?? 0);
const totalLabel = statsData.total_earned_label
|| formatPriceFromKopeks(statsData.total_earned_kopeks || 0);
summaryEarned.textContent = totalLabel;
summaryContainer.classList.remove('hidden');
} else {
summaryContainer.classList.add('hidden');
}
}
const termsList = document.getElementById('referralTermsList');
if (termsList) {
termsList.innerHTML = '';
const terms = data.terms || {};
const entries = [
{ label: 'referral.terms.minimum_topup', value: terms.minimum_topup_label },
{ label: 'referral.terms.first_topup', value: terms.first_topup_bonus_label },
{ label: 'referral.terms.inviter_bonus', value: terms.inviter_bonus_label },
{
label: 'referral.terms.commission',
value: `${Number.parseFloat(terms.commission_percent ?? 0).toFixed(1).replace(/\.0$/, '')}%`,
},
];
entries
.filter(entry => entry.value !== undefined && entry.value !== null)
.forEach(entry => {
const item = document.createElement('li');
item.className = 'referral-term-item';
const label = document.createElement('span');
label.className = 'referral-term-label';
const labelKey = t(entry.label);
label.textContent = labelKey === entry.label ? entry.label : labelKey;
const value = document.createElement('span');
value.className = 'referral-term-value';
value.textContent = entry.value;
item.appendChild(label);
item.appendChild(value);
termsList.appendChild(item);
});
}
const list = document.getElementById('referralList');
const emptyListState = document.getElementById('referralListEmpty');
if (list && emptyListState) {
list.innerHTML = '';
const referrals = Array.isArray(data?.referrals?.items) ? data.referrals.items : [];
referrals.forEach(referral => {
const item = document.createElement('li');
item.className = 'referral-item';
const header = document.createElement('div');
header.className = 'referral-item-header';
const displayName = referral.full_name
|| referral.username
|| (referral.telegram_id ? `ID ${referral.telegram_id}` : t('referral.referrals.unknown'));
const titleWrapper = document.createElement('div');
titleWrapper.className = 'referral-item-name';
titleWrapper.textContent = displayName;
const statusBadge = document.createElement('span');
const status = (referral.status || '').toLowerCase();
let statusKey = `referral.status.${status}`;
if (!status || !t(statusKey) || t(statusKey) === statusKey) {
if (referral.has_made_first_topup) {
statusKey = 'referral.status.paying';
} else if (status === 'active') {
statusKey = 'referral.status.active';
} else {
statusKey = 'referral.status.inactive';
}
}
const resolved = t(statusKey);
statusBadge.textContent = resolved === statusKey ? statusKey.split('.').pop() : resolved;
statusBadge.className = 'referral-status';
const statusText = statusBadge.textContent?.toLowerCase() || '';
if (statusText.includes('inactive') || statusText.includes('неактив')) {
statusBadge.classList.remove('active');
statusBadge.classList.add('inactive');
} else if (statusText.includes('active') || statusText.includes('актив')) {
statusBadge.classList.add('active');
} else if (statusKey === 'referral.status.paying') {
statusBadge.classList.add('new');
}
header.appendChild(titleWrapper);
header.appendChild(statusBadge);
item.appendChild(header);
if (referral.username && referral.username !== displayName) {
const username = document.createElement('div');
username.className = 'referral-item-username';
username.textContent = `@${referral.username.replace(/^@/, '')}`;
item.appendChild(username);
}
const metrics = document.createElement('div');
metrics.className = 'referral-item-grid';
const earnedMetric = document.createElement('div');
earnedMetric.className = 'referral-item-metric';
const earnedLabel = document.createElement('span');
earnedLabel.className = 'referral-item-label';
earnedLabel.textContent = t('referral.meta.earned');
const earnedValue = document.createElement('span');
earnedValue.className = 'referral-item-value';
earnedValue.textContent = referral.total_earned_label
|| formatPriceFromKopeks(referral.total_earned_kopeks || 0);
earnedMetric.appendChild(earnedLabel);
earnedMetric.appendChild(earnedValue);
const topupsMetric = document.createElement('div');
topupsMetric.className = 'referral-item-metric';
const topupsLabel = document.createElement('span');
topupsLabel.className = 'referral-item-label';
topupsLabel.textContent = t('referral.meta.topups');
const topupsValue = document.createElement('span');
topupsValue.className = 'referral-item-value';
topupsValue.textContent = String(referral.topups_count ?? 0);
topupsMetric.appendChild(topupsLabel);
topupsMetric.appendChild(topupsValue);
metrics.appendChild(earnedMetric);
metrics.appendChild(topupsMetric);
const dates = document.createElement('div');
dates.className = 'referral-item-dates';
const joined = document.createElement('span');
joined.className = 'referral-item-date';
const joinedLabel = t('referral.meta.joined');
joined.innerHTML = `<strong>${joinedLabel === 'referral.meta.joined' ? 'Joined' : joinedLabel}:</strong> ${escapeHtml(formatDate(referral.created_at))}`;
const lastActivity = document.createElement('span');
lastActivity.className = 'referral-item-date';
const lastActivityLabel = t('referral.meta.last_activity');
const lastActivityValue = formatDate(referral.last_activity) || '—';
lastActivity.innerHTML = `<strong>${lastActivityLabel === 'referral.meta.last_activity' ? 'Last activity' : lastActivityLabel}:</strong> ${escapeHtml(lastActivityValue)}`;
dates.appendChild(joined);
dates.appendChild(lastActivity);
item.appendChild(metrics);
item.appendChild(dates);
list.appendChild(item);
});
updateReferralToggleState();
}
}
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(escapeHtml(createdAt));
}
if (statusLabel) {
metaParts.push(escapeHtml(statusLabel));
}
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';
const iconHtml = isNegative
? '<svg class="history-amount-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l5-5m0 0l5 5m-5-5v12"/></svg>'
: '<svg class="history-amount-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5m0 0l5-5m-5 5V6"/></svg>';
return `
<li class="history-item">
<div class="history-item-header">
<span class="${amountClass}">${iconHtml}${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');
const removeLabelRaw = t('devices.remove_button_label');
const removeLabel = (typeof removeLabelRaw === 'string' && removeLabelRaw !== 'devices.remove_button_label')
? removeLabelRaw
: 'Reset device';
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>`
: '';
const deviceHwid = device?.hwid ? String(device.hwid) : '';
const safeTitle = escapeHtml(title);
const removeButtonHtml = deviceHwid
? `
<div class="device-actions">
<button
class="device-remove-button"
type="button"
data-device-hwid="${escapeHtml(deviceHwid)}"
data-device-label="${safeTitle}"
aria-label="${escapeHtml(removeLabel)}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
`
: '';
return `
<li class="device-item">
<div class="device-header">
<div class="device-title">${safeTitle}</div>
${removeButtonHtml}
</div>
${metaHtml}
</li>
`;
}).join('');
list.querySelectorAll('.device-remove-button').forEach(button => {
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const { deviceHwid = '', deviceLabel = '' } = button.dataset;
handleDeviceRemoval(deviceHwid, deviceLabel, button);
});
});
}
function setDeviceRemovingState(button, isRemoving) {
if (!button) {
return;
}
if (isRemoving) {
button.disabled = true;
button.classList.add('is-removing');
} else {
button.disabled = false;
button.classList.remove('is-removing');
}
}
function resolveDeviceLabel(value) {
const raw = typeof value === 'string' ? value.trim() : '';
if (raw) {
return raw;
}
const fallback = t('values.not_available');
if (typeof fallback === 'string' && fallback !== 'values.not_available') {
return fallback;
}
return 'Device';
}
function confirmDeviceRemoval(deviceName) {
const label = resolveDeviceLabel(deviceName);
const template = t('devices.remove_confirm.message');
const message = typeof template === 'string' && template.includes('{device}')
? template.replace('{device}', label)
: template;
const title = t('devices.remove_confirm.title');
return new Promise(resolve => {
if (typeof tg.showPopup === 'function') {
tg.showPopup({
title: typeof title === 'string' ? title : 'Confirm',
message: typeof message === 'string' ? message : String(message),
buttons: [
{
id: 'confirm',
type: 'destructive',
text: t('devices.remove_confirm.confirm') || 'Reset',
},
{
id: 'cancel',
type: 'cancel',
text: t('devices.remove_confirm.cancel') || 'Cancel',
},
],
}, buttonId => {
resolve(buttonId === 'confirm');
});
} else {
resolve(window.confirm(typeof message === 'string' ? message : label));
}
});
}
async function handleDeviceRemoval(hwid, deviceName, button) {
const normalizedHwid = typeof hwid === 'string' ? hwid.trim() : '';
if (!normalizedHwid) {
showPopup(
t('devices.remove_error.generic') || 'Failed to reset the device. Please try again later.',
t('devices.remove_error.title') || 'Unable to reset device',
);
return;
}
const confirmed = await confirmDeviceRemoval(deviceName);
if (!confirmed) {
return;
}
const initData = tg.initData || '';
if (!initData) {
showPopup(
t('devices.remove_error.unauthorized')
|| 'Authorization failed. Please reopen the mini app from Telegram and try again.',
t('devices.remove_error.title') || 'Unable to reset device',
);
return;
}
setDeviceRemovingState(button, true);
try {
const response = await fetch('/miniapp/devices/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData, hwid: normalizedHwid }),
});
let payload = null;
try {
payload = await response.json();
} catch (error) {
payload = null;
}
if (!response.ok || (payload && payload.success === false)) {
const message = payload?.message
|| payload?.detail?.message
|| t('devices.remove_error.generic')
|| 'Failed to reset the device. Please try again later.';
showPopup(
message,
t('devices.remove_error.title') || 'Unable to reset device',
);
return;
}
applyDeviceRemovalUpdate(normalizedHwid);
showPopup(
t('devices.remove_success') || 'The device has been reset successfully.',
t('devices.remove_success.title') || 'Device reset',
);
} catch (error) {
console.warn('Failed to remove device:', error);
showPopup(
t('devices.remove_error.network') || 'Network error. Please try again later.',
t('devices.remove_error.title') || 'Unable to reset device',
);
} finally {
setDeviceRemovingState(button, false);
}
}
function applyDeviceRemovalUpdate(hwid) {
if (!userData) {
return;
}
const normalizedHwid = typeof hwid === 'string' ? hwid.trim() : '';
if (!normalizedHwid) {
return;
}
const devices = Array.isArray(userData.connected_devices)
? userData.connected_devices
: [];
const filtered = devices.filter(device => {
if (!device) {
return false;
}
const deviceHwid = typeof device.hwid === 'string'
? device.hwid.trim()
: device.hwid;
if (!deviceHwid) {
return true;
}
return deviceHwid !== normalizedHwid;
});
userData.connected_devices = filtered;
const newCount = filtered.length;
userData.connected_devices_count = newCount;
const devicesCountElement = document.getElementById('devicesCount');
if (devicesCountElement) {
devicesCountElement.textContent = newCount;
}
renderDevicesList();
}
function renderFaqSection() {
const faqCard = document.getElementById('faqCard');
const faqList = document.getElementById('faqList');
if (!faqCard || !faqList) {
return;
}
const faq = userData?.faq;
const isEnabled = faq && faq.is_enabled !== false;
if (!isEnabled) {
faqCard.classList.add('hidden');
faqList.innerHTML = '';
return;
}
const items = Array.isArray(faq?.items) ? faq.items : [];
const processedItems = [];
items.forEach(item => {
if (!item) {
return;
}
const sanitized = sanitizeFaqHtml(item.content);
if (!sanitized) {
return;
}
const probe = document.createElement('div');
probe.innerHTML = sanitized;
if (!probe.textContent.trim()) {
return;
}
processedItems.push({ item, sanitized });
});
if (!processedItems.length) {
faqCard.classList.add('hidden');
faqList.innerHTML = '';
return;
}
processedItems.sort((a, b) => {
const parseOrder = value => {
if (value === null || value === undefined) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const orderA = parseOrder(a.item?.display_order);
const orderB = parseOrder(b.item?.display_order);
if (orderA !== null && orderB !== null && orderA !== orderB) {
return orderA - orderB;
}
if (orderA !== null) {
return -1;
}
if (orderB !== null) {
return 1;
}
const idA = Number(a.item?.id) || 0;
const idB = Number(b.item?.id) || 0;
return idA - idB;
});
const fallbackTitleTemplate = t('faq.item_default_title');
const fallbackAnswer = t('faq.item_empty');
const html = processedItems.map(({ item, sanitized }, index) => {
const hasTitle = typeof item.title === 'string' && item.title.trim().length > 0;
const fallbackTitle = fallbackTitleTemplate.includes('{index}')
? fallbackTitleTemplate.replace('{index}', String(index + 1))
: `${fallbackTitleTemplate} ${index + 1}`;
const question = escapeHtml(hasTitle ? item.title : fallbackTitle);
const answer = sanitized || `<p>${escapeHtml(fallbackAnswer)}</p>`;
return `
<details class="faq-item">
<summary class="faq-question">
<span class="faq-question-text">${question}</span>
<svg class="faq-toggle-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</summary>
<div class="faq-answer">${answer}</div>
</details>
`;
}).join('');
faqList.innerHTML = html;
faqCard.classList.remove('hidden');
}
function renderLegalDocuments() {
const card = document.getElementById('legalDocsCard');
const list = document.getElementById('legalDocsList');
if (!card || !list) {
return;
}
const documents = userData?.legal_documents;
if (!documents || typeof documents !== 'object') {
list.innerHTML = '';
card.classList.add('hidden');
return;
}
const entries = [];
Object.keys(LEGAL_DOCUMENT_CONFIG).forEach(key => {
const doc = documents?.[key];
if (!doc || doc.is_enabled === false) {
return;
}
const rawContent = typeof doc.content === 'string' ? doc.content : '';
const sanitized = sanitizeLegalDocumentHtml(rawContent);
if (!sanitized) {
return;
}
const probe = document.createElement('div');
probe.innerHTML = sanitized;
if (!probe.textContent.trim()) {
return;
}
entries.push({ key, doc, sanitized });
});
if (!entries.length) {
list.innerHTML = '';
card.classList.add('hidden');
return;
}
const html = entries.map(({ key, doc, sanitized }) => {
const config = LEGAL_DOCUMENT_CONFIG[key] || {};
const translationKey = config.titleKey || '';
const translatedTitle = translationKey ? t(translationKey) : translationKey;
const fallbackTitle = config.fallback || translationKey || key;
const rawTitle = typeof doc.title === 'string' ? doc.title.trim() : '';
const resolvedTitle = rawTitle
|| (translatedTitle && translatedTitle !== translationKey ? translatedTitle : fallbackTitle);
const updatedSource = doc.updated_at ?? doc.updatedAt ?? null;
const updatedLabel = updatedSource ? formatLegalUpdatedLabel(updatedSource) : '';
const updatedHtml = updatedLabel
? `<div class="legal-doc-updated">${escapeHtml(updatedLabel)}</div>`
: '';
const icon = escapeHtml(config.icon || '📄');
return `
<details class="legal-doc-item">
<summary class="legal-doc-summary">
<span class="legal-doc-icon">${icon}</span>
<span class="legal-doc-title">${escapeHtml(resolvedTitle)}</span>
<svg class="legal-doc-toggle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</summary>
<div class="legal-doc-body">
${updatedHtml}
<div class="legal-doc-content">${sanitized}</div>
</div>
</details>
`;
}).join('');
list.innerHTML = html;
card.classList.remove('hidden');
}
const PROMO_DISCOUNT_FIELDS = [
{ field: 'server_discount_percent', labelKey: 'promo_levels.discounts.server' },
{ field: 'traffic_discount_percent', labelKey: 'promo_levels.discounts.traffic' },
{ field: 'device_discount_percent', labelKey: 'promo_levels.discounts.devices' },
];
function normalizePromoPercent(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function populatePromoDiscounts(container, source, options = {}) {
const { showEmptyMessage = false } = options;
if (!container) {
return false;
}
container.innerHTML = '';
if (!source) {
if (showEmptyMessage) {
const badge = document.createElement('span');
badge.className = 'promo-discount-badge muted';
badge.textContent = t('promo_levels.discounts.none');
container.appendChild(badge);
container.classList.remove('hidden');
} else {
container.classList.add('hidden');
}
return false;
}
let hasDiscounts = false;
PROMO_DISCOUNT_FIELDS.forEach(({ field, labelKey }) => {
const percentValue = normalizePromoPercent(source?.[field]);
if (percentValue === null) {
return;
}
const normalized = Math.max(0, Math.round(percentValue));
if (normalized <= 0) {
return;
}
hasDiscounts = true;
const badge = document.createElement('span');
badge.className = 'promo-discount-badge';
const label = document.createElement('span');
label.className = 'promo-discount-label';
label.textContent = t(labelKey);
const valueElement = document.createElement('span');
valueElement.className = 'promo-discount-value';
valueElement.textContent = `${normalized}%`;
badge.appendChild(label);
badge.appendChild(valueElement);
container.appendChild(badge);
});
if (!hasDiscounts) {
if (showEmptyMessage) {
const badge = document.createElement('span');
badge.className = 'promo-discount-badge muted';
badge.textContent = t('promo_levels.discounts.none');
container.appendChild(badge);
container.classList.remove('hidden');
} else {
container.classList.add('hidden');
}
return false;
}
container.classList.remove('hidden');
return true;
}
function collectPromoDiscountValues(source) {
const values = [];
PROMO_DISCOUNT_FIELDS.forEach(({ field }) => {
const percentValue = normalizePromoPercent(source?.[field]);
if (percentValue === null) {
return;
}
const normalized = Math.max(0, Math.round(percentValue));
if (normalized > 0) {
values.push(normalized);
}
});
return values;
}
function normalizePeriodDiscountsMap(periodDiscounts) {
if (!periodDiscounts || typeof periodDiscounts !== 'object') {
return [];
}
const entries = [];
Object.entries(periodDiscounts).forEach(([key, value]) => {
const period = Number.parseInt(key, 10);
const percent = normalizePromoPercent(value);
if (!Number.isFinite(period) || period <= 0 || percent === null) {
return;
}
const normalized = Math.max(0, Math.round(percent));
if (normalized <= 0) {
return;
}
entries.push({ period, percent: normalized });
});
return entries.sort((a, b) => a.period - b.period);
}
function formatPeriodLabel(period) {
const template = t('promo.periods.label');
if (template && template !== 'promo.periods.label' && template.includes('{months}')) {
return template.replace('{months}', period);
}
const suffix = t('promo.periods.month_suffix');
if (suffix && suffix !== 'promo.periods.month_suffix') {
if (suffix.includes('{months}')) {
return suffix.replace('{months}', period);
}
return `${period} ${suffix}`;
}
return `${period} mo`;
}
function populatePromoPeriods(container, periodDiscounts) {
if (!container) {
return false;
}
container.innerHTML = '';
const entries = normalizePeriodDiscountsMap(periodDiscounts);
if (!entries.length) {
container.classList.add('hidden');
return false;
}
entries.forEach(({ period, percent }) => {
const badge = document.createElement('span');
badge.className = 'promo-period-badge';
const label = document.createElement('span');
label.className = 'promo-period-label';
label.textContent = formatPeriodLabel(period);
const value = document.createElement('span');
value.className = 'promo-period-value';
value.textContent = `${percent}%`;
badge.appendChild(label);
badge.appendChild(value);
container.appendChild(badge);
});
container.classList.remove('hidden');
return true;
}
function computeTopPromoDiscount(source) {
if (!source) {
return 0;
}
const values = collectPromoDiscountValues(source);
normalizePeriodDiscountsMap(source.period_discounts).forEach(entry => {
values.push(entry.percent);
});
return values.length ? Math.max(...values) : 0;
}
function renderPromoGroupInfo() {
const section = document.getElementById('promoCurrentGroupSection');
const valueElement = document.getElementById('promoGroupValue');
const discountsContainer = document.getElementById('promoGroupDiscounts');
const periodWrapper = document.getElementById('promoGroupPeriods');
const periodList = document.getElementById('promoGroupPeriodList');
const summaryName = document.getElementById('promoCardGroupName');
const summaryDiscount = document.getElementById('promoCardTopDiscount');
if (!section || !valueElement || !summaryName) {
return { hasGroup: false, hasDiscounts: false, topDiscount: 0 };
}
const promoGroup = userData?.promo_group || null;
const hasGroup = Boolean(promoGroup);
const groupLabel = promoGroup?.name || t('promo.summary.no_group');
summaryName.textContent = groupLabel;
valueElement.textContent = promoGroup?.name || t('values.not_available');
let hasBaseDiscounts = false;
let hasPeriodDiscounts = false;
if (discountsContainer) {
hasBaseDiscounts = populatePromoDiscounts(discountsContainer, promoGroup, {
showEmptyMessage: hasGroup,
});
}
if (periodList && periodWrapper) {
hasPeriodDiscounts = populatePromoPeriods(periodList, promoGroup?.period_discounts);
periodWrapper.classList.toggle('hidden', !hasPeriodDiscounts);
}
section.classList.toggle('hidden', !hasGroup);
const topDiscount = hasGroup ? computeTopPromoDiscount(promoGroup) : 0;
if (summaryDiscount) {
if (topDiscount > 0) {
const template = t('promo.summary.up_to');
const valueLabel = `${topDiscount}%`;
summaryDiscount.textContent = template.includes('{value}')
? template.replace('{value}', valueLabel)
: `${template} ${valueLabel}`;
summaryDiscount.classList.remove('hidden');
} else {
summaryDiscount.classList.add('hidden');
}
}
return {
hasGroup,
hasDiscounts: hasBaseDiscounts || hasPeriodDiscounts,
topDiscount,
};
}
function renderPromoLevels() {
const list = document.getElementById('promoLevelsList');
const section = document.getElementById('promoLevelsSection');
const totalSpentElement = document.getElementById('promoLevelsSpent');
if (!list || !section || !totalSpentElement) {
return false;
}
const levels = Array.isArray(userData?.auto_assign_promo_groups)
? userData.auto_assign_promo_groups
: [];
const totalSpentKopeksRaw = typeof userData?.total_spent_kopeks === 'number'
? userData.total_spent_kopeks
: Number.parseInt(userData?.total_spent_kopeks ?? '0', 10);
const totalSpentKopeks = Number.isFinite(totalSpentKopeksRaw) ? totalSpentKopeksRaw : 0;
totalSpentElement.textContent = userData?.total_spent_label
|| formatPriceFromKopeks(totalSpentKopeks);
list.innerHTML = '';
if (!levels.length) {
section.classList.add('hidden');
return false;
}
section.classList.remove('hidden');
const currencyCode = (userData?.balance_currency || 'RUB').toUpperCase();
const thresholdTemplate = t('promo_levels.threshold');
levels.forEach(level => {
const classes = ['promo-level-item'];
if (level?.is_current) {
classes.push('current', 'reached');
} else if (level?.is_reached) {
classes.push('reached');
} else {
classes.push('locked');
}
const item = document.createElement('li');
item.className = classes.join(' ');
const info = document.createElement('div');
info.className = 'promo-level-info';
const name = document.createElement('div');
name.className = 'promo-level-name';
name.textContent = level?.name || t('values.not_available');
info.appendChild(name);
const threshold = document.createElement('div');
threshold.className = 'promo-level-threshold';
const thresholdLabel = level?.threshold_label
|| formatPriceFromKopeks(level?.threshold_kopeks, currencyCode);
threshold.textContent = thresholdTemplate.includes('{amount}')
? thresholdTemplate.replace('{amount}', thresholdLabel)
: `${thresholdTemplate} ${thresholdLabel}`;
info.appendChild(threshold);
const benefits = document.createElement('div');
benefits.className = 'promo-level-benefits';
const discountBadges = document.createElement('div');
discountBadges.className = 'promo-discount-list';
const hasDiscounts = populatePromoDiscounts(discountBadges, level);
if (hasDiscounts) {
benefits.appendChild(discountBadges);
}
const periodWrapper = document.createElement('div');
periodWrapper.className = 'promo-periods';
const periodLabel = document.createElement('span');
periodLabel.className = 'promo-periods-label';
periodLabel.textContent = t('promo.periods.title_short');
const levelPeriodList = document.createElement('div');
levelPeriodList.className = 'promo-period-list';
const hasPeriods = populatePromoPeriods(levelPeriodList, level?.period_discounts);
if (hasPeriods) {
periodWrapper.appendChild(periodLabel);
periodWrapper.appendChild(levelPeriodList);
benefits.appendChild(periodWrapper);
}
if (benefits.childElementCount > 0) {
info.appendChild(benefits);
}
const badge = document.createElement('div');
badge.className = 'promo-level-badge';
let badgeKey = 'promo_levels.badge.locked';
if (level?.is_current) {
badgeKey = 'promo_levels.badge.current';
} else if (level?.is_reached) {
badgeKey = 'promo_levels.badge.unlocked';
}
badge.textContent = t(badgeKey);
item.appendChild(info);
item.appendChild(badge);
list.appendChild(item);
});
return true;
}
function renderPromoSection() {
const card = document.getElementById('promoCard');
const divider = document.getElementById('promoDivider');
if (!card) {
return;
}
const groupResult = renderPromoGroupInfo();
const hasLevels = renderPromoLevels();
const hasGroupSection = Boolean(groupResult?.hasGroup);
if (divider) {
divider.classList.toggle('hidden', !(hasGroupSection && hasLevels));
}
const shouldShow = hasGroupSection || hasLevels;
card.classList.toggle('hidden', !shouldShow);
if (!shouldShow) {
card.classList.remove('expanded');
}
}
function hasPaidSubscription() {
if (!userData?.user) {
return false;
}
const typeRaw = String(userData.subscription_type || '').toLowerCase();
if (typeRaw === 'trial') {
return false;
}
if (typeRaw === 'paid') {
return true;
}
if (userData.user.has_active_subscription === false) {
return false;
}
const statusRaw = String(
userData.user.subscription_actual_status
|| userData.user.subscription_status
|| ''
).toLowerCase();
if (['trial', 'expired', 'disabled'].includes(statusRaw)) {
return false;
}
return true;
}
function normalizeServerEntry(entry) {
if (!entry) {
return null;
}
if (typeof entry === 'string') {
const normalized = entry.trim();
if (!normalized) {
return null;
}
return { uuid: normalized, name: normalized };
}
if (typeof entry !== 'object') {
return null;
}
const uuid = entry.uuid
|| entry.id
|| entry.squad_uuid
|| entry.short_uuid
|| entry.shortUuid
|| entry.value
|| null;
const name = entry.name
|| entry.title
|| entry.display_name
|| entry.displayName
|| entry.label
|| uuid
|| '';
if (!uuid && !name) {
return null;
}
return {
uuid: uuid || name,
name: name || uuid || '',
};
}
function resetSubscriptionSettingsSelections(data) {
if (!data) {
subscriptionSettingsSelections.servers = new Set();
subscriptionSettingsSelections.traffic = null;
subscriptionSettingsSelections.devices = null;
return;
}
const serverSet = data.current?.serverSet instanceof Set
? data.current.serverSet
: new Set();
subscriptionSettingsSelections.servers = new Set(Array.from(serverSet));
subscriptionSettingsSelections.traffic = data.traffic?.currentValue ?? null;
subscriptionSettingsSelections.devices = data.devices?.current ?? null;
}
function normalizeSubscriptionSettings(payload) {
if (!payload || typeof payload !== 'object') {
return null;
}
const root = payload.settings || payload.data || payload;
if (!root || typeof root !== 'object') {
return null;
}
const currentInfo = root.current || root.subscription || {};
const serversInfo = root.servers || root.countries || {};
const trafficInfo = root.traffic || root.traffic_options || root.trafficOptions || {};
const devicesInfo = root.devices || root.device_options || root.deviceOptions || {};
const currentServersRaw = ensureArray(
currentInfo.servers
|| currentInfo.connected_servers
|| root.current_servers
|| root.connected_servers
|| userData?.connected_servers
);
const normalizedCurrentServers = currentServersRaw
.map(normalizeServerEntry)
.filter(Boolean);
const serverSet = new Set(
normalizedCurrentServers
.map(server => server.uuid)
.filter(Boolean)
);
const availableServersRaw = ensureArray(
serversInfo.available
|| serversInfo.options
|| root.available_servers
|| root.available_squads
|| []
);
const normalizedAvailableServers = availableServersRaw
.map(entry => {
const base = normalizeServerEntry(entry);
if (!base) {
return null;
}
return {
uuid: base.uuid,
name: base.name,
priceKopeks: coercePositiveInt(
entry.price_kopeks ?? entry.priceKopeks ?? entry.price ?? entry.cost,
null
),
priceLabel: entry.price_label || entry.priceLabel || null,
discountPercent: coercePositiveInt(
entry.discount_percent ?? entry.discountPercent ?? entry.discount,
null
),
isConnected: coerceBoolean(
entry.is_connected ?? entry.connected ?? entry.isSelected ?? entry.selected ?? serverSet.has(base.uuid),
false
),
isAvailable: coerceBoolean(
entry.is_available ?? entry.available ?? entry.enabled ?? entry.selectable ?? true,
true
),
disabledReason: entry.disabled_reason || entry.reason || null,
};
})
.filter(Boolean);
const trafficOptionsRaw = ensureArray(
trafficInfo.options
|| root.available_traffic
|| root.traffic_options
|| []
);
const normalizedTrafficOptions = trafficOptionsRaw
.map(option => {
const value = coerceNumber(
option.value ?? option.gb ?? option.limit ?? option.traffic_gb ?? option.trafficGb,
null
);
return {
value,
label: option.label || option.title || (value !== null ? formatTrafficLimit(value) : ''),
priceKopeks: coercePositiveInt(option.price_kopeks ?? option.priceKopeks ?? option.price ?? null, null),
priceLabel: option.price_label || option.priceLabel || null,
isCurrent: coerceBoolean(option.is_current ?? option.current, false),
isAvailable: coerceBoolean(option.is_available ?? option.available ?? option.enabled ?? true, true),
description: option.description || null,
};
})
.filter(option => option.value !== null || option.label);
const currentTrafficLimit = coerceNumber(
currentInfo.traffic_limit_gb
?? currentInfo.traffic
?? root.current_traffic_gb
?? userData?.user?.traffic_limit_gb,
null
);
const currentTrafficLabel = currentInfo.traffic_limit_label
|| trafficInfo.current_label
|| trafficInfo.currentLabel
|| userData?.user?.traffic_limit_label
|| (currentTrafficLimit !== null ? formatTrafficLimit(currentTrafficLimit) : '');
let currentTrafficValue = normalizedTrafficOptions.find(option => option.isCurrent) || null;
if (!currentTrafficValue && currentTrafficLimit !== null) {
currentTrafficValue = normalizedTrafficOptions.find(
option => Number(option.value) === Number(currentTrafficLimit)
) || null;
}
const devicesOptionsRaw = ensureArray(
devicesInfo.options
|| root.available_devices
|| root.device_options
|| []
);
const normalizedDeviceOptions = devicesOptionsRaw
.map(option => {
const value = coercePositiveInt(option.value ?? option.devices ?? option.count ?? option.limit, null);
return {
value,
label: option.label || option.title || (value !== null ? String(value) : ''),
priceKopeks: coercePositiveInt(option.price_kopeks ?? option.priceKopeks ?? option.price ?? null, null),
priceLabel: option.price_label || option.priceLabel || null,
};
})
.filter(option => option.value !== null);
const currentDeviceLimit = coercePositiveInt(
devicesInfo.current
?? currentInfo.device_limit
?? root.current_device_limit
?? userData?.user?.device_limit,
0
);
return {
raw: payload,
subscriptionId: root.subscription_id
?? payload.subscription_id
?? payload.subscriptionId
?? userData?.subscription_id
?? userData?.subscriptionId
?? null,
currency: (root.currency || payload.currency || userData?.balance_currency || 'RUB').toString().toUpperCase(),
current: {
servers: normalizedCurrentServers,
serverSet,
trafficLimitGb: currentTrafficLimit,
trafficLabel: currentTrafficLabel,
deviceLimit: currentDeviceLimit,
},
servers: {
available: normalizedAvailableServers,
min: coercePositiveInt(
serversInfo.min ?? serversInfo.min_selectable ?? serversInfo.minRequired ?? root.min_servers,
0
) || 0,
max: coercePositiveInt(
serversInfo.max ?? serversInfo.max_selectable ?? serversInfo.maxAllowed ?? root.max_servers,
0
) || 0,
canUpdate: coerceBoolean(
serversInfo.can_update ?? serversInfo.enabled ?? serversInfo.allow_update ?? true,
true
),
hint: serversInfo.hint || null,
},
traffic: {
options: normalizedTrafficOptions,
canUpdate: coerceBoolean(
trafficInfo.can_update ?? trafficInfo.enabled ?? trafficInfo.allow_update ?? true,
true
),
currentValue: currentTrafficValue
? currentTrafficValue.value
: (currentTrafficLimit !== null ? currentTrafficLimit : null),
},
devices: {
options: normalizedDeviceOptions,
canUpdate: coerceBoolean(
devicesInfo.can_update ?? devicesInfo.enabled ?? devicesInfo.allow_update ?? true,
true
),
min: coercePositiveInt(devicesInfo.min ?? devicesInfo.min_devices ?? devicesInfo.min_limit, 0) || 0,
max: coercePositiveInt(devicesInfo.max ?? devicesInfo.max_devices ?? devicesInfo.max_limit, 0) || 0,
step: coercePositiveInt(devicesInfo.step ?? devicesInfo.increment ?? 1, 1) || 1,
current: currentDeviceLimit,
},
};
}
function extractSettingsError(payload, status) {
if (status === 401) {
return t('subscription_settings.error.unauthorized');
}
if (!payload || typeof payload !== 'object') {
return t('subscription_settings.error.generic');
}
if (typeof payload.detail === 'string') {
return payload.detail;
}
if (payload.detail && typeof payload.detail === 'object') {
if (typeof payload.detail.message === 'string') {
return payload.detail.message;
}
if (typeof payload.detail.error === 'string') {
return payload.detail.error;
}
}
if (typeof payload.message === 'string') {
return payload.message;
}
if (typeof payload.error === 'string') {
return payload.error;
}
return t('subscription_settings.error.generic');
}
function resolveSettingsErrorMessage(error, fallbackKey = 'subscription_settings.error.generic') {
if (!error) {
return t(fallbackKey);
}
if (typeof error === 'string') {
return error;
}
if (typeof error.message === 'string' && error.message.trim()) {
return error.message;
}
if (error.detail) {
if (typeof error.detail === 'string' && error.detail.trim()) {
return error.detail;
}
if (typeof error.detail.message === 'string' && error.detail.message.trim()) {
return error.detail.message;
}
}
if (error.status === 401) {
return t('subscription_settings.error.unauthorized');
}
return t(fallbackKey);
}
function setSubscriptionSettingsActionLoading(section, isLoading) {
const ids = {
servers: 'subscriptionSettingsServersApply',
traffic: 'subscriptionSettingsTrafficApply',
devices: 'subscriptionSettingsDevicesApply',
};
const buttonId = ids[section];
if (!buttonId) {
return;
}
const button = document.getElementById(buttonId);
if (!button) {
return;
}
if (isLoading) {
if (!button.dataset.originalLabel) {
button.dataset.originalLabel = button.textContent;
}
button.textContent = t('subscription_settings.pending_action');
button.disabled = true;
return;
}
const original = button.dataset.originalLabel;
const key = button.getAttribute('data-i18n');
if (key) {
const translated = t(key);
if (translated && translated !== key) {
button.textContent = translated;
} else if (original) {
button.textContent = original;
}
} else if (original) {
button.textContent = original;
}
delete button.dataset.originalLabel;
button.disabled = false;
}
function handleSubscriptionSettingsError(error) {
const message = resolveSettingsErrorMessage(error);
showPopup(message, t('subscription_settings.title') || 'Subscription settings');
}
function ensureSubscriptionSettingsLoaded(options = {}) {
const { force = false } = options;
if (!hasPaidSubscription()) {
subscriptionSettingsData = null;
subscriptionSettingsPromise = null;
subscriptionSettingsError = null;
subscriptionSettingsLoading = false;
resetSubscriptionSettingsSelections(null);
return Promise.resolve(null);
}
if (!force) {
if (subscriptionSettingsPromise) {
return subscriptionSettingsPromise;
}
if (subscriptionSettingsData && !subscriptionSettingsError) {
return Promise.resolve(subscriptionSettingsData);
}
}
const initData = tg.initData || '';
if (!initData) {
const error = createError('Authorization Error', t('subscription_settings.error.unauthorized'));
subscriptionSettingsError = error;
subscriptionSettingsLoading = false;
renderSubscriptionSettingsCard();
return Promise.reject(error);
}
subscriptionSettingsLoading = true;
subscriptionSettingsError = null;
renderSubscriptionSettingsCard();
const payload = {
initData,
subscription_id: userData?.subscription_id ?? userData?.subscriptionId ?? null,
};
const request = fetch('/miniapp/subscription/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async response => {
const body = await parseJsonSafe(response);
if (!response.ok || (body && body.success === false)) {
const message = extractSettingsError(body, response.status);
throw createError('Subscription settings error', message, response.status);
}
const normalized = normalizeSubscriptionSettings(body);
subscriptionSettingsData = normalized;
subscriptionSettingsError = null;
subscriptionSettingsLoading = false;
subscriptionSettingsPromise = null;
resetSubscriptionSettingsSelections(normalized);
renderSubscriptionSettingsCard();
return normalized;
}).catch(error => {
subscriptionSettingsError = error;
subscriptionSettingsLoading = false;
subscriptionSettingsPromise = null;
renderSubscriptionSettingsCard();
throw error;
});
subscriptionSettingsPromise = request;
return request;
}
function renderSubscriptionSettingsSummary(data) {
const summary = document.getElementById('subscriptionSettingsSummary');
if (!summary) {
return;
}
summary.innerHTML = '';
if (!data) {
return;
}
const fragments = [];
const serversCount = data.current?.servers?.length
?? ensureArray(userData?.connected_servers).length
?? 0;
if (serversCount) {
const key = serversCount === 1
? 'subscription_settings.summary.servers_one'
: 'subscription_settings.summary.servers';
const chip = document.createElement('div');
chip.className = 'subscription-settings-chip';
chip.textContent = t(key).replace('{count}', String(serversCount));
fragments.push(chip);
}
const trafficLabel = data.current?.trafficLabel || userData?.user?.traffic_limit_label || '';
if (trafficLabel) {
const chip = document.createElement('div');
chip.className = 'subscription-settings-chip';
chip.textContent = t('subscription_settings.summary.traffic').replace('{amount}', trafficLabel);
fragments.push(chip);
}
const deviceLimit = data.current?.deviceLimit ?? coercePositiveInt(userData?.user?.device_limit, null);
if (deviceLimit !== null && deviceLimit !== undefined) {
const chip = document.createElement('div');
chip.className = 'subscription-settings-chip';
if (deviceLimit > 0) {
const key = deviceLimit === 1
? 'subscription_settings.summary.devices_one'
: 'subscription_settings.summary.devices';
chip.textContent = t(key).replace('{count}', String(deviceLimit));
} else {
chip.textContent = t('subscription_settings.summary.devices').replace(
'{count}',
t('subscription_settings.summary.unlimited')
);
}
fragments.push(chip);
}
fragments.forEach(fragment => summary.appendChild(fragment));
}
function renderSubscriptionSettingsCard() {
const card = document.getElementById('subscriptionSettingsCard');
if (!card) {
return;
}
const shouldShow = hasPaidSubscription();
card.classList.toggle('hidden', !shouldShow);
if (!shouldShow) {
return;
}
const loadingBlock = document.getElementById('subscriptionSettingsLoading');
const errorBlock = document.getElementById('subscriptionSettingsError');
const contentBlock = document.getElementById('subscriptionSettingsContent');
const statusText = document.getElementById('subscriptionSettingsStatus');
if (subscriptionSettingsLoading) {
loadingBlock?.classList.remove('hidden');
errorBlock?.classList.add('hidden');
contentBlock?.classList.remove('hidden');
if (statusText) {
statusText.textContent = t('subscription_settings.status.loading');
statusText.classList.remove('hidden');
}
renderSubscriptionSettingsSummary(subscriptionSettingsData);
return;
}
loadingBlock?.classList.add('hidden');
statusText?.classList.add('hidden');
if (subscriptionSettingsError) {
contentBlock?.classList.add('hidden');
if (errorBlock) {
const errorText = document.getElementById('subscriptionSettingsErrorText');
if (errorText) {
errorText.textContent = resolveSettingsErrorMessage(subscriptionSettingsError);
}
errorBlock.classList.remove('hidden');
}
return;
}
errorBlock?.classList.add('hidden');
contentBlock?.classList.remove('hidden');
if (!subscriptionSettingsData) {
ensureSubscriptionSettingsLoaded().catch(error => {
console.warn('Failed to load subscription settings:', error);
});
renderSubscriptionSettingsSummary(null);
return;
}
renderSubscriptionSettingsSummary(subscriptionSettingsData);
renderSubscriptionSettingsServers(subscriptionSettingsData);
renderSubscriptionSettingsTraffic(subscriptionSettingsData);
renderSubscriptionSettingsDevices(subscriptionSettingsData);
}
function setupSubscriptionSettingsEvents() {
document.getElementById('subscriptionSettingsRetry')?.addEventListener('click', () => {
ensureSubscriptionSettingsLoaded({ force: true }).catch(error => {
handleSubscriptionSettingsError(error);
});
});
document.getElementById('subscriptionSettingsServersApply')?.addEventListener('click', submitSubscriptionServersChange);
document.getElementById('subscriptionSettingsTrafficApply')?.addEventListener('click', submitSubscriptionTrafficChange);
document.getElementById('subscriptionSettingsDevicesApply')?.addEventListener('click', submitSubscriptionDevicesChange);
}
function resolveServerPriceLabel(option, currency) {
if (!option) {
return '';
}
if (option.priceLabel) {
return option.priceLabel;
}
if (option.priceKopeks === null || option.priceKopeks === undefined) {
return '';
}
const price = coercePositiveInt(option.priceKopeks, null);
if (price === null) {
return '';
}
if (price === 0) {
return t('subscription_settings.price.included');
}
return formatPriceFromKopeks(price, currency);
}
function renderSubscriptionSettingsServers(data) {
const list = document.getElementById('subscriptionSettingsServersList');
const emptyState = document.getElementById('subscriptionSettingsServersEmpty');
const disabledNote = document.getElementById('subscriptionSettingsServersDisabled');
const applyButton = document.getElementById('subscriptionSettingsServersApply');
const hintElement = document.getElementById('subscriptionSettingsServersHint');
const metaElement = document.getElementById('subscriptionSettingsServersMeta');
if (!list || !applyButton) {
return;
}
const available = ensureArray(data?.servers?.available);
const minSelectable = coercePositiveInt(data?.servers?.min, 0) || 0;
const maxSelectable = coercePositiveInt(data?.servers?.max, 0) || 0;
const canUpdate = data?.servers?.canUpdate !== false;
const hint = data?.servers?.hint || t('subscription_settings.servers.hint');
const selectionSet = subscriptionSettingsSelections.servers instanceof Set
? new Set(subscriptionSettingsSelections.servers)
: new Set();
const currentSet = data.current?.serverSet instanceof Set
? data.current.serverSet
: new Set();
list.innerHTML = '';
if (metaElement) {
const selectedCount = selectionSet.size || currentSet.size;
metaElement.textContent = t('subscription_settings.servers.limit').replace('{count}', String(selectedCount));
}
if (hintElement) {
hintElement.textContent = hint || '';
}
if (!available.length) {
emptyState?.classList.remove('hidden');
disabledNote?.classList.toggle('hidden', canUpdate);
applyButton.disabled = true;
return;
}
emptyState?.classList.add('hidden');
disabledNote?.classList.toggle('hidden', canUpdate);
available.forEach(option => {
if (!option) {
return;
}
const isSelected = selectionSet.has(option.uuid) || (!selectionSet.size && option.isConnected);
if (isSelected && option.uuid && !selectionSet.has(option.uuid)) {
selectionSet.add(option.uuid);
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'subscription-settings-toggle';
if (isSelected) {
button.classList.add('active');
}
const isOptionEnabled = canUpdate && coerceBoolean(option.isAvailable, true);
if (!isOptionEnabled) {
button.classList.add('disabled');
}
const label = document.createElement('div');
label.className = 'subscription-settings-toggle-label';
const title = document.createElement('div');
title.className = 'subscription-settings-toggle-title';
title.textContent = option.name || option.uuid || t('values.not_available');
label.appendChild(title);
const meta = resolveServerPriceLabel(option, data.currency);
if (meta) {
const metaSpan = document.createElement('div');
metaSpan.className = 'subscription-settings-toggle-meta';
metaSpan.textContent = meta;
label.appendChild(metaSpan);
}
button.appendChild(label);
if (isOptionEnabled && option.uuid) {
button.addEventListener('click', () => {
if (subscriptionSettingsAction === 'servers') {
return;
}
if (selectionSet.has(option.uuid)) {
selectionSet.delete(option.uuid);
} else {
selectionSet.add(option.uuid);
}
subscriptionSettingsSelections.servers = new Set(selectionSet);
renderSubscriptionSettingsServers(data);
});
}
list.appendChild(button);
});
subscriptionSettingsSelections.servers = new Set(selectionSet);
const selectedCount = selectionSet.size;
const belowMin = selectedCount < (minSelectable || 1);
const aboveMax = maxSelectable && selectedCount > maxSelectable;
const hasChanges = !isSameSet(selectionSet, currentSet);
const isLoading = subscriptionSettingsAction === 'servers';
if (hintElement && (belowMin || aboveMax)) {
hintElement.textContent = t('subscription_settings.error.validation');
}
applyButton.disabled = isLoading
|| !canUpdate
|| !hasChanges
|| selectedCount === 0
|| belowMin
|| aboveMax;
}
function renderSubscriptionSettingsTraffic(data) {
const list = document.getElementById('subscriptionSettingsTrafficList');
const emptyState = document.getElementById('subscriptionSettingsTrafficEmpty');
const disabledNote = document.getElementById('subscriptionSettingsTrafficDisabled');
const applyButton = document.getElementById('subscriptionSettingsTrafficApply');
const hintElement = document.getElementById('subscriptionSettingsTrafficHint');
const metaElement = document.getElementById('subscriptionSettingsTrafficMeta');
if (!list || !applyButton) {
return;
}
const options = ensureArray(data?.traffic?.options);
const canUpdate = data?.traffic?.canUpdate !== false;
const currentValue = data?.traffic?.currentValue;
const selectedValue = subscriptionSettingsSelections.traffic !== null
? subscriptionSettingsSelections.traffic
: currentValue;
list.innerHTML = '';
if (metaElement) {
const currentLabel = data.current?.trafficLabel || '';
metaElement.textContent = currentLabel
? t('subscription_settings.traffic.current').replace('{limit}', currentLabel)
: '';
}
if (!options.length) {
emptyState?.classList.remove('hidden');
disabledNote?.classList.toggle('hidden', canUpdate);
applyButton.disabled = true;
if (hintElement) {
hintElement.textContent = '';
}
return;
}
emptyState?.classList.add('hidden');
disabledNote?.classList.toggle('hidden', canUpdate);
options.forEach(option => {
if (!option) {
return;
}
const value = option.value;
const label = option.label || (value !== null ? formatTrafficLimit(value) : t('values.not_available'));
const button = document.createElement('button');
button.type = 'button';
button.className = 'subscription-settings-toggle';
if (value === selectedValue) {
button.classList.add('active');
}
const isOptionEnabled = canUpdate && coerceBoolean(option.isAvailable, true);
if (!isOptionEnabled) {
button.classList.add('disabled');
}
const labelContainer = document.createElement('div');
labelContainer.className = 'subscription-settings-toggle-label';
const title = document.createElement('div');
title.className = 'subscription-settings-toggle-title';
title.textContent = label;
labelContainer.appendChild(title);
const meta = option.priceLabel
|| (option.priceKopeks !== null && option.priceKopeks !== undefined
? resolveServerPriceLabel(option, data.currency)
: '');
if (meta) {
const metaSpan = document.createElement('div');
metaSpan.className = 'subscription-settings-toggle-meta';
metaSpan.textContent = meta;
labelContainer.appendChild(metaSpan);
}
button.appendChild(labelContainer);
if (isOptionEnabled && value !== null) {
button.addEventListener('click', () => {
if (subscriptionSettingsAction === 'traffic') {
return;
}
subscriptionSettingsSelections.traffic = value;
renderSubscriptionSettingsTraffic(data);
});
}
list.appendChild(button);
});
if (hintElement) {
hintElement.textContent = '';
}
const isLoading = subscriptionSettingsAction === 'traffic';
const changed = selectedValue !== null && selectedValue !== currentValue;
applyButton.disabled = isLoading || !canUpdate || !changed;
}
function resolveDevicePriceLabel(value, devicesInfo, currency) {
const options = ensureArray(devicesInfo?.options);
const matched = options.find(option => coercePositiveInt(option.value, null) === value);
if (matched) {
if (matched.priceLabel) {
return matched.priceLabel;
}
if (matched.priceKopeks !== null && matched.priceKopeks !== undefined) {
const normalized = coercePositiveInt(matched.priceKopeks, null);
if (normalized !== null) {
if (normalized === 0) {
return t('subscription_settings.price.included');
}
return formatPriceFromKopeks(normalized, currency);
}
}
}
const basePrice = coercePositiveInt(
devicesInfo?.priceKopeks ?? devicesInfo?.price_kopeks ?? devicesInfo?.price ?? null,
null
);
if (basePrice !== null) {
if (basePrice === 0) {
return t('subscription_settings.price.included');
}
return formatPriceFromKopeks(basePrice, currency);
}
return '';
}
function renderSubscriptionSettingsDevices(data) {
const decreaseBtn = document.getElementById('subscriptionSettingsDevicesDecrease');
const increaseBtn = document.getElementById('subscriptionSettingsDevicesIncrease');
const valueElement = document.getElementById('subscriptionSettingsDevicesValue');
const priceElement = document.getElementById('subscriptionSettingsDevicesPrice');
const disabledNote = document.getElementById('subscriptionSettingsDevicesDisabled');
const applyButton = document.getElementById('subscriptionSettingsDevicesApply');
const hintElement = document.getElementById('subscriptionSettingsDevicesHint');
if (!decreaseBtn || !increaseBtn || !valueElement || !applyButton) {
return;
}
const devicesInfo = data?.devices || {};
const canUpdate = devicesInfo.canUpdate !== false;
const min = coercePositiveInt(devicesInfo.min, 0) || 0;
const max = coercePositiveInt(devicesInfo.max, 0) || 0;
const step = coercePositiveInt(devicesInfo.step, 1) || 1;
const current = coercePositiveInt(devicesInfo.current, coercePositiveInt(data?.current?.deviceLimit, 0)) || 0;
if (subscriptionSettingsSelections.devices == null) {
subscriptionSettingsSelections.devices = current;
}
const selected = coercePositiveInt(subscriptionSettingsSelections.devices, current);
subscriptionSettingsSelections.devices = selected;
const unlimited = selected <= 0;
valueElement.textContent = unlimited
? t('subscription_settings.devices.unlimited')
: t(selected === 1 ? 'subscription_settings.devices.value_one' : 'subscription_settings.devices.value').replace('{count}', String(selected));
if (priceElement) {
priceElement.textContent = resolveDevicePriceLabel(selected, devicesInfo, data.currency);
}
disabledNote?.classList.toggle('hidden', canUpdate);
const isLoading = subscriptionSettingsAction === 'devices';
const canDecrease = canUpdate && !isLoading && selected > min;
const canIncrease = canUpdate && !isLoading && (!max || selected < max);
decreaseBtn.disabled = !canDecrease;
increaseBtn.disabled = !canIncrease;
decreaseBtn.onclick = () => {
if (!canDecrease) {
return;
}
const next = Math.max(min, selected - step);
subscriptionSettingsSelections.devices = next;
renderSubscriptionSettingsDevices(data);
};
increaseBtn.onclick = () => {
if (!canIncrease) {
return;
}
const limit = max > 0 ? max : selected + step;
const next = Math.min(limit, selected + step);
subscriptionSettingsSelections.devices = next;
renderSubscriptionSettingsDevices(data);
};
const changed = selected !== current;
applyButton.disabled = isLoading || !canUpdate || !changed;
if (hintElement) {
hintElement.textContent = '';
}
}
async function submitSubscriptionServersChange() {
if (subscriptionSettingsAction) {
return;
}
const data = subscriptionSettingsData;
if (!data) {
try {
await ensureSubscriptionSettingsLoaded({ force: true });
} catch (error) {
handleSubscriptionSettingsError(error);
}
return;
}
const selectionSet = subscriptionSettingsSelections.servers instanceof Set
? subscriptionSettingsSelections.servers
: new Set();
const selected = Array.from(selectionSet);
const minSelectable = coercePositiveInt(data.servers?.min, 0) || 0;
const maxSelectable = coercePositiveInt(data.servers?.max, 0) || 0;
const currentSet = data.current?.serverSet instanceof Set ? data.current.serverSet : new Set();
if (!selected.length || selected.length < (minSelectable || 1) || (maxSelectable && selected.length > maxSelectable)) {
handleSubscriptionSettingsError(createError('Validation', t('subscription_settings.error.validation')));
return;
}
if (isSameSet(new Set(selected), currentSet)) {
return;
}
const initData = tg.initData || '';
if (!initData) {
handleSubscriptionSettingsError(createError('Auth', t('subscription_settings.error.unauthorized')));
return;
}
const payload = {
initData,
squads: selected,
servers: selected,
squad_uuids: selected,
server_uuids: selected,
subscription_id: data.subscriptionId || userData?.subscription_id || userData?.subscriptionId || null,
};
subscriptionSettingsAction = 'servers';
setSubscriptionSettingsActionLoading('servers', true);
try {
const response = await fetch('/miniapp/subscription/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await parseJsonSafe(response);
if (!response.ok || (body && body.success === false)) {
const message = extractSettingsError(body, response.status);
throw createError('Subscription settings error', message, response.status);
}
showPopup(t('subscription_settings.success.servers'), t('subscription_settings.title'));
await refreshSubscriptionData({ silent: true });
await ensureSubscriptionSettingsLoaded({ force: true });
} catch (error) {
handleSubscriptionSettingsError(error);
} finally {
subscriptionSettingsAction = null;
setSubscriptionSettingsActionLoading('servers', false);
renderSubscriptionSettingsCard();
}
}
async function submitSubscriptionTrafficChange() {
if (subscriptionSettingsAction) {
return;
}
const data = subscriptionSettingsData;
if (!data) {
try {
await ensureSubscriptionSettingsLoaded({ force: true });
} catch (error) {
handleSubscriptionSettingsError(error);
}
return;
}
const selected = subscriptionSettingsSelections.traffic;
const currentValue = data.traffic?.currentValue;
if (selected === null || selected === undefined) {
handleSubscriptionSettingsError(createError('Validation', t('subscription_settings.error.validation')));
return;
}
if (selected === currentValue) {
return;
}
const initData = tg.initData || '';
if (!initData) {
handleSubscriptionSettingsError(createError('Auth', t('subscription_settings.error.unauthorized')));
return;
}
const payload = {
initData,
traffic: selected,
traffic_gb: selected,
trafficGb: selected,
subscription_id: data.subscriptionId || userData?.subscription_id || userData?.subscriptionId || null,
};
subscriptionSettingsAction = 'traffic';
setSubscriptionSettingsActionLoading('traffic', true);
try {
const response = await fetch('/miniapp/subscription/traffic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await parseJsonSafe(response);
if (!response.ok || (body && body.success === false)) {
const message = extractSettingsError(body, response.status);
throw createError('Subscription settings error', message, response.status);
}
showPopup(t('subscription_settings.success.traffic'), t('subscription_settings.title'));
await refreshSubscriptionData({ silent: true });
await ensureSubscriptionSettingsLoaded({ force: true });
} catch (error) {
handleSubscriptionSettingsError(error);
} finally {
subscriptionSettingsAction = null;
setSubscriptionSettingsActionLoading('traffic', false);
renderSubscriptionSettingsCard();
}
}
async function submitSubscriptionDevicesChange() {
if (subscriptionSettingsAction) {
return;
}
const data = subscriptionSettingsData;
if (!data) {
try {
await ensureSubscriptionSettingsLoaded({ force: true });
} catch (error) {
handleSubscriptionSettingsError(error);
}
return;
}
const selected = coercePositiveInt(subscriptionSettingsSelections.devices, null);
const current = coercePositiveInt(data.devices?.current, coercePositiveInt(data.current?.deviceLimit, 0)) || 0;
if (selected === null) {
handleSubscriptionSettingsError(createError('Validation', t('subscription_settings.error.validation')));
return;
}
if (selected === current) {
return;
}
const min = coercePositiveInt(data.devices?.min, 0) || 0;
const max = coercePositiveInt(data.devices?.max, 0) || 0;
if (selected < min || (max && selected > max)) {
handleSubscriptionSettingsError(createError('Validation', t('subscription_settings.error.validation')));
return;
}
const initData = tg.initData || '';
if (!initData) {
handleSubscriptionSettingsError(createError('Auth', t('subscription_settings.error.unauthorized')));
return;
}
const payload = {
initData,
devices: selected,
device_limit: selected,
deviceLimit: selected,
subscription_id: data.subscriptionId || userData?.subscription_id || userData?.subscriptionId || null,
};
subscriptionSettingsAction = 'devices';
setSubscriptionSettingsActionLoading('devices', true);
try {
const response = await fetch('/miniapp/subscription/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await parseJsonSafe(response);
if (!response.ok || (body && body.success === false)) {
const message = extractSettingsError(body, response.status);
throw createError('Subscription settings error', message, response.status);
}
showPopup(t('subscription_settings.success.devices'), t('subscription_settings.title'));
await refreshSubscriptionData({ silent: true });
await ensureSubscriptionSettingsLoaded({ force: true });
} catch (error) {
handleSubscriptionSettingsError(error);
} finally {
subscriptionSettingsAction = null;
setSubscriptionSettingsActionLoading('devices', false);
renderSubscriptionSettingsCard();
}
}
function getCurrentSubscriptionUrl() {
return userData?.subscription_url || userData?.subscriptionUrl || '';
}
function getHappCryptoLink() {
if (!userData) {
return null;
}
return (
userData.happ_crypto_link ||
userData.subscriptionCryptoLink ||
userData.subscription_crypto_link ||
null
);
}
function getConnectLink() {
if (!userData) {
return null;
}
const happCryptoLink = getHappCryptoLink();
const isPC = currentPlatform === 'pc' || (!['ios', 'android', 'tv'].includes(currentPlatform));
// For PC, prefer direct subscription URL if no Happ redirect link
if (isPC) {
if (userData.happ_cryptolink_redirect_link) {
return userData.happ_cryptolink_redirect_link;
}
if (happCryptoLink) {
return happCryptoLink;
}
// Return plain subscription URL for PC
return getCurrentSubscriptionUrl();
}
// Original logic for mobile platforms
if (happCryptoLink) {
return happCryptoLink;
}
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 openExternalLink(link, options = {}) {
if (!link) {
return;
}
const { openInMiniApp = false } = options;
// Detect if we're on PC and trying to open a happ:// link
const isPC = currentPlatform === 'pc' || (!['ios', 'android', 'tv'].includes(currentPlatform));
const isHappLink = link.startsWith('happ://');
if (isPC && isHappLink) {
// For PC users with happ:// links, provide fallback options
handlePCHappLink(link);
return;
}
if (openInMiniApp) {
window.location.href = link;
return;
}
if (typeof tg.openLink === 'function') {
try {
tg.openLink(link, { try_instant_view: false });
return;
} catch (error) {
console.warn('tg.openLink failed:', error);
}
}
const newWindow = window.open(link, '_blank', 'noopener,noreferrer');
if (newWindow) {
newWindow.opener = null;
return;
}
window.location.href = link;
}
function handlePCHappLink(happLink) {
// Extract the subscription URL from the happ:// link
const subscriptionUrl = happLink.replace('happ://', '');
// Try to open the happ:// link first (in case user has the app installed)
const testWindow = window.open(happLink, '_blank');
// Set a timeout to check if the link opened successfully
setTimeout(() => {
// If the window is still open and accessible, the app likely isn't installed
if (testWindow && !testWindow.closed) {
testWindow.close();
// Show options to the user
if (typeof tg.showPopup === 'function') {
tg.showPopup({
title: t('pc.happ.title') || 'Open Subscription',
message: t('pc.happ.message') || 'The Happ application doesn\'t appear to be installed. Would you like to:',
buttons: [
{
id: 'copy',
type: 'default',
text: t('pc.happ.copy') || 'Copy subscription link'
},
{
id: 'redirect',
type: 'default',
text: t('pc.happ.redirect') || 'Open in browser'
},
{
type: 'cancel'
}
]
}, (buttonId) => {
if (buttonId === 'copy') {
copySubscriptionUrl(subscriptionUrl);
} else if (buttonId === 'redirect') {
openWithRedirect(subscriptionUrl);
}
});
} else {
// Fallback for environments without showPopup
const choice = confirm(
'The Happ application doesn\'t appear to be installed.\n\n' +
'Click OK to open the subscription in your browser, or Cancel to copy the link.'
);
if (choice) {
openWithRedirect(subscriptionUrl);
} else {
copySubscriptionUrl(subscriptionUrl);
}
}
}
}, 1500); // Wait 1.5 seconds to see if the app opens
}
// Add function to open with redirect parameter
function openWithRedirect(subscriptionUrl) {
// Create the redirect URL
const currentOrigin = window.location.origin;
const redirectUrl = `${currentOrigin}/index.html?redirect_to=${encodeURIComponent(subscriptionUrl)}`;
// Open in a new tab/window
const newWindow = window.open(redirectUrl, '_blank');
if (newWindow) {
newWindow.opener = null;
} else {
window.location.href = redirectUrl;
}
}
async function copySubscriptionUrl(url) {
if (!url) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(url);
showPopup(
t('notifications.copy.success') || 'Subscription link copied to clipboard.',
t('notifications.copy.title.success') || 'Copied'
);
} else {
// Fallback for non-secure contexts
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showPopup(
t('notifications.copy.success') || 'Subscription link copied to clipboard.',
t('notifications.copy.title.success') || 'Copied'
);
} catch (err) {
throw err;
} finally {
document.body.removeChild(textArea);
}
}
} catch (error) {
console.warn('Clipboard copy failed:', error);
showPopup(
t('notifications.copy.failure') || 'Unable to copy. Please copy manually: ' + url,
t('notifications.copy.title.failure') || 'Copy failed'
);
}
}
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,
purchaseUrl: normalizeUrl(error?.purchaseUrl) || null,
};
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();
openExternalLink(link);
});
const topupButton = document.getElementById('topupBalanceBtn');
if (topupButton) {
topupButton.addEventListener('click', () => {
openTopupModal();
});
}
const topupModal = document.getElementById('topupModal');
if (topupModal) {
topupModal.addEventListener('click', event => {
if (event.target === topupModal) {
closeTopupModal();
}
});
}
window.addEventListener('keydown', event => {
if (event.key === 'Escape') {
const { backdrop } = getTopupElements();
if (backdrop && !backdrop.classList.contains('hidden')) {
closeTopupModal();
}
}
});
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'));
}
});
document.getElementById('referralToggleBtn')?.addEventListener('click', () => {
referralListExpanded = !referralListExpanded;
updateReferralToggleState();
});
document.getElementById('referralCopyBtn')?.addEventListener('click', async () => {
const button = document.getElementById('referralCopyBtn');
const value = button?.dataset.copyValue || '';
if (!button || !value) {
showPopup(
t('referral.copy.unavailable') || 'Copying is unavailable. Please copy the link manually.',
t('notifications.copy.title.failure') || 'Copy failed'
);
return;
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const textArea = document.createElement('textarea');
textArea.value = value;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
showPopup(
t('referral.copy.success') || 'Referral link copied to clipboard.',
t('notifications.copy.title.success') || 'Copied'
);
const copiedLabel = t('referral.link.copied');
button.textContent = copiedLabel === 'referral.link.copied' ? 'Link copied' : copiedLabel;
clearTimeout(referralCopyResetHandle);
referralCopyResetHandle = setTimeout(() => {
const defaultLabel = t('referral.link.copy');
button.textContent = defaultLabel === 'referral.link.copy' ? 'Copy link' : defaultLabel;
}, 2000);
} catch (error) {
console.warn('Clipboard copy failed:', error);
let failureMessage = t('referral.copy.failure');
if (!failureMessage || failureMessage === 'referral.copy.failure') {
const fallback = t('notifications.copy.failure') || 'Unable to copy automatically.';
failureMessage = `${fallback} ${value}`;
} else {
failureMessage = failureMessage.replace('{value}', value);
}
showPopup(
failureMessage,
t('notifications.copy.title.failure') || 'Copy failed'
);
}
});
document.getElementById('purchaseBtn')?.addEventListener('click', () => {
const link = getEffectivePurchaseUrl();
if (!link) {
return;
}
openExternalLink(link, { openInMiniApp: true });
});
initializePromoCodeForm();
init();
</script>
</body>
</html>