mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-21 20:01:47 +00:00
10333 lines
396 KiB
HTML
10333 lines
396 KiB
HTML
<!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">×</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>
|