Merge branch 'dev' into feature/new-tariffs
# Conflicts: # .gitignore # bot/app/web/subscription_webapp.py # tests/test_settings.py
This commit is contained in:
@@ -28,6 +28,7 @@ tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ WEBAPP_SERVER_HOST=0.0.0.0 #
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
||||
WEBAPP_LOGO_URL=https://em-content.zobj.net/source/animated-noto-color-emoji/427/dotted-line-face_1fae5.gif # Optional logo URL; shown in the header and login screen, leave empty to hide
|
||||
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
|
||||
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
|
||||
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
|
||||
@@ -12,6 +12,7 @@ node_modules/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
tmp
|
||||
.claude
|
||||
@@ -30,3 +31,4 @@ locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
config/tariffs.json
|
||||
docker-compose-dev.yml
|
||||
|
||||
@@ -16,6 +16,7 @@ COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY bot/app/web/frontend ./bot/app/web/frontend
|
||||
COPY bot/app/web/templates ./bot/app/web/templates
|
||||
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
|
||||
|
||||
@@ -36,9 +37,12 @@ COPY . .
|
||||
|
||||
# Replace template assets with freshly built ones
|
||||
RUN rm -f bot/app/web/templates/subscription_webapp.css \
|
||||
bot/app/web/templates/subscription_webapp.js \
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
|
||||
bot/app/web/templates/
|
||||
|
||||
|
||||
@@ -303,19 +303,6 @@ Web App запускается в том же контейнере, что и б
|
||||
|
||||
Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
|
||||
|
||||
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
|
||||
|
||||
Для настройки внешнего вида без запуска бота можно открыть файл `bot/app/web/templates/subscription_webapp.html` напрямую в браузере. Рядом с ним лежат `telegram-web-app.js`, `telegram-widget.js`, `subscription_webapp.css` и `subscription_webapp.js`, поэтому локальный предпросмотр работает без сервера и без внешнего CDN. При отдаче страницы через Web App сервер dev-mock автоматически вырезается и не попадает пользователям.
|
||||
|
||||
Локальная копия Telegram Web App SDK хранится в `bot/app/web/templates/telegram-web-app.js`, а локальная копия Telegram Login Widget - в `bot/app/web/templates/telegram-widget.js`. В контейнере обе копии автоматически обновляются при старте Web App и затем раз в 24 часа; если источник временно недоступен, используется уже сохраненная версия. У локального `telegram-widget.js` есть минимальная нормализация, чтобы при загрузке с вашего домена виджет все равно открывал iframe на Telegram-origin, а не на `/embed/...` вашего сайта. Для ручного обновления можно запустить команды:
|
||||
|
||||
```bash
|
||||
python scripts/update_telegram_web_app_js.py
|
||||
python scripts/update_telegram_widget_js.py
|
||||
```
|
||||
|
||||
При необходимости оба скрипта принимают `--source-url` и `--target`, если нужно скачать файл в другое место или проверить альтернативный источник.
|
||||
|
||||
## Подробная инструкция для развертывания на сервере с панелью Remnawave
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
<script>
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Copy,
|
||||
Crown,
|
||||
Database,
|
||||
Download,
|
||||
Gift,
|
||||
Globe2,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Repeat2,
|
||||
Send,
|
||||
Ticket,
|
||||
UserRound,
|
||||
Zap,
|
||||
} from "lucide-svelte";
|
||||
|
||||
import Button from "./lib/components/ui/button.svelte";
|
||||
import Card from "./lib/components/ui/card.svelte";
|
||||
import BackTitle from "./preview/BackTitle.svelte";
|
||||
import PhoneFrame from "./preview/PhoneFrame.svelte";
|
||||
import PreviewMethods from "./preview/PreviewMethods.svelte";
|
||||
import PreviewNav from "./preview/PreviewNav.svelte";
|
||||
|
||||
export let config = {};
|
||||
export let mockData = {};
|
||||
|
||||
const title = config.title || "/minishop";
|
||||
const logoEmoji = config.logoEmoji || "🫥";
|
||||
const plans = mockData.plans || [];
|
||||
const sub = mockData.subscription || {};
|
||||
const methods = mockData.payment_methods || [];
|
||||
const user = mockData.user || {};
|
||||
const tariffs = [
|
||||
["subscription", "Подписка", "Безлимитный трафик", "Идеально для постоянного использования", Zap],
|
||||
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
|
||||
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
|
||||
];
|
||||
const traffic = [
|
||||
[20, 290],
|
||||
[50, 590],
|
||||
[100, 990],
|
||||
[300, 2190],
|
||||
];
|
||||
const settingsRows = [
|
||||
[Globe2, "Язык интерфейса", "Русский"],
|
||||
[Send, "Привязка Telegram", user.telegram_linked ? `@${user.username || "username"}` : "Не привязан"],
|
||||
[Mail, "Привязка почты", user.email || "Не привязана"],
|
||||
[UserRound, "Выйти", "Завершить сессию"],
|
||||
];
|
||||
const previewTelegramName = user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
|
||||
const previewEmail = user.email || "Почта не привязана";
|
||||
const previewTelegramId = user.telegram_id ? `TG ID ${user.telegram_id}` : "TG ID не привязан";
|
||||
const previewAvatar = user.telegram_photo_url || "";
|
||||
|
||||
function money(value) {
|
||||
return `${value} ₽`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="preview-board" style={`--accent: ${config.primaryColor || "#00fe7a"};`}>
|
||||
<PhoneFrame number="1" label="Главный экран">
|
||||
<main class="home-layout">
|
||||
<div class="login-brand home-brand">
|
||||
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<div class="home-bottom">
|
||||
<Card class="status-card">
|
||||
<div class="sub-status">
|
||||
<CheckCircle2 size={23} />
|
||||
<div><h2>Подписка активна</h2><p>до {sub.end_date_text}</p></div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="traffic-top"><span>Использовано трафика</span><strong>{sub.traffic_used} из {sub.traffic_limit}</strong></div>
|
||||
<div class="progress"><span style="width: 18%"></span></div>
|
||||
<div class="traffic-percent">18%</div>
|
||||
</Card>
|
||||
<div class="action-stack">
|
||||
<Button class="wide"><Download size={17} />Установить и настроить</Button>
|
||||
<Button variant="secondary" class="wide"><RefreshCw size={17} />Продлить</Button>
|
||||
<Button variant="secondary" class="wide"><Repeat2 size={17} />Сменить тариф</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewNav active="home" />
|
||||
</main>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="2" label="Выбор тарифа">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<div class="tariff-list">
|
||||
{#each tariffs as tariff, index}
|
||||
<div class:active={index === 0} class="select-card">
|
||||
<span class="select-icon"><svelte:component this={tariff[4]} size={24} /></span>
|
||||
<span><strong>{tariff[1]}</strong><small>{tariff[2]}</small><em>{tariff[3]}</em></span>
|
||||
{#if index === 0}<CheckCircle2 size={21} />{:else}<Circle size={21} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="3" label="Оплата тарифа — подписка" wide>
|
||||
<BackTitle title="Подписка" subtitle="Выберите срок подписки" />
|
||||
<div class="period-grid">
|
||||
{#each plans as plan, index}
|
||||
<div class:active={index === 1} class="period-card">
|
||||
<strong>{plan.title}</strong><span>{money(plan.price)}</span><small>{money(Math.round(plan.price / plan.months))}/мес</small>
|
||||
{#if index === 1}<CheckCircle2 size={18} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>790 ₽</strong></Card>
|
||||
<PreviewMethods {methods} />
|
||||
<Button class="wide bottom-action">Оплатить 790 ₽ <LockKeyhole size={16} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="4" label="Оплата тарифа — трафик" wide>
|
||||
<BackTitle title="Трафик" subtitle="Выберите пакет трафика" />
|
||||
<div class="period-grid">
|
||||
{#each traffic as pack, index}
|
||||
<div class:active={index === 2} class="period-card">
|
||||
<strong>{pack[0]} ГБ</strong><span>{money(pack[1])}</span><small>{money(Math.round(pack[1] / pack[0]))}/ГБ</small>
|
||||
{#if index === 2}<CheckCircle2 size={18} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>990 ₽</strong></Card>
|
||||
<PreviewMethods {methods} />
|
||||
<Button class="wide bottom-action">Оплатить 990 ₽ <LockKeyhole size={16} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="5" label="Смена тарифа">
|
||||
<BackTitle title="Смена тарифа" subtitle="Остаток 12 дней будет пересчитан" />
|
||||
<div class="tariff-list compact">
|
||||
<div class="select-card"><span><strong>Подписка</strong><small>Безлимитный трафик</small></span><em>Доплата 190 ₽</em><Circle size={20} /></div>
|
||||
<div class="select-card active"><span><strong>Трафик</strong><small>Пакеты гигабайт</small></span><em>Доплата не требуется</em><CheckCircle2 size={20} /></div>
|
||||
<div class="select-card"><span><strong>Премиум</strong><small>Максимальная скорость</small></span><em>Доплата 390 ₽</em><Circle size={20} /></div>
|
||||
</div>
|
||||
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
|
||||
<div class="preview-modal">
|
||||
<Repeat2 size={30} />
|
||||
<strong>Сменить тариф без доплаты?</strong>
|
||||
<p>Остаток 12 дней будет пересчитан по новому тарифу.</p>
|
||||
<Button>Да, сменить</Button>
|
||||
<Button variant="secondary">Отмена</Button>
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="6" label="Пригласить друга">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<Card>
|
||||
<div class="card-label">Ваша реферальная ссылка</div>
|
||||
<div class="copy-row"><code>https://minishop.app/ref/ABCD1234</code><Button>Копировать <Copy size={16} /></Button></div>
|
||||
</Card>
|
||||
<Card class="bonus-card">
|
||||
<Gift size={42} /><div><span>Ваш бонус</span><strong>+7 дней за каждого друга</strong><p>Друг получит +3 дня к подписке.</p></div>
|
||||
</Card>
|
||||
<Button variant="outline" class="wide"><Ticket size={18} />Активировать промокод</Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="7" label="Настройки">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<Card class="settings-profile">
|
||||
<div class="settings-avatar">
|
||||
{#if previewAvatar}
|
||||
<img src={previewAvatar} alt="Аватар пользователя" />
|
||||
{:else}
|
||||
<UserRound size={27} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="settings-profile-meta">
|
||||
<strong>{previewTelegramName}</strong>
|
||||
<small>{previewEmail}</small>
|
||||
<small>{previewTelegramId}</small>
|
||||
</div>
|
||||
</Card> <div class="settings-list">
|
||||
{#each settingsRows as row}
|
||||
<div class="settings-row">
|
||||
<svelte:component this={row[0]} size={20} />
|
||||
<span><strong>{row[1]}</strong><small>{row[2]}</small></span>
|
||||
<ArrowRight size={16} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="8" label="Логин" wide>
|
||||
<div class="login-brand small">
|
||||
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
|
||||
<h1>{title}</h1><p>Войдите в свой аккаунт</p>
|
||||
</div>
|
||||
<Card class="auth-card">
|
||||
<div class="field-label">Вход по email</div>
|
||||
<div class="auth-email-stack"><div class="input muted">Email</div><Button class="wide"><Mail size={17} />Войти по почте</Button></div>
|
||||
<div class="or-line"><span></span>или<span></span></div>
|
||||
<Button variant="telegram" class="wide telegram-login-button">
|
||||
<span class="telegram-login-text"><Send size={17} />Войти через телеграм</span>
|
||||
</Button>
|
||||
</Card>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="9" label="Подтверждение по коду" wide>
|
||||
<BackTitle title="Подтверждение по email" subtitle="Мы отправили код на user@example.com" />
|
||||
<div class="otp-slots static">{#each [1, 2, 3, 4, 5, 6] as digit}<span>{digit}</span>{/each}</div>
|
||||
<Button class="wide bottom-action">Подтвердить</Button>
|
||||
<button class="link-button"><RefreshCw size={15} />Отправить код повторно (00:45)</button>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let type = "button";
|
||||
export let variant = "default";
|
||||
export let size = "default";
|
||||
export let disabled = false;
|
||||
export let href = "";
|
||||
export let onclick = undefined;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const variants = {
|
||||
default: "btn btn-primary",
|
||||
secondary: "btn btn-secondary",
|
||||
outline: "btn btn-outline",
|
||||
ghost: "btn btn-ghost",
|
||||
telegram: "btn btn-telegram",
|
||||
icon: "btn btn-icon",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
default: "",
|
||||
sm: "btn-sm",
|
||||
lg: "btn-lg",
|
||||
icon: "btn-square",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a class={cn(variants[variant], sizes[size], className)} {href} on:click={onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} on:click={onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let active = false;
|
||||
export let compact = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
|
||||
<slot />
|
||||
</section>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { X } from "lucide-svelte";
|
||||
import { cn } from "../../utils.js";
|
||||
import Button from "./button.svelte";
|
||||
|
||||
export let open = false;
|
||||
export let title = "";
|
||||
export let description = "";
|
||||
export let closeLabel = "Close";
|
||||
export let onclose = () => {};
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<button class="dialog-backdrop" type="button" aria-label={closeLabel} on:click={onclose}></button>
|
||||
<section class={cn("dialog-card", className)}>
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
{#if title}<h2>{title}</h2>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
</div>
|
||||
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let value = "";
|
||||
export let type = "text";
|
||||
export let placeholder = "";
|
||||
export let inputmode = undefined;
|
||||
export let maxlength = undefined;
|
||||
export let autocomplete = undefined;
|
||||
export let disabled = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:value
|
||||
class={cn("input", className)}
|
||||
on:keydown
|
||||
on:input
|
||||
on:focus
|
||||
on:blur
|
||||
{type}
|
||||
{placeholder}
|
||||
{inputmode}
|
||||
{maxlength}
|
||||
{autocomplete}
|
||||
{disabled}
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mount } from "svelte";
|
||||
|
||||
import App from "./App.svelte";
|
||||
import "./styles.css";
|
||||
|
||||
const target = document.getElementById("app");
|
||||
|
||||
if (target) {
|
||||
mount(App, { target });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { ArrowLeft } from "lucide-svelte";
|
||||
|
||||
export let title = "";
|
||||
export let subtitle = "";
|
||||
</script>
|
||||
|
||||
<header class="screen-head">
|
||||
<button class="btn btn-icon btn-square" type="button" aria-label="Назад">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div class="center-copy">
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
<span></span>
|
||||
</header>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
export let number = "";
|
||||
export let label = "";
|
||||
export let wide = false;
|
||||
</script>
|
||||
|
||||
<section class:wide class="preview-phone-wrap">
|
||||
<h2><span>{number}.</span> {label}</h2>
|
||||
<div class="preview-phone">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import { CreditCard, Send, WalletCards } from "lucide-svelte";
|
||||
|
||||
export let methods = [];
|
||||
|
||||
const icons = [CreditCard, Send, WalletCards, WalletCards];
|
||||
|
||||
function note(index) {
|
||||
if (index === 0) return "Visa, Mastercard";
|
||||
if (index === 1) return "Быстро и удобно";
|
||||
if (index === 2) return "USDT, BTC, ETH";
|
||||
return "ЮMoney, СБП и др.";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="method-grid">
|
||||
{#each methods as method, index}
|
||||
<div class:active={index === 1} class="method-card">
|
||||
<svelte:component this={icons[index] || WalletCards} size={18} />
|
||||
<span>
|
||||
<strong>{method.name}</strong>
|
||||
<small>{note(index)}</small>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import { Gift, Home, Settings } from "lucide-svelte";
|
||||
|
||||
export let active = "home";
|
||||
</script>
|
||||
|
||||
<nav class="bottom-nav static">
|
||||
<button class:active={active === "home"} type="button"><Home size={20} /><span>Главная</span></button>
|
||||
<button class:active={active === "invite"} type="button"><Gift size={20} /><span>Пригласить</span></button>
|
||||
<button class:active={active === "settings"} type="button"><Settings size={20} /><span>Настройки</span></button>
|
||||
</nav>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const templateDir = path.resolve(__dirname, "../templates");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: templateDir,
|
||||
emptyOutDir: false,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
cssCodeSplit: false,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, "src/main.js"),
|
||||
name: "SubscriptionWebApp",
|
||||
formats: ["iife"],
|
||||
fileName: () => "subscription_webapp.js",
|
||||
cssFileName: "subscription_webapp",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
|
||||
return "subscription_webapp.css";
|
||||
}
|
||||
return "subscription_webapp.[name][extname]";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+616
-199
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -5,292 +5,25 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#05070a">
|
||||
<meta name="theme-color" content="#03070b">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>Моя подписка</title>
|
||||
<title>/minishop</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
src: url('https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
<script src="./telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="./subscription_webapp.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loader" class="app-loader" data-i18n="loading">Загрузка...</div>
|
||||
|
||||
<main id="app" class="app hidden">
|
||||
<header class="app-header">
|
||||
<div class="app-header-title">
|
||||
<div class="brand-logo-shell hidden" data-brand-logo-shell aria-hidden="true">
|
||||
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
|
||||
<img id="brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
||||
</div>
|
||||
<div id="brand-title" class="brand-title" data-brand-title>Моя подписка</div>
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="lang-menu">
|
||||
<button id="lang-chip" class="lang-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="lang-dropdown" aria-label="Language" data-action="toggle-lang-menu">
|
||||
<span id="lang-chip-flag" class="lang-chip-flag" aria-hidden="true">🇷🇺</span>
|
||||
<span id="lang-chip-label" class="lang-chip-label">RU</span>
|
||||
<svg class="lang-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="lang-dropdown" class="lang-dropdown hidden" role="menu"></div>
|
||||
</div>
|
||||
<div class="user-menu">
|
||||
<button id="user-chip" class="user-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-dropdown" data-action="toggle-user-menu">
|
||||
<img id="user-chip-avatar" class="user-chip-avatar" alt="" aria-hidden="true">
|
||||
<span id="user-chip-name" class="user-chip-name">...</span>
|
||||
<svg class="user-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="user-dropdown" class="user-dropdown hidden" role="menu">
|
||||
<div class="user-dropdown-head">
|
||||
<img id="user-dropdown-avatar" class="user-dropdown-avatar" alt="" aria-hidden="true">
|
||||
<div class="user-dropdown-head-text">
|
||||
<div id="user-dropdown-name" class="user-dropdown-name">...</div>
|
||||
<div id="user-dropdown-sub" class="user-dropdown-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-dropdown-rows">
|
||||
<div class="user-dropdown-row" role="menuitem">
|
||||
<span class="user-dropdown-row-label" data-i18n="email_label">Email</span>
|
||||
<span id="user-dropdown-email-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
<div class="user-dropdown-row" role="menuitem">
|
||||
<span class="user-dropdown-row-label" data-i18n="telegram_label">Telegram</span>
|
||||
<span id="user-dropdown-telegram-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="user-dropdown-logout" type="button" role="menuitem" data-action="logout">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
|
||||
</svg>
|
||||
<span data-i18n="logout">Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel grid gap-[17px] p-[18px]">
|
||||
<div id="sub-status" class="sub-status">
|
||||
<span class="sub-status-dot" aria-hidden="true"></span>
|
||||
<span id="sub-status-text" class="sub-status-text">...</span>
|
||||
</div>
|
||||
|
||||
<div class="sub-countdown">
|
||||
<div class="sub-countdown-row">
|
||||
<span id="remaining" class="sub-countdown-value">...</span>
|
||||
<span id="end-date-caption" class="sub-countdown-caption"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="traffic-bar" class="traffic-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="traffic-bar-fill" class="traffic-bar-fill"></div>
|
||||
<span id="traffic" class="traffic-bar-value">...</span>
|
||||
</div>
|
||||
|
||||
<div id="connect-actions">
|
||||
<button id="connect-btn" class="btn-primary w-full" type="button" data-action="open-connect-link" data-i18n="connect">Подключиться</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 border-t border-[var(--border)] pt-3.5">
|
||||
<button id="extend-btn" class="btn-ghost w-full" type="button" data-action="toggle-payment-flow" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="link-panel" class="panel hidden grid gap-3.5 p-[17px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="link-panel-title" class="section-title" data-i18n="link_panel_title">Привяжите способ входа</div>
|
||||
<div id="link-panel-caption" class="section-caption" data-i18n="link_panel_caption">Чтобы не терять доступ к кабинету</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="email-link-box" class="hidden grid gap-2">
|
||||
<div class="link-cta-head">
|
||||
<span class="link-cta-title" data-i18n="link_email_title">Привяжите email</span>
|
||||
<span class="link-cta-caption" data-i18n="link_email_caption">Получайте код для входа на почту</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-link-send-btn" class="btn" type="button" data-action="request-email-link-code" data-i18n="send_code">Отправить код</button>
|
||||
</div>
|
||||
<div id="email-link-code-row" class="hidden grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-code-input" class="input input-code w-full" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
|
||||
<button id="email-link-verify-btn" class="btn-primary" type="button" data-action="verify-email-link-code" data-i18n="confirm">Подтвердить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="telegram-link-box" class="hidden grid gap-2">
|
||||
<div class="link-cta-head">
|
||||
<span class="link-cta-title" data-i18n="link_telegram_title">Привяжите Telegram</span>
|
||||
<span class="link-cta-caption" data-i18n="link_telegram_caption">Входите одним нажатием через Telegram</span>
|
||||
</div>
|
||||
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||
<div id="telegram-link-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid min-w-0 grid-cols-2 gap-2">
|
||||
<button class="btn w-full" type="button" data-action="open-referral-modal" data-i18n="referral_title">Пригласить друга</button>
|
||||
<button class="btn w-full" type="button" data-action="open-promo-modal" data-i18n="promo_title">Промокод</button>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
<a id="support-link" class="hidden mt-0.5 btn" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
|
||||
<div id="legal-links-app" data-legal-links class="legal-links hidden">
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="payment-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="payment-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow"></button>
|
||||
<section id="payment-flow" class="panel-modal modal-card grid gap-4 p-4">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="payment-title" class="section-title text-[var(--accent)]" data-i18n="payment_title">Оплата подписки</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5">
|
||||
<div class="section-label" data-i18n="select_period">Выберите период</div>
|
||||
<div id="plans" class="grid grid-cols-2 gap-2 sm:grid-cols-4"></div>
|
||||
</div>
|
||||
|
||||
<div class="notice grid gap-1.5">
|
||||
<div class="section-label" data-i18n="payment_amount_label">Стоимость выбранного периода</div>
|
||||
<div id="selected-plan-price" class="text-[20px] font-extrabold leading-tight text-[var(--text-primary)]">...</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5">
|
||||
<div class="section-label" data-i18n="choose_payment_method">Способ оплаты</div>
|
||||
<div id="payment-methods" class="grid gap-2"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="promo-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="promo-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal"></button>
|
||||
<section id="promo-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="promo-title" class="section-title text-[var(--accent)]" data-i18n="promo_title">Промокод</div>
|
||||
<div class="section-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal">×</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="promo-code-input" class="input input-code w-full" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
|
||||
<button id="promo-apply-btn" class="btn-primary" type="button" data-action="apply-promo-code" data-i18n="apply_promo">Применить</button>
|
||||
</div>
|
||||
<div id="promo-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="referral-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="referral-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-referral-modal"></button>
|
||||
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="account-merge-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="account-merge-title" aria-describedby="account-merge-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-account-merge-modal"></button>
|
||||
<section id="account-merge-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal"></button>
|
||||
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
|
||||
<section class="panel-modal modal-card grid gap-3.5 p-[18px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="email-code-title" class="section-title" data-i18n="email_code_title">Подтвердите вход</div>
|
||||
<div id="email-code-caption" class="section-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.6fr)_minmax(0,1.4fr)]">
|
||||
<div class="metric-label" data-i18n="email_label">Email</div>
|
||||
<div id="email-code-address" class="metric-value">...</div>
|
||||
</div>
|
||||
|
||||
<label class="relative block min-h-14 cursor-text">
|
||||
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
|
||||
<span class="otp-slots" aria-hidden="true">
|
||||
<span id="email-code-slot-0"></span>
|
||||
<span id="email-code-slot-1"></span>
|
||||
<span id="email-code-slot-2"></span>
|
||||
<span id="email-code-slot-3"></span>
|
||||
<span id="email-code-slot-4"></span>
|
||||
<span id="email-code-slot-5"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div id="email-code-status" class="status-text hidden" aria-live="polite"></div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<button id="email-login-verify-btn" class="btn-primary w-full" type="button" data-action="verify-email-login-code" data-i18n="login">Войти</button>
|
||||
</div>
|
||||
</section>
|
||||
<button id="email-login-resend-btn" class="code-modal-resend" type="button" data-action="resend-email-login-code" data-i18n="resend_code">Отправить еще раз</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="login" class="login login-layout gap-5">
|
||||
<div class="grid justify-items-center gap-3 text-center">
|
||||
<div class="brand-logo-shell brand-logo-shell--lg hidden" data-brand-logo-shell aria-hidden="true">
|
||||
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
|
||||
<img id="login-brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
||||
</div>
|
||||
<div id="login-brand-title" class="max-w-[320px] break-words font-[family-name:var(--font-mono)] text-[28px] font-extrabold leading-[1.05] tracking-[0] text-[var(--accent)] max-[460px]:max-w-[240px] max-[460px]:text-[24px]" data-brand-title>Моя подписка</div>
|
||||
</div>
|
||||
<div class="panel-modal w-full grid gap-5 p-6 text-center">
|
||||
<h1 class="section-title text-[22px] leading-[1.1]" data-i18n="login_title">Войдите или зарегистрируйтесь</h1>
|
||||
<div class="grid gap-2.5">
|
||||
<div class="auth-tabs-wrapper" role="tablist">
|
||||
<button id="email-auth-tab" class="auth-tab active" type="button" data-action="set-auth-mode" data-mode="email" data-i18n="email_login_tab">Email</button>
|
||||
<button id="telegram-auth-tab" class="auth-tab" type="button" data-action="set-auth-mode" data-mode="telegram" data-i18n="telegram_login_tab">Telegram</button>
|
||||
</div>
|
||||
<div class="login-auth-body">
|
||||
<div id="email-login-pane" class="grid gap-2.5">
|
||||
<div class="grid gap-2.5">
|
||||
<input id="email-login-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-login-send-btn" class="btn-primary w-full" type="button" data-action="request-email-login-code" data-i18n="login_continue">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="telegram-login-pane" class="hidden grid gap-2.5">
|
||||
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="legal-links-login" data-legal-links class="legal-links hidden">
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
|
||||
<main id="app"></main>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,916 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "./subscription_webapp.html";
|
||||
@source "./subscription_webapp.js";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #05070a;
|
||||
--bg-card: #0f1521;
|
||||
--bg-card-hover: #161e2e;
|
||||
--border: #1e2940;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #00fe7a;
|
||||
--accent-hover: #5aff9f;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 8px;
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--app-safe-top-extra: 10px;
|
||||
--app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top));
|
||||
--app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@apply m-0 min-h-full w-full overflow-x-hidden tracking-[0];
|
||||
}
|
||||
|
||||
body {
|
||||
@apply overflow-x-hidden bg-[var(--bg-primary)] font-[family-name:var(--font-sans)] text-[var(--text-primary)] antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer border-0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@apply cursor-progress opacity-[0.58];
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
.btn-sm {
|
||||
@apply inline-flex min-h-[38px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[11px] py-2 text-center font-extrabold text-[13px] leading-none text-[var(--text-secondary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
.metrics {
|
||||
@apply grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.018)];
|
||||
}
|
||||
.metric {
|
||||
@apply grid min-h-12 grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] items-center gap-2.5 border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2.5;
|
||||
}
|
||||
.auth-tab {
|
||||
@apply min-h-9 rounded-[var(--radius-sm)] bg-transparent font-[850] text-[var(--text-secondary)] transition-colors;
|
||||
}
|
||||
.login-layout {
|
||||
@apply hidden mx-auto min-h-screen w-[min(100vw,560px)] flex-col items-center justify-center gap-4 px-[14px];
|
||||
padding-top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));
|
||||
padding-bottom: max(var(--app-safe-bottom), 18px);
|
||||
}
|
||||
.section-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold text-[var(--text-muted)];
|
||||
}
|
||||
.app-loader {
|
||||
@apply grid min-h-[78vh] place-items-center font-[family-name:var(--font-mono)] font-extrabold text-[var(--text-muted)];
|
||||
}
|
||||
.auth-tabs-wrapper {
|
||||
@apply grid min-h-[46px] grid-cols-2 gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.38)] p-1;
|
||||
}
|
||||
|
||||
|
||||
.panel-head {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
.section-title {
|
||||
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
.section-caption {
|
||||
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
|
||||
}
|
||||
.metric-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
.metric-value {
|
||||
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
|
||||
}
|
||||
.referral-link-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.referral-link-value {
|
||||
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
|
||||
}
|
||||
.bonus-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.empty-state {
|
||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.plan-card {
|
||||
@apply inline-flex min-h-[44px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-center font-extrabold text-[14px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.plan-card-active {
|
||||
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] text-[var(--accent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
|
||||
}
|
||||
.payment-method-card {
|
||||
@apply inline-flex min-h-[52px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-4 py-[12px] text-center font-extrabold text-[15px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.payment-method-card--platega {
|
||||
border-color: rgba(0, 254, 122, 0.36);
|
||||
background: rgba(0, 254, 122, 0.08);
|
||||
}
|
||||
.payment-method-card--cryptopay {
|
||||
border-color: rgba(34, 211, 238, 0.36);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
.plan-name {
|
||||
@apply block text-[15px] font-extrabold leading-tight;
|
||||
}
|
||||
.plan-meta {
|
||||
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
.plan-price {
|
||||
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
|
||||
}
|
||||
.notice {
|
||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.step-num {
|
||||
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
|
||||
}
|
||||
.step-name {
|
||||
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
|
||||
}
|
||||
.main-value {
|
||||
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
|
||||
}
|
||||
.main-caption {
|
||||
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.status-text {
|
||||
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.legal-links {
|
||||
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-center;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@apply flex min-h-12 items-center justify-between gap-3;
|
||||
}
|
||||
.app-header-title {
|
||||
@apply flex min-w-0 items-center gap-2.5;
|
||||
}
|
||||
.app-header-actions {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
.panel-head-sm {
|
||||
@apply flex min-w-0 items-center justify-between gap-2.5;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
.lang-menu {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
.lang-chip {
|
||||
@apply inline-flex min-h-[38px] cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-2 text-left font-extrabold text-[12px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.lang-chip[aria-expanded="true"] {
|
||||
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.lang-chip-flag {
|
||||
@apply text-[14px] leading-none;
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
|
||||
.lang-chip-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] tracking-wide;
|
||||
}
|
||||
|
||||
.lang-chip-caret {
|
||||
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
|
||||
}
|
||||
|
||||
.lang-chip[aria-expanded="true"] .lang-chip-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.lang-dropdown {
|
||||
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[160px] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
|
||||
backdrop-filter: blur(14px);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.lang-dropdown.show {
|
||||
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
|
||||
}
|
||||
|
||||
.lang-dropdown-item {
|
||||
@apply flex cursor-pointer items-center gap-2.5 border-0 bg-transparent px-3 py-2.5 text-left text-[13px] font-bold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.lang-dropdown-item + .lang-dropdown-item {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.lang-dropdown-item.is-active {
|
||||
@apply bg-[var(--bg-card-hover)] text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.lang-dropdown-flag {
|
||||
@apply text-[16px] leading-none;
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
|
||||
.lang-dropdown-label {
|
||||
@apply flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@apply inline-flex min-h-[38px] min-w-0 max-w-[200px] cursor-pointer items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] py-1 pl-1 pr-2.5 text-left font-extrabold text-[13px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.user-chip[aria-expanded="true"] {
|
||||
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.user-chip-avatar {
|
||||
@apply h-[28px] w-[28px] flex-none rounded-[var(--radius-sm)] object-cover;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.user-chip-caret {
|
||||
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
|
||||
}
|
||||
|
||||
.user-chip[aria-expanded="true"] .user-chip-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[min(calc(100vw-28px),280px)] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
|
||||
backdrop-filter: blur(14px);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.user-dropdown.show {
|
||||
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
|
||||
}
|
||||
|
||||
.user-dropdown-head {
|
||||
@apply flex items-center gap-2.5 border-b border-[var(--border)] px-3 py-2.5;
|
||||
}
|
||||
|
||||
.user-dropdown-avatar {
|
||||
@apply h-[40px] w-[40px] flex-none rounded-[var(--radius-md)] object-cover;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.user-dropdown-head-text {
|
||||
@apply grid min-w-0 gap-0.5;
|
||||
}
|
||||
|
||||
.user-dropdown-name {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-sm font-extrabold text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-sub {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap font-[family-name:var(--font-mono)] text-[11px] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-rows {
|
||||
@apply grid;
|
||||
}
|
||||
|
||||
.user-dropdown-row {
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.user-dropdown-row + .user-dropdown-row {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-right text-[13px] font-bold text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status.linked {
|
||||
@apply text-[#6ee7b7];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status.unlinked {
|
||||
@apply text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-logout {
|
||||
@apply flex min-h-[42px] w-full cursor-pointer items-center justify-center gap-2 rounded-none border-0 border-t border-[var(--border)] bg-transparent px-3 py-2.5 text-center text-[13px] font-extrabold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-logout svg {
|
||||
@apply h-4 w-4 fill-current;
|
||||
}
|
||||
|
||||
.sub-status {
|
||||
@apply flex min-h-[28px] items-center gap-2.5 text-[var(--danger)];
|
||||
}
|
||||
|
||||
.sub-status.active {
|
||||
@apply text-[var(--success)];
|
||||
}
|
||||
|
||||
.sub-status-dot {
|
||||
@apply relative inline-block h-[10px] w-[10px] flex-none rounded-full;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 12px currentColor;
|
||||
}
|
||||
|
||||
.sub-status-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
animation: sub-status-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.sub-status-text {
|
||||
@apply font-extrabold text-[15px] leading-tight;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
@keyframes brand-logo-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sub-countdown {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.sub-countdown-row {
|
||||
@apply flex flex-wrap items-baseline gap-x-2 gap-y-0.5;
|
||||
}
|
||||
|
||||
.sub-countdown-value {
|
||||
@apply font-extrabold text-[22px] leading-[1.1] text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.sub-countdown-caption {
|
||||
@apply font-[family-name:var(--font-mono)] text-[12px] leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.traffic-bar {
|
||||
@apply relative flex min-h-[24px] w-full items-center justify-center overflow-hidden rounded-full border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.traffic-bar-fill {
|
||||
@apply absolute inset-y-0 left-0;
|
||||
width: 0%;
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease;
|
||||
}
|
||||
|
||||
.traffic-bar.over .traffic-bar-fill {
|
||||
background: color-mix(in srgb, var(--danger) 32%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar.unlimited .traffic-bar-fill {
|
||||
width: 100%;
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar.inactive {
|
||||
border-color: color-mix(in srgb, var(--danger) 60%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar.inactive .traffic-bar-fill {
|
||||
width: 100%;
|
||||
background: color-mix(in srgb, var(--danger) 55%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar-value {
|
||||
@apply relative z-[1] min-w-0 px-3 font-[family-name:var(--font-mono)] text-[12px] font-bold text-center whitespace-nowrap;
|
||||
color: #ffffff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.traffic-bar.unlimited .traffic-bar-value {
|
||||
animation: traffic-infinity-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes traffic-infinity-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 0 color-mix(in srgb, var(--success) 0%, transparent);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--success) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.link-cta-head {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.link-cta-title {
|
||||
@apply font-extrabold text-[15px] leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.link-cta-caption {
|
||||
@apply text-[13px] leading-[1.4] text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
@keyframes sub-status-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
70% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.login-head {
|
||||
@apply flex min-w-0 items-center justify-center gap-3.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[13px] py-[11px] text-center font-extrabold text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_80%,var(--border))] bg-[var(--accent)] px-[13px] py-[11px] text-center font-extrabold text-[#04110a] no-underline shadow-[0_10px_26px_color-mix(in_srgb,var(--accent)_18%,transparent)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[var(--accent-hover)] hover:shadow-[0_14px_34px_color-mix(in_srgb,var(--accent)_24%,transparent)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex min-h-[46px] min-w-0 w-full cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_35%,var(--border))] bg-[color-mix(in_srgb,var(--accent)_5%,transparent)] px-[13px] py-[11px] text-center font-extrabold text-[var(--accent)] no-underline shadow-none transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:shadow-[0_10px_24px_color-mix(in_srgb,var(--accent)_10%,transparent)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply inline-flex h-[38px] w-[38px] flex-none cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] text-[var(--text-secondary)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply min-h-[46px] w-full rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.42)] px-3 py-[11px] font-bold text-[var(--text-primary)] outline-none transition-[border-color,background,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] placeholder:text-[var(--text-muted)] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:bg-[rgba(7,12,19,0.68)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_12%,transparent),0_12px_28px_rgba(0,0,0,0.2)] aria-[invalid=true]:border-[color-mix(in_srgb,var(--danger)_72%,var(--border))] aria-[invalid=true]:focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--danger)_14%,transparent)];
|
||||
}
|
||||
|
||||
.input-code {
|
||||
@apply text-center font-[family-name:var(--font-mono)] tracking-[0];
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_20px_40px_rgba(0,0,0,0.18)];
|
||||
}
|
||||
|
||||
.panel-modal {
|
||||
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_30px_60px_rgba(0,0,0,0.28)];
|
||||
}
|
||||
|
||||
.step-box {
|
||||
@apply min-h-[58px] rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[9px] text-[var(--text-muted)] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)];
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply fixed inset-0 z-20 grid place-items-center opacity-0 transition-opacity pointer-events-none;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
@apply opacity-100 pointer-events-auto;
|
||||
}
|
||||
|
||||
.modal-backdrop,
|
||||
.tools-modal-backdrop {
|
||||
@apply absolute inset-0 border-0 p-0;
|
||||
background: rgba(3, 6, 10, 0.72);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.tools-modal-backdrop {
|
||||
@apply fixed z-25;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
@apply relative max-h-[min(86vh,760px)] w-[min(100%,560px)] overflow-auto transition-transform duration-[0.22s] ease-[cubic-bezier(0.2,0.8,0.2,1)];
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
|
||||
.modal.show .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
|
||||
.app {
|
||||
@apply mx-auto flex w-[min(100vw,560px)] max-w-[100vw] flex-col gap-4;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);
|
||||
}
|
||||
|
||||
@media (min-width: 561px) {
|
||||
.app {
|
||||
@apply min-h-screen justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
.telegram-login-widget {
|
||||
@apply flex min-w-0 items-center;
|
||||
}
|
||||
|
||||
.brand-logo-shell {
|
||||
@apply relative grid h-[38px] w-[38px] flex-none place-items-center overflow-hidden;
|
||||
}
|
||||
|
||||
.brand-logo-shell--lg {
|
||||
@apply h-[96px] w-[96px];
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
@apply absolute inset-0 h-full w-full object-contain;
|
||||
}
|
||||
|
||||
.brand-logo-spinner {
|
||||
@apply h-5 w-5 flex-none rounded-full border-2;
|
||||
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
|
||||
border-top-color: var(--accent);
|
||||
animation: brand-logo-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
@apply h-[84px] w-[84px];
|
||||
}
|
||||
|
||||
.plans,
|
||||
.methods,
|
||||
.metrics,
|
||||
.referral-link-list,
|
||||
.bonus-list {
|
||||
@apply grid min-w-0;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.otp-slots,
|
||||
.metric {
|
||||
@apply grid min-w-0 gap-2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-template-columns: minmax(0, 1fr) 48px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
@apply gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
|
||||
.metric,
|
||||
.notice,
|
||||
.otp-slots span {
|
||||
@apply border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply min-h-[46px] items-center gap-2.5 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply min-h-12;
|
||||
}
|
||||
|
||||
.metric + .metric {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.brand-title,
|
||||
.login-brand-title {
|
||||
@apply max-w-[260px] font-[family-name:var(--font-mono)] font-extrabold tracking-[0] text-[var(--accent)];
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-[17px] leading-[1.15];
|
||||
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
@apply text-left text-2xl leading-[1.1];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-auth-body {
|
||||
@apply grid w-full min-h-[140px] gap-2.5 content-start;
|
||||
}
|
||||
|
||||
.login-auth-body--telegram {
|
||||
@apply content-center;
|
||||
}
|
||||
|
||||
.login-text,
|
||||
.promo-status {
|
||||
@apply text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@apply font-extrabold;
|
||||
}
|
||||
|
||||
.mono-pill,
|
||||
.badge {
|
||||
@apply inline-flex min-h-7 items-center rounded-[var(--radius-md)] px-2.5 font-[family-name:var(--font-mono)] text-[11px] font-bold whitespace-nowrap;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mono-pill {
|
||||
@apply text-[var(--accent)];
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply min-w-0 overflow-hidden text-ellipsis text-[#6ee7b7];
|
||||
border: 1px solid color-mix(in srgb, var(--success) 34%, var(--border));
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.badge.off,
|
||||
.promo-status.error,
|
||||
.login-status.error {
|
||||
@apply text-[#fca5a5];
|
||||
}
|
||||
|
||||
.badge.off {
|
||||
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.promo-status.success {
|
||||
@apply text-[#6ee7b7];
|
||||
}
|
||||
|
||||
#legal-links-login {
|
||||
@apply w-[min(100vw,560px)];
|
||||
}
|
||||
|
||||
.legal-link,
|
||||
.code-modal-resend {
|
||||
@apply border-0 bg-transparent p-0 text-[13px] font-bold text-[var(--text-secondary)] underline underline-offset-[3px] transition-colors;
|
||||
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
|
||||
}
|
||||
|
||||
.legal-link {
|
||||
@apply inline flex-initial;
|
||||
}
|
||||
|
||||
|
||||
.otp-input {
|
||||
@apply absolute inset-0 z-2 h-full w-full border-0 bg-transparent text-transparent opacity-[0.01] outline-none;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.otp-slots {
|
||||
@apply grid-cols-6;
|
||||
}
|
||||
|
||||
.otp-slots span {
|
||||
@apply grid aspect-square min-w-0 place-items-center rounded-[var(--radius-md)] font-[family-name:var(--font-mono)] text-xl font-[850] leading-none text-[var(--text-primary)] transition-[border-color,background,box-shadow];
|
||||
background: rgba(5, 8, 13, 0.42);
|
||||
}
|
||||
|
||||
.otp-slots span.filled {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
|
||||
}
|
||||
|
||||
.otp-slots span.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.step-num,
|
||||
.step-name {
|
||||
@apply block text-current;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1];
|
||||
}
|
||||
|
||||
.step-name {
|
||||
@apply mt-1 text-xs font-extrabold leading-[1.15];
|
||||
}
|
||||
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
@apply border-[var(--accent)] text-[var(--accent)];
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.step.done {
|
||||
@apply text-[var(--text-secondary)];
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
|
||||
.notice {
|
||||
@apply rounded-[var(--radius-md)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
.login-layout.show {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.telegram-login-widget {
|
||||
@apply min-h-14 justify-center;
|
||||
}
|
||||
|
||||
.login-text,
|
||||
.promo-status {
|
||||
@apply m-0 text-sm leading-[1.45];
|
||||
}
|
||||
|
||||
.promo-status {
|
||||
@apply mt-0.5 min-h-[22px];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-status {
|
||||
@apply m-0 min-h-5;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@apply fixed left-1/2 z-40 w-max max-w-[calc(100vw-28px)] rounded-[var(--radius-md)] px-[13px] py-[11px] text-[13px] font-bold text-[var(--text-primary)] opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)];
|
||||
bottom: max(var(--app-safe-bottom), 22px);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: rgba(15, 21, 33, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.45), 0 0 15px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 14px) scale(0.94);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
@apply opacity-100;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
|
||||
.mono-pill,
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.mono-pill,
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
background: rgba(0, 254, 122, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.legal-link:hover:not(:disabled),
|
||||
.code-modal-resend:hover:not(:disabled) {
|
||||
@apply text-[var(--accent)];
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.app {
|
||||
@apply px-2.5;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@apply max-w-[150px];
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply max-w-[164px];
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.mono-pill {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
.otp-slots {
|
||||
@apply gap-1.5;
|
||||
}
|
||||
|
||||
.otp-slots span {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
@apply h-[68px] w-[68px];
|
||||
}
|
||||
|
||||
.brand-logo-shell--lg {
|
||||
@apply h-[96px] w-[96px];
|
||||
}
|
||||
|
||||
.login-auth-body {
|
||||
@apply min-h-[128px];
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
@apply max-w-[200px] text-[21px];
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.brand-title {
|
||||
@apply max-w-[150px];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.step-box {
|
||||
@apply min-h-[54px] px-[7px] py-2;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply place-items-center p-0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
|
||||
}
|
||||
|
||||
.modal--page {
|
||||
@apply place-items-stretch p-0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.modal--page .modal-backdrop {
|
||||
@apply bg-[var(--bg-primary)] opacity-100;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
@apply h-auto max-h-[min(92vh,760px)] w-full rounded-[var(--radius-lg)];
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
|
||||
.modal--page .modal-card {
|
||||
@apply h-full w-full max-h-none max-w-none rounded-none border-0 shadow-none;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.modal.show .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,575 +0,0 @@
|
||||
(function(window) {
|
||||
(function(window){
|
||||
window.__parseFunction = function(__func, __attrs) {
|
||||
__attrs = __attrs || [];
|
||||
__func = '(function(' + __attrs.join(',') + '){' + __func + '})';
|
||||
return window.execScript ? window.execScript(__func) : eval(__func);
|
||||
}
|
||||
}(window));
|
||||
(function(window){
|
||||
|
||||
function addEvent(el, event, handler) {
|
||||
var events = event.split(/\s+/);
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
if (el.addEventListener) {
|
||||
el.addEventListener(events[i], handler);
|
||||
} else {
|
||||
el.attachEvent('on' + events[i], handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
function removeEvent(el, event, handler) {
|
||||
var events = event.split(/\s+/);
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
if (el.removeEventListener) {
|
||||
el.removeEventListener(events[i], handler);
|
||||
} else {
|
||||
el.detachEvent('on' + events[i], handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
function getCssProperty(el, prop) {
|
||||
if (window.getComputedStyle) {
|
||||
return window.getComputedStyle(el, '').getPropertyValue(prop) || null;
|
||||
} else if (el.currentStyle) {
|
||||
return el.currentStyle[prop] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function geById(el_or_id) {
|
||||
if (typeof el_or_id == 'string' || el_or_id instanceof String) {
|
||||
return document.getElementById(el_or_id);
|
||||
} else if (el_or_id instanceof HTMLElement) {
|
||||
return el_or_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var getWidgetsOrigin = function(default_origin, dev_origin) {
|
||||
var link = document.createElement('A'), origin;
|
||||
link.href = document.currentScript && document.currentScript.src || default_origin;
|
||||
origin = link.origin || link.protocol + '//' + link.hostname;
|
||||
if (origin == 'https://telegram.org') {
|
||||
origin = default_origin;
|
||||
} else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {
|
||||
origin = dev_origin;
|
||||
} else {
|
||||
origin = default_origin;
|
||||
}
|
||||
return origin;
|
||||
};
|
||||
|
||||
var getPageCanonical = function() {
|
||||
var a = document.createElement('A'), link, href;
|
||||
if (document.querySelector) {
|
||||
link = document.querySelector('link[rel="canonical"]');
|
||||
if (link && (href = link.getAttribute('href'))) {
|
||||
a.href = href;
|
||||
return a.href;
|
||||
}
|
||||
} else {
|
||||
var links = document.getElementsByTagName('LINK');
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
if ((link = links[i]) &&
|
||||
(link.getAttribute('rel') == 'canonical') &&
|
||||
(href = link.getAttribute('href'))) {
|
||||
a.href = href;
|
||||
return a.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function haveTgAuthResult() {
|
||||
var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match;
|
||||
try {
|
||||
locationHash = location.hash.toString();
|
||||
if (match = locationHash.match(re)) {
|
||||
location.hash = locationHash.replace(re, '');
|
||||
var data = match[1] || '';
|
||||
data = data.replace(/-/g, '+').replace(/_/g, '/');
|
||||
var pad = data.length % 4;
|
||||
if (pad > 1) {
|
||||
data += new Array(5 - pad).join('=');
|
||||
}
|
||||
return JSON.parse(window.atob(data));
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getXHR() {
|
||||
if (navigator.appName == "Microsoft Internet Explorer"){
|
||||
return new ActiveXObject("Microsoft.XMLHTTP");
|
||||
} else {
|
||||
return new XMLHttpRequest();
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.Telegram) {
|
||||
window.Telegram = {};
|
||||
}
|
||||
if (!window.Telegram.__WidgetUuid) {
|
||||
window.Telegram.__WidgetUuid = 0;
|
||||
}
|
||||
if (!window.Telegram.__WidgetLastId) {
|
||||
window.Telegram.__WidgetLastId = 0;
|
||||
}
|
||||
if (!window.Telegram.__WidgetCallbacks) {
|
||||
window.Telegram.__WidgetCallbacks = {};
|
||||
}
|
||||
|
||||
function postMessageToIframe(iframe, event, data, callback) {
|
||||
if (!iframe._ready) {
|
||||
if (!iframe._readyQueue) iframe._readyQueue = [];
|
||||
iframe._readyQueue.push([event, data, callback]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
data = data || {};
|
||||
data.event = event;
|
||||
if (callback) {
|
||||
data._cb = ++window.Telegram.__WidgetLastId;
|
||||
window.Telegram.__WidgetCallbacks[data._cb] = {
|
||||
iframe: iframe,
|
||||
callback: callback
|
||||
};
|
||||
}
|
||||
iframe.contentWindow.postMessage(JSON.stringify(data), '*');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function initWidget(widgetEl) {
|
||||
var widgetId, widgetElId, widgetsOrigin, existsEl,
|
||||
src, styles = {}, allowedAttrs = [],
|
||||
defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth;
|
||||
if (!widgetEl.tagName ||
|
||||
!(widgetEl.tagName.toUpperCase() == 'SCRIPT' ||
|
||||
widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' &&
|
||||
widgetEl.classList.contains('telegram-post'))) {
|
||||
return null;
|
||||
}
|
||||
if (widgetEl._iframe) {
|
||||
return widgetEl._iframe;
|
||||
}
|
||||
if (widgetId = widgetEl.getAttribute('data-telegram-post')) {
|
||||
var comment = widgetEl.getAttribute('data-comment') || '';
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : '');
|
||||
src = widgetsOrigin + '/' + widgetId + '?embed=1';
|
||||
allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color'];
|
||||
defWidth = widgetEl.getAttribute('data-width') || '100%';
|
||||
defHeight = '';
|
||||
styles.minWidth = '320px';
|
||||
}
|
||||
else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) {
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid);
|
||||
var websitePageUrl = widgetEl.getAttribute('data-page-url');
|
||||
if (!websitePageUrl) {
|
||||
websitePageUrl = getPageCanonical();
|
||||
}
|
||||
src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : '');
|
||||
allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height'];
|
||||
defWidth = widgetEl.getAttribute('data-width') || '100%';
|
||||
defHeight = widgetEl.getAttribute('data-height') || 0;
|
||||
styles.minWidth = '320px';
|
||||
if (defHeight > 0) {
|
||||
scrollable = true;
|
||||
}
|
||||
}
|
||||
else if (widgetEl.hasAttribute('data-telegram-login')) {
|
||||
widgetId = widgetEl.getAttribute('data-telegram-login');
|
||||
widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev');
|
||||
widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-');
|
||||
src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href);
|
||||
allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang'];
|
||||
defWidth = 186;
|
||||
defHeight = 28;
|
||||
if (widgetEl.hasAttribute('data-size')) {
|
||||
var size = widgetEl.getAttribute('data-size');
|
||||
if (size == 'small') defWidth = 148, defHeight = 20;
|
||||
else if (size == 'large') defWidth = 238, defHeight = 40;
|
||||
}
|
||||
if (widgetEl.hasAttribute('data-onauth')) {
|
||||
onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']);
|
||||
}
|
||||
else if (widgetEl.hasAttribute('data-auth-url')) {
|
||||
var a = document.createElement('A');
|
||||
a.href = widgetEl.getAttribute('data-auth-url');
|
||||
onAuthUser = function(user) {
|
||||
var authUrl = a.href;
|
||||
authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?';
|
||||
var params = [];
|
||||
for (var key in user) {
|
||||
params.push(key + '=' + encodeURIComponent(user[key]));
|
||||
}
|
||||
authUrl += params.join('&');
|
||||
location.href = authUrl;
|
||||
};
|
||||
}
|
||||
if (widgetEl.hasAttribute('data-onunauth')) {
|
||||
onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth'));
|
||||
}
|
||||
var auth_result = haveTgAuthResult();
|
||||
if (auth_result && onAuthUser) {
|
||||
onAuthUser(auth_result);
|
||||
}
|
||||
}
|
||||
else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) {
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-share-' + window.btoa(widgetId);
|
||||
src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname);
|
||||
allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text'];
|
||||
defWidth = 60;
|
||||
defHeight = 20;
|
||||
if (widgetEl.getAttribute('data-size') == 'large') {
|
||||
defWidth = 76;
|
||||
defHeight = 28;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
existsEl = document.getElementById(widgetElId);
|
||||
if (existsEl) {
|
||||
return existsEl;
|
||||
}
|
||||
for (var i = 0; i < allowedAttrs.length; i++) {
|
||||
var attr = allowedAttrs[i];
|
||||
var novalue = attr.substr(-1) == '?';
|
||||
if (novalue) {
|
||||
attr = attr.slice(0, -1);
|
||||
}
|
||||
var data_attr = 'data-' + attr.replace(/_/g, '-');
|
||||
if (widgetEl.hasAttribute(data_attr)) {
|
||||
var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr));
|
||||
src += '&' + attr + '=' + attr_value;
|
||||
}
|
||||
}
|
||||
function getCurCoords(iframe) {
|
||||
var docEl = document.documentElement;
|
||||
var frect = iframe.getBoundingClientRect();
|
||||
return {
|
||||
frameTop: frect.top,
|
||||
frameBottom: frect.bottom,
|
||||
frameLeft: frect.left,
|
||||
frameRight: frect.right,
|
||||
frameWidth: frect.width,
|
||||
frameHeight: frect.height,
|
||||
scrollTop: window.pageYOffset,
|
||||
scrollLeft: window.pageXOffset,
|
||||
clientWidth: docEl.clientWidth,
|
||||
clientHeight: docEl.clientHeight
|
||||
};
|
||||
}
|
||||
function visibilityHandler() {
|
||||
if (isVisible(iframe, 50)) {
|
||||
postMessageToIframe(iframe, 'visible', {frame: widgetElId});
|
||||
}
|
||||
}
|
||||
function focusHandler() {
|
||||
postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()});
|
||||
}
|
||||
function postMessageHandler(event) {
|
||||
if (event.source !== iframe.contentWindow ||
|
||||
event.origin != widgetsOrigin) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var data = JSON.parse(event.data);
|
||||
} catch(e) {
|
||||
var data = {};
|
||||
}
|
||||
if (data.event == 'resize') {
|
||||
if (data.height) {
|
||||
iframe.style.height = data.height + 'px';
|
||||
}
|
||||
if (data.width) {
|
||||
iframe.style.width = data.width + 'px';
|
||||
}
|
||||
}
|
||||
else if (data.event == 'ready') {
|
||||
iframe._ready = true;
|
||||
focusHandler();
|
||||
for (var i = 0; i < iframe._readyQueue.length; i++) {
|
||||
var queue_item = iframe._readyQueue[i];
|
||||
postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]);
|
||||
}
|
||||
iframe._readyQueue = [];
|
||||
}
|
||||
else if (data.event == 'visible_off') {
|
||||
removeEvent(window, 'scroll', visibilityHandler);
|
||||
removeEvent(window, 'resize', visibilityHandler);
|
||||
}
|
||||
else if (data.event == 'get_coords') {
|
||||
postMessageToIframe(iframe, 'callback', {
|
||||
_cb: data._cb,
|
||||
value: getCurCoords(iframe)
|
||||
});
|
||||
}
|
||||
else if (data.event == 'scroll_to') {
|
||||
try {
|
||||
window.scrollTo(data.x || 0, data.y || 0);
|
||||
} catch(e) {}
|
||||
}
|
||||
else if (data.event == 'auth_user') {
|
||||
if (data.init) {
|
||||
onInitAuthUser && onInitAuthUser(data.auth_data);
|
||||
} else {
|
||||
onAuthUser && onAuthUser(data.auth_data);
|
||||
}
|
||||
}
|
||||
else if (data.event == 'unauthorized') {
|
||||
onUnauth && onUnauth();
|
||||
}
|
||||
else if (data.event == 'callback') {
|
||||
var cb_data = null;
|
||||
if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) {
|
||||
if (cb_data.iframe === iframe) {
|
||||
cb_data.callback(data.value);
|
||||
delete window.Telegram.__WidgetCallbacks[data._cb];
|
||||
}
|
||||
} else {
|
||||
console.warn('Callback #' + data._cb + ' not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.id = widgetElId;
|
||||
iframe.src = src;
|
||||
iframe.width = defWidth;
|
||||
iframe.height = defHeight;
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
if (!scrollable) {
|
||||
iframe.setAttribute('scrolling', 'no');
|
||||
iframe.style.overflow = 'hidden';
|
||||
}
|
||||
iframe.style.colorScheme = 'light dark';
|
||||
iframe.style.border = 'none';
|
||||
for (var prop in styles) {
|
||||
iframe.style[prop] = styles[prop];
|
||||
}
|
||||
if (widgetEl.parentNode) {
|
||||
widgetEl.parentNode.insertBefore(iframe, widgetEl);
|
||||
if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') {
|
||||
widgetEl.parentNode.removeChild(widgetEl);
|
||||
}
|
||||
}
|
||||
iframe._ready = false;
|
||||
iframe._readyQueue = [];
|
||||
widgetEl._iframe = iframe;
|
||||
addEvent(iframe, 'load', function() {
|
||||
removeEvent(iframe, 'load', visibilityHandler);
|
||||
addEvent(window, 'scroll', visibilityHandler);
|
||||
addEvent(window, 'resize', visibilityHandler);
|
||||
visibilityHandler();
|
||||
});
|
||||
addEvent(window, 'focus blur', focusHandler);
|
||||
addEvent(window, 'message', postMessageHandler);
|
||||
return iframe;
|
||||
}
|
||||
function isVisible(el, padding) {
|
||||
var node = el, val;
|
||||
var visibility = getCssProperty(node, 'visibility');
|
||||
if (visibility == 'hidden') return false;
|
||||
while (node) {
|
||||
if (node === document.documentElement) break;
|
||||
var display = getCssProperty(node, 'display');
|
||||
if (display == 'none') return false;
|
||||
var opacity = getCssProperty(node, 'opacity');
|
||||
if (opacity !== null && opacity < 0.1) return false;
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (el.getBoundingClientRect) {
|
||||
padding = +padding || 0;
|
||||
var rect = el.getBoundingClientRect();
|
||||
var html = document.documentElement;
|
||||
if (rect.bottom < padding ||
|
||||
rect.right < padding ||
|
||||
rect.top > (window.innerHeight || html.clientHeight) - padding ||
|
||||
rect.left > (window.innerWidth || html.clientWidth) - padding) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAllWidgets() {
|
||||
var widgets = [];
|
||||
if (document.querySelectorAll) {
|
||||
widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]');
|
||||
} else {
|
||||
widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT'));
|
||||
widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE')));
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function getWidgetInfo(el_or_id, callback) {
|
||||
var e = null, iframe = null;
|
||||
if (el = geById(el_or_id)) {
|
||||
if (el.tagName &&
|
||||
el.tagName.toUpperCase() == 'IFRAME') {
|
||||
iframe = el;
|
||||
} else if (el._iframe) {
|
||||
iframe = el._iframe;
|
||||
}
|
||||
if (iframe && callback) {
|
||||
postMessageToIframe(iframe, 'get_info', {}, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setWidgetOptions(options, el_or_id) {
|
||||
var e = null, iframe = null;
|
||||
if (typeof el_or_id === 'undefined') {
|
||||
var widgets = getAllWidgets();
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
if (iframe = widgets[i]._iframe) {
|
||||
postMessageToIframe(iframe, 'set_options', {options: options});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (el = geById(el_or_id)) {
|
||||
if (el.tagName &&
|
||||
el.tagName.toUpperCase() == 'IFRAME') {
|
||||
iframe = el;
|
||||
} else if (el._iframe) {
|
||||
iframe = el._iframe;
|
||||
}
|
||||
if (iframe) {
|
||||
postMessageToIframe(iframe, 'set_options', {options: options});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!document.currentScript ||
|
||||
!initWidget(document.currentScript)) {
|
||||
var widgets = getAllWidgets();
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
initWidget(widgets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
var TelegramLogin = {
|
||||
popups: {},
|
||||
options: null,
|
||||
auth_callback: null,
|
||||
_init: function(options, auth_callback) {
|
||||
TelegramLogin.options = options;
|
||||
TelegramLogin.auth_callback = auth_callback;
|
||||
var auth_result = haveTgAuthResult();
|
||||
if (auth_result && auth_callback) {
|
||||
auth_callback(auth_result);
|
||||
}
|
||||
},
|
||||
_open: function(callback) {
|
||||
TelegramLogin._auth(TelegramLogin.options, function(authData) {
|
||||
if (TelegramLogin.auth_callback) {
|
||||
TelegramLogin.auth_callback(authData);
|
||||
}
|
||||
if (callback) {
|
||||
callback(authData);
|
||||
}
|
||||
});
|
||||
},
|
||||
_auth: function(options, callback) {
|
||||
var bot_id = parseInt(options.bot_id);
|
||||
if (!bot_id) {
|
||||
throw new Error('Bot id required');
|
||||
}
|
||||
var width = 550;
|
||||
var height = 470;
|
||||
var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0),
|
||||
top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0);
|
||||
var onMessage = function (event) {
|
||||
try {
|
||||
var data = JSON.parse(event.data);
|
||||
} catch(e) {
|
||||
var data = {};
|
||||
}
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (event.source !== TelegramLogin.popups[bot_id].window) return;
|
||||
if (data.event == 'auth_result') {
|
||||
onAuthDone(data.result);
|
||||
}
|
||||
};
|
||||
var onAuthDone = function (authData) {
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (TelegramLogin.popups[bot_id].authFinished) return;
|
||||
callback && callback(authData);
|
||||
TelegramLogin.popups[bot_id].authFinished = true;
|
||||
removeEvent(window, 'message', onMessage);
|
||||
};
|
||||
var checkClose = function(bot_id) {
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (!TelegramLogin.popups[bot_id].window ||
|
||||
TelegramLogin.popups[bot_id].window.closed) {
|
||||
return TelegramLogin.getAuthData(options, function(origin, authData) {
|
||||
onAuthDone(authData);
|
||||
});
|
||||
}
|
||||
setTimeout(checkClose, 100, bot_id);
|
||||
}
|
||||
var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href);
|
||||
var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0');
|
||||
TelegramLogin.popups[bot_id] = {
|
||||
window: popup,
|
||||
authFinished: false
|
||||
};
|
||||
if (popup) {
|
||||
addEvent(window, 'message', onMessage);
|
||||
popup.focus();
|
||||
checkClose(bot_id);
|
||||
}
|
||||
},
|
||||
getAuthData: function(options, callback) {
|
||||
var bot_id = parseInt(options.bot_id);
|
||||
if (!bot_id) {
|
||||
throw new Error('Bot id required');
|
||||
}
|
||||
var xhr = getXHR();
|
||||
var url = Telegram.Login.widgetsOrigin + '/auth/get';
|
||||
xhr.open('POST', url);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState == 4) {
|
||||
if (typeof xhr.responseBody == 'undefined' && xhr.responseText) {
|
||||
try {
|
||||
var result = JSON.parse(xhr.responseText);
|
||||
} catch(e) {
|
||||
var result = {};
|
||||
}
|
||||
if (result.user) {
|
||||
callback(result.origin, result.user);
|
||||
} else {
|
||||
callback(result.origin, false);
|
||||
}
|
||||
} else {
|
||||
callback('*', false);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
callback('*', false);
|
||||
};
|
||||
xhr.withCredentials = true;
|
||||
xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : ''));
|
||||
}
|
||||
};
|
||||
|
||||
window.Telegram.getWidgetInfo = getWidgetInfo;
|
||||
window.Telegram.setWidgetOptions = setWidgetOptions;
|
||||
window.Telegram.Login = {
|
||||
init: TelegramLogin._init,
|
||||
open: TelegramLogin._open,
|
||||
auth: TelegramLogin._auth,
|
||||
widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev')
|
||||
};
|
||||
|
||||
}(window));
|
||||
})(window);
|
||||
@@ -1,7 +1,6 @@
|
||||
import hmac
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot, Dispatcher
|
||||
@@ -17,8 +16,6 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
|
||||
return False
|
||||
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
||||
|
||||
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
def _inject_shared_instances(
|
||||
app: web.Application,
|
||||
@@ -132,13 +129,8 @@ async def build_and_start_web_app(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
|
||||
telegram_web_app_sdk_refresh_task = None
|
||||
if settings.WEBAPP_ENABLED:
|
||||
from bot.app.web.subscription_webapp import (
|
||||
create_subscription_webapp_application,
|
||||
refresh_telegram_login_widget_sdk,
|
||||
refresh_telegram_web_app_sdk,
|
||||
)
|
||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
||||
|
||||
subscription_app = create_subscription_webapp_application(
|
||||
dp,
|
||||
@@ -161,25 +153,9 @@ async def build_and_start_web_app(
|
||||
settings.WEBAPP_SERVER_PORT,
|
||||
)
|
||||
|
||||
async def _refresh_telegram_web_assets_forever() -> None:
|
||||
while True:
|
||||
await refresh_telegram_web_app_sdk()
|
||||
await refresh_telegram_login_widget_sdk()
|
||||
await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS)
|
||||
|
||||
telegram_web_app_sdk_refresh_task = asyncio.create_task(
|
||||
_refresh_telegram_web_assets_forever(),
|
||||
name="TelegramWebAssetsRefreshTask",
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
if telegram_web_app_sdk_refresh_task is not None:
|
||||
telegram_web_app_sdk_refresh_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await telegram_web_app_sdk_refresh_task
|
||||
|
||||
for runner in reversed(runners):
|
||||
try:
|
||||
await runner.cleanup()
|
||||
|
||||
@@ -99,7 +99,7 @@ def _layout(
|
||||
logo_block = (
|
||||
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
|
||||
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
|
||||
f'border-radius:16px;background:{_CARD_BG};">'
|
||||
f'border-radius:16px;">'
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
|
||||
@@ -495,6 +495,7 @@ class SubscriptionService:
|
||||
"is_active": True,
|
||||
"status_from_panel": "TRIAL",
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
try:
|
||||
@@ -516,6 +517,7 @@ class SubscriptionService:
|
||||
expire_at=end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
|
||||
@@ -87,6 +87,7 @@ class WebAppSettings(BaseModel):
|
||||
title: str
|
||||
primary_color: str
|
||||
logo_url: Optional[str]
|
||||
logo_emoji: str
|
||||
session_ttl_seconds: int
|
||||
session_secret: str
|
||||
webhook_secret_token: str
|
||||
@@ -315,6 +316,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_ENABLED: bool = Field(default=True)
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
|
||||
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
|
||||
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
|
||||
@@ -331,6 +333,7 @@ class Settings(BaseSettings):
|
||||
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
||||
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
|
||||
@@ -475,6 +478,7 @@ class Settings(BaseSettings):
|
||||
title=self.WEBAPP_TITLE,
|
||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||
logo_url=self.WEBAPP_LOGO_URL,
|
||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
||||
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
|
||||
session_secret=self.WEBAPP_SESSION_SECRET,
|
||||
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
|
||||
|
||||
@@ -14,6 +14,11 @@ services:
|
||||
ports:
|
||||
- 127.0.0.1:8080:8080
|
||||
- 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}
|
||||
# Optional Tariffs 2.0 config. Put tariffs.json near this compose file,
|
||||
# then uncomment the mount below. If no JSON is mounted, legacy .env
|
||||
# pricing is used.
|
||||
# volumes:
|
||||
# - ./tariffs.json:/app/config/tariffs.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
|
||||
+145
-8
@@ -57,7 +57,6 @@
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"connect_button": "🔗 Connect",
|
||||
"cancel_button": "❌ Cancel",
|
||||
|
||||
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
|
||||
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
|
||||
"no_devices_details_found_message": "📱 <b>My Devices</b>\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.",
|
||||
@@ -67,7 +66,6 @@
|
||||
"no_devices_found": "Error loading device list.",
|
||||
"devices_unlimited_label": "Unlimited",
|
||||
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_description_traffic": "Traffic package {traffic_gb} GB",
|
||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||
@@ -522,9 +520,7 @@
|
||||
"admin_ads_deleted_success": "Campaign deleted.",
|
||||
"admin_ads_not_found": "Campaign not found.",
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n",
|
||||
|
||||
"email_footer_auto": "Sent automatically by {brand}. Please don't reply.",
|
||||
|
||||
"email_login_code_subject": "{code} — your sign-in code",
|
||||
"email_login_code_preheader": "Your one-time code expires in {minutes} min.",
|
||||
"email_login_code_heading": "Confirm your sign-in",
|
||||
@@ -537,7 +533,6 @@
|
||||
"email_login_code_magic_or": "or",
|
||||
"email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.",
|
||||
"email_login_code_text_magic": "Or sign in with this single-use link: {url}",
|
||||
|
||||
"email_account_merged_subject": "Accounts merged",
|
||||
"email_account_merged_preheader": "Your accounts were combined into one profile.",
|
||||
"email_account_merged_heading": "Accounts merged",
|
||||
@@ -547,7 +542,6 @@
|
||||
"email_account_merged_row_end_date": "New end date",
|
||||
"email_account_merged_note": "Your subscription link is unchanged. The duplicate panel user was removed automatically.",
|
||||
"email_account_merged_text": "We merged your accounts into one profile.\n\nKept account: {primary}\nRemoved account: {removed}\nPaid periods were combined. New subscription end date: {end_date}.\nYour subscription link stayed the same, and the later account was removed from Remnawave automatically.",
|
||||
|
||||
"email_payment_success_subject": "Payment received",
|
||||
"email_payment_success_preheader": "Thanks — your subscription has been activated.",
|
||||
"email_payment_success_heading": "Payment received",
|
||||
@@ -565,7 +559,6 @@
|
||||
"email_payment_success_text_subscription": "Payment received: {amount}.\nSubscription extended by {months} month(s).\nActive until: {end_date}.",
|
||||
"email_payment_success_text_traffic": "Payment received: {amount}.\nTraffic added: {traffic_gb} GB.\nActive until: {end_date}.",
|
||||
"email_payment_success_text_dashboard": "Dashboard: {url}",
|
||||
|
||||
"email_subscription_expiring_subject_today": "Your subscription ends today",
|
||||
"email_subscription_expiring_subject_tomorrow": "1 day left on your subscription",
|
||||
"email_subscription_expiring_subject_days": "{days} days left on your subscription",
|
||||
@@ -583,5 +576,149 @@
|
||||
"email_subscription_expiring_cta": "Renew subscription",
|
||||
"email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.",
|
||||
"email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.",
|
||||
"email_subscription_expiring_text_renew": "Renew: {url}"
|
||||
"email_subscription_expiring_text_renew": "Renew: {url}",
|
||||
"wa_loading": "Loading...",
|
||||
"wa_back": "Back",
|
||||
"wa_close": "Close",
|
||||
"wa_auth_checking_login": "Checking sign-in...",
|
||||
"wa_auth_login_confirm_failed": "Could not confirm sign-in",
|
||||
"wa_auth_checking_telegram": "Checking Telegram...",
|
||||
"wa_auth_access_denied": "Access denied",
|
||||
"wa_auth_telegram_not_confirmed": "Telegram sign-in not confirmed",
|
||||
"wa_auth_telegram_unavailable": "Telegram sign-in is currently unavailable",
|
||||
"wa_auth_telegram_not_configured": "Telegram sign-in is not configured",
|
||||
"wa_auth_telegram_cancelled": "Telegram sign-in was cancelled",
|
||||
"wa_auth_invalid_email": "Enter a valid email",
|
||||
"wa_auth_sending_code": "Sending code...",
|
||||
"wa_auth_send_code_failed": "Failed to send code",
|
||||
"wa_auth_enter_code_6digits": "Enter the 6-digit code from the email",
|
||||
"wa_auth_checking_code": "Checking code...",
|
||||
"wa_auth_invalid_code": "Invalid code",
|
||||
"wa_auth_code_expired": "Code expired",
|
||||
"wa_auth_resend_wait": "You can resend in {seconds} sec.",
|
||||
"wa_email_verification_title": "Email confirmation",
|
||||
"wa_email_sent_to": "We sent a code to {email}",
|
||||
"wa_email_code_aria": "Verification code",
|
||||
"wa_email_placeholder": "Email",
|
||||
"wa_confirm": "Confirm",
|
||||
"wa_resend_code": "Resend code",
|
||||
"wa_send_code_email": "Send code to email",
|
||||
"wa_or": "or",
|
||||
"wa_login_telegram_button": "Sign in with Telegram",
|
||||
"wa_auth_legal_intro": "By creating an account, you agree to the",
|
||||
"wa_auth_legal_privacy": "privacy policy",
|
||||
"wa_auth_legal_and": "and",
|
||||
"wa_auth_legal_agreement": "user agreement",
|
||||
"wa_home_subscription_active": "Subscription active",
|
||||
"wa_home_access_active": "Access active",
|
||||
"wa_sub_term_forever": "Forever",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "day",
|
||||
"wa_sub_term_day_few": "days",
|
||||
"wa_sub_term_day_many": "days",
|
||||
"wa_sub_term_month_one": "month",
|
||||
"wa_sub_term_month_few": "months",
|
||||
"wa_sub_term_month_many": "months",
|
||||
"wa_sub_term_year_one": "year",
|
||||
"wa_sub_term_year_few": "years",
|
||||
"wa_sub_term_year_many": "years",
|
||||
"wa_home_subscription_inactive": "Subscription inactive",
|
||||
"wa_until_date": "until {date}",
|
||||
"wa_home_traffic_used": "Traffic used",
|
||||
"wa_renew": "Renew",
|
||||
"wa_pay_subscription": "Pay subscription",
|
||||
"wa_subscription_title": "Subscription",
|
||||
"wa_subscription_choose_period": "Choose subscription period",
|
||||
"wa_tariffs_title": "Tariffs",
|
||||
"wa_tariffs_choose": "Choose a tariff and payment option",
|
||||
"wa_per_month_short": "/mo",
|
||||
"wa_traffic_packages_title": "Traffic",
|
||||
"wa_traffic_packages_choose": "Choose traffic package",
|
||||
"wa_per_gb_short": "/GB",
|
||||
"wa_buy_traffic": "Buy traffic",
|
||||
"wa_activate_trial": "Activate trial",
|
||||
"wa_trial_title": "Trial period",
|
||||
"wa_trial_details": "{days} days, {traffic}",
|
||||
"wa_trial_activated": "Trial activated",
|
||||
"wa_trial_activation_failed": "Failed to activate trial",
|
||||
"wa_payment_methods_not_configured": "Payment methods are not configured yet",
|
||||
"wa_pay": "Pay",
|
||||
"wa_referral_link_title": "Your referral link",
|
||||
"wa_link_unavailable": "Link is not available yet",
|
||||
"wa_link_copied": "Link copied",
|
||||
"wa_copy": "Copy",
|
||||
"wa_referral_bonus_overview_title": "Bonus system",
|
||||
"wa_referral_bonus_once_note": "Bonus is granted only once per invited user after their first payment.",
|
||||
"wa_referral_bonus_registration_title": "For registration via referral link",
|
||||
"wa_referral_bonus_paid_intro": "If a friend pays for a subscription:",
|
||||
"wa_referral_bonus_you_days": "You: +{days} days",
|
||||
"wa_referral_bonus_friend_days": "Friend: +{days} days",
|
||||
"wa_referral_bonus_not_configured": "Referral bonuses are not configured",
|
||||
"wa_promo_enter": "Enter promo code",
|
||||
"wa_promo_activated_until": "Promo code activated. Subscription until {date}",
|
||||
"wa_promo_activated": "Promo code activated",
|
||||
"wa_promo_activation_failed": "Failed to activate promo code",
|
||||
"wa_activate": "Activate",
|
||||
"wa_settings_avatar_alt": "User avatar",
|
||||
"wa_settings_language": "Language",
|
||||
"wa_settings_link_telegram": "Telegram linked",
|
||||
"wa_settings_not_linked": "Not linked",
|
||||
"wa_settings_link_email": "Email linked",
|
||||
"wa_settings_email_not_linked": "Not linked",
|
||||
"wa_settings_linked": "Linked",
|
||||
"wa_settings_language_update_failed": "Failed to update language",
|
||||
"wa_settings_user_agreement": "User agreement",
|
||||
"wa_settings_privacy_policy": "Privacy policy",
|
||||
"wa_logout": "Log out",
|
||||
"wa_end_session": "End session",
|
||||
"wa_navigation": "Navigation",
|
||||
"wa_nav_home": "Home",
|
||||
"wa_nav_settings": "Settings",
|
||||
"wa_nav_devices": "Devices",
|
||||
"wa_devices_title": "My devices",
|
||||
"wa_devices_count": "{current} of {max}",
|
||||
"wa_devices_unlimited": "Unlimited",
|
||||
"wa_devices_refresh": "Refresh devices",
|
||||
"wa_devices_loading": "Loading devices...",
|
||||
"wa_devices_load_failed": "Failed to load devices",
|
||||
"wa_devices_empty": "No devices yet",
|
||||
"wa_devices_empty_hint": "You can connect up to {max} devices from your subscription app.",
|
||||
"wa_device_fallback_name": "Device {index}",
|
||||
"wa_devices_platform_unknown": "Platform unknown",
|
||||
"wa_devices_connected_at": "Connected",
|
||||
"wa_devices_disconnect": "Disconnect device",
|
||||
"wa_devices_disconnect_title": "Disconnect device",
|
||||
"wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.",
|
||||
"wa_devices_disconnect_confirm": "Disconnect",
|
||||
"wa_device_disconnected": "Device disconnected",
|
||||
"wa_device_disconnect_failed": "Failed to disconnect device",
|
||||
"wa_cancel": "Cancel",
|
||||
"wa_payment_created": "Payment created",
|
||||
"wa_payment_create_failed": "Failed to create payment",
|
||||
"wa_connect_link_unavailable": "Connection link is not available yet",
|
||||
"wa_copied": "Copied",
|
||||
"wa_unavailable": "Not available yet",
|
||||
"wa_method_other_title": "Other methods",
|
||||
"wa_unlimited_traffic": "Unlimited traffic",
|
||||
"wa_traffic_of": "{used} of {limit}",
|
||||
"wa_language_default": "English",
|
||||
"wa_telegram_not_linked": "Telegram not linked",
|
||||
"wa_tg_id_not_linked": "TG ID not linked",
|
||||
"wa_nav_bonuses": "Bonuses",
|
||||
"wa_method_platega_sbp_card": "SBP/Card",
|
||||
"wa_method_platega_crypto": "Crypto",
|
||||
"wa_settings_link_telegram_action": "Link Telegram",
|
||||
"wa_settings_telegram_linked_title": "Telegram linked",
|
||||
"wa_settings_link_email_action": "Link email",
|
||||
"wa_settings_email_linked_title": "Email linked",
|
||||
"wa_activate_promo_title": "Activate promo code",
|
||||
"wa_link_email_modal_title": "Link email",
|
||||
"wa_traffic_reset_none": "No reset",
|
||||
"wa_traffic_reset_monthly": "Monthly reset",
|
||||
"wa_traffic_reset_weekly": "Weekly reset",
|
||||
"wa_traffic_reset_daily": "Daily reset",
|
||||
"wa_traffic_reset_yearly": "Yearly reset",
|
||||
"wa_traffic_reset_policy": "Traffic reset policy",
|
||||
"wa_plan_one_year": "1 year",
|
||||
"wa_link_email_modal_desc": "Enter your email and get a verification code"
|
||||
}
|
||||
|
||||
+145
-8
@@ -56,7 +56,6 @@
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"connect_button": "🔗 Подключиться",
|
||||
|
||||
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
||||
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
||||
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||
@@ -66,7 +65,6 @@
|
||||
"no_devices_found": "Ошибка загрузки списка устройств.",
|
||||
"devices_unlimited_label": "Без ограничений",
|
||||
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
|
||||
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_description_traffic": "Пакет трафика {traffic_gb} ГБ",
|
||||
@@ -522,9 +520,7 @@
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена.",
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n",
|
||||
|
||||
"email_footer_auto": "Это автоматическое письмо от {brand}. Отвечать на него не нужно.",
|
||||
|
||||
"email_login_code_subject": "{code} — код для входа",
|
||||
"email_login_code_preheader": "Одноразовый код действует {minutes} мин.",
|
||||
"email_login_code_heading": "Подтвердите вход",
|
||||
@@ -537,7 +533,6 @@
|
||||
"email_login_code_magic_or": "или",
|
||||
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
|
||||
"email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}",
|
||||
|
||||
"email_account_merged_subject": "Аккаунты объединены",
|
||||
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
|
||||
"email_account_merged_heading": "Аккаунты объединены",
|
||||
@@ -547,7 +542,6 @@
|
||||
"email_account_merged_row_end_date": "Новая дата окончания",
|
||||
"email_account_merged_note": "Ссылка на подписку осталась прежней. Дублирующий пользователь панели был удалён автоматически.",
|
||||
"email_account_merged_text": "Мы объединили ваши аккаунты в один профиль.\n\nОставлен аккаунт: {primary}\nУдалён аккаунт: {removed}\nОплаченные периоды сложились. Новая дата окончания подписки: {end_date}.\nСсылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически.",
|
||||
|
||||
"email_payment_success_subject": "Платёж получен",
|
||||
"email_payment_success_preheader": "Спасибо — подписка активирована.",
|
||||
"email_payment_success_heading": "Платёж получен",
|
||||
@@ -565,7 +559,6 @@
|
||||
"email_payment_success_text_subscription": "Платёж получен: {amount}.\nПодписка продлена на {months} мес.\nДействует до: {end_date}.",
|
||||
"email_payment_success_text_traffic": "Платёж получен: {amount}.\nЗачислено трафика: {traffic_gb} ГБ.\nДействует до: {end_date}.",
|
||||
"email_payment_success_text_dashboard": "Кабинет: {url}",
|
||||
|
||||
"email_subscription_expiring_subject_today": "Подписка заканчивается сегодня",
|
||||
"email_subscription_expiring_subject_tomorrow": "Подписка заканчивается завтра",
|
||||
"email_subscription_expiring_subject_days": "До конца подписки осталось {days} дн.",
|
||||
@@ -583,5 +576,149 @@
|
||||
"email_subscription_expiring_cta": "Продлить подписку",
|
||||
"email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.",
|
||||
"email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.",
|
||||
"email_subscription_expiring_text_renew": "Продлить: {url}"
|
||||
"email_subscription_expiring_text_renew": "Продлить: {url}",
|
||||
"wa_loading": "Загрузка...",
|
||||
"wa_back": "Назад",
|
||||
"wa_close": "Закрыть",
|
||||
"wa_auth_checking_login": "Проверяем вход...",
|
||||
"wa_auth_login_confirm_failed": "Не удалось подтвердить вход",
|
||||
"wa_auth_checking_telegram": "Проверяем Telegram...",
|
||||
"wa_auth_access_denied": "Доступ запрещен",
|
||||
"wa_auth_telegram_not_confirmed": "Telegram-вход не подтвержден",
|
||||
"wa_auth_telegram_unavailable": "Telegram-вход сейчас недоступен",
|
||||
"wa_auth_telegram_not_configured": "Telegram-вход не настроен",
|
||||
"wa_auth_telegram_cancelled": "Вход через Telegram отменён",
|
||||
"wa_auth_invalid_email": "Введите корректный email",
|
||||
"wa_auth_sending_code": "Отправляем код...",
|
||||
"wa_auth_send_code_failed": "Не удалось отправить код",
|
||||
"wa_auth_enter_code_6digits": "Введите 6 цифр из письма",
|
||||
"wa_auth_checking_code": "Проверяем код...",
|
||||
"wa_auth_invalid_code": "Неверный код",
|
||||
"wa_auth_code_expired": "Код устарел",
|
||||
"wa_auth_resend_wait": "Повторная отправка через {seconds} сек.",
|
||||
"wa_email_verification_title": "Подтверждение по email",
|
||||
"wa_email_sent_to": "Мы отправили код на {email}",
|
||||
"wa_email_code_aria": "Код подтверждения",
|
||||
"wa_email_placeholder": "Email",
|
||||
"wa_confirm": "Подтвердить",
|
||||
"wa_resend_code": "Отправить код повторно",
|
||||
"wa_send_code_email": "Отправить код на почту",
|
||||
"wa_or": "или",
|
||||
"wa_login_telegram_button": "Войти через телеграм",
|
||||
"wa_auth_legal_intro": "Создавая аккаунт, вы соглашаетесь с",
|
||||
"wa_auth_legal_privacy": "политикой конфиденциальности",
|
||||
"wa_auth_legal_and": "и",
|
||||
"wa_auth_legal_agreement": "пользовательским соглашением",
|
||||
"wa_home_subscription_active": "Подписка активна",
|
||||
"wa_home_access_active": "Доступ активен",
|
||||
"wa_sub_term_forever": "Навсегда",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "день",
|
||||
"wa_sub_term_day_few": "дня",
|
||||
"wa_sub_term_day_many": "дней",
|
||||
"wa_sub_term_month_one": "месяц",
|
||||
"wa_sub_term_month_few": "месяца",
|
||||
"wa_sub_term_month_many": "месяцев",
|
||||
"wa_sub_term_year_one": "год",
|
||||
"wa_sub_term_year_few": "года",
|
||||
"wa_sub_term_year_many": "лет",
|
||||
"wa_home_subscription_inactive": "Подписка не активна",
|
||||
"wa_until_date": "до {date}",
|
||||
"wa_home_traffic_used": "Использовано трафика",
|
||||
"wa_renew": "Продлить",
|
||||
"wa_pay_subscription": "Оплатить подписку",
|
||||
"wa_subscription_title": "Подписка",
|
||||
"wa_subscription_choose_period": "Выберите срок подписки",
|
||||
"wa_tariffs_title": "Тарифы",
|
||||
"wa_tariffs_choose": "Выберите тариф и вариант оплаты",
|
||||
"wa_per_month_short": "/мес",
|
||||
"wa_traffic_packages_title": "Трафик",
|
||||
"wa_traffic_packages_choose": "Выберите пакет трафика",
|
||||
"wa_per_gb_short": "/ГБ",
|
||||
"wa_buy_traffic": "Купить трафик",
|
||||
"wa_activate_trial": "Активировать триал",
|
||||
"wa_trial_title": "Пробный период",
|
||||
"wa_trial_details": "{days} дн., {traffic}",
|
||||
"wa_trial_activated": "Пробный период активирован",
|
||||
"wa_trial_activation_failed": "Не удалось активировать пробный период",
|
||||
"wa_payment_methods_not_configured": "Способы оплаты пока не настроены",
|
||||
"wa_pay": "Оплатить",
|
||||
"wa_referral_link_title": "Ваша реферальная ссылка",
|
||||
"wa_link_unavailable": "Ссылка пока недоступна",
|
||||
"wa_link_copied": "Ссылка скопирована",
|
||||
"wa_copy": "Копировать",
|
||||
"wa_referral_bonus_overview_title": "Система бонусов",
|
||||
"wa_referral_bonus_once_note": "Бонус за приглашённого начисляется только один раз, после его первой оплаты.",
|
||||
"wa_referral_bonus_registration_title": "За регистрацию по реферальной ссылке",
|
||||
"wa_referral_bonus_paid_intro": "Если друг оплатит подписку:",
|
||||
"wa_referral_bonus_you_days": "Вам: +{days} дней",
|
||||
"wa_referral_bonus_friend_days": "Другу: +{days} дней",
|
||||
"wa_referral_bonus_not_configured": "Реферальные бонусы не настроены",
|
||||
"wa_promo_enter": "Введите промокод",
|
||||
"wa_promo_activated_until": "Промокод активирован. Подписка до {date}",
|
||||
"wa_promo_activated": "Промокод активирован",
|
||||
"wa_promo_activation_failed": "Не удалось активировать промокод",
|
||||
"wa_activate": "Активировать",
|
||||
"wa_settings_avatar_alt": "Аватар пользователя",
|
||||
"wa_settings_language": "Выбор языка",
|
||||
"wa_settings_link_telegram": "Привязка Telegram",
|
||||
"wa_settings_not_linked": "Не привязан",
|
||||
"wa_settings_link_email": "Привязка почты",
|
||||
"wa_settings_email_not_linked": "Не привязана",
|
||||
"wa_settings_linked": "Привязано",
|
||||
"wa_settings_language_update_failed": "Не удалось обновить язык",
|
||||
"wa_settings_user_agreement": "Пользовательское соглашение",
|
||||
"wa_settings_privacy_policy": "Политика конфиденциальности",
|
||||
"wa_logout": "Выйти",
|
||||
"wa_end_session": "Завершить сессию",
|
||||
"wa_navigation": "Навигация",
|
||||
"wa_nav_home": "Главная",
|
||||
"wa_nav_settings": "Настройки",
|
||||
"wa_nav_devices": "Устройства",
|
||||
"wa_devices_title": "Мои устройства",
|
||||
"wa_devices_count": "{current} из {max}",
|
||||
"wa_devices_unlimited": "Без ограничений",
|
||||
"wa_devices_refresh": "Обновить устройства",
|
||||
"wa_devices_loading": "Загружаем устройства...",
|
||||
"wa_devices_load_failed": "Не удалось загрузить устройства",
|
||||
"wa_devices_empty": "Устройств пока нет",
|
||||
"wa_devices_empty_hint": "Можно подключить до {max} устройств через приложение с подпиской.",
|
||||
"wa_device_fallback_name": "Устройство {index}",
|
||||
"wa_devices_platform_unknown": "Платформа неизвестна",
|
||||
"wa_devices_connected_at": "Подключено",
|
||||
"wa_devices_disconnect": "Отключить устройство",
|
||||
"wa_devices_disconnect_title": "Отключить устройство",
|
||||
"wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.",
|
||||
"wa_devices_disconnect_confirm": "Отключить",
|
||||
"wa_device_disconnected": "Устройство отключено",
|
||||
"wa_device_disconnect_failed": "Не удалось отключить устройство",
|
||||
"wa_cancel": "Отмена",
|
||||
"wa_payment_created": "Платеж создан",
|
||||
"wa_payment_create_failed": "Не удалось создать платеж",
|
||||
"wa_connect_link_unavailable": "Ссылка для подключения пока недоступна",
|
||||
"wa_copied": "Скопировано",
|
||||
"wa_unavailable": "Пока недоступно",
|
||||
"wa_method_other_title": "Другие способы",
|
||||
"wa_unlimited_traffic": "Безлимитный трафик",
|
||||
"wa_traffic_of": "{used} из {limit}",
|
||||
"wa_language_default": "Русский",
|
||||
"wa_telegram_not_linked": "Telegram не привязан",
|
||||
"wa_tg_id_not_linked": "TG ID не привязан",
|
||||
"wa_nav_bonuses": "Бонусы",
|
||||
"wa_method_platega_sbp_card": "СБП/Карта",
|
||||
"wa_method_platega_crypto": "Крипта",
|
||||
"wa_settings_link_telegram_action": "Привязать телеграм",
|
||||
"wa_settings_telegram_linked_title": "Телеграм привязан",
|
||||
"wa_settings_link_email_action": "Привязать почту",
|
||||
"wa_settings_email_linked_title": "Почта привязана",
|
||||
"wa_activate_promo_title": "Активировать промокод",
|
||||
"wa_link_email_modal_title": "Привязать почту",
|
||||
"wa_traffic_reset_none": "Без сброса",
|
||||
"wa_traffic_reset_monthly": "Сброс ежемесячно",
|
||||
"wa_traffic_reset_weekly": "Сброс еженедельно",
|
||||
"wa_traffic_reset_daily": "Сброс ежедневно",
|
||||
"wa_traffic_reset_yearly": "Сброс ежегодно",
|
||||
"wa_traffic_reset_policy": "Стратегия сброса трафика",
|
||||
"wa_plan_one_year": "1 год",
|
||||
"wa_link_email_modal_desc": "Введите email и получите код подтверждения"
|
||||
}
|
||||
|
||||
Generated
+993
-1
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -1,13 +1,22 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:webapp:css": "tailwindcss -i ./bot/app/web/templates/subscription_webapp.tailwind.css -o ./bot/app/web/templates/subscription_webapp.css --minify",
|
||||
"build:webapp:svelte": "vite build --config ./bot/app/web/frontend/vite.config.mjs",
|
||||
"build:webapp:css": "npm run build:webapp:svelte",
|
||||
"build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs",
|
||||
"build:webapp": "npm run build:webapp:css && npm run build:webapp:js"
|
||||
"build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/cli": "4.2.4",
|
||||
"bits-ui": "^2.18.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"tailwindcss": "4.2.4"
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"svelte": "^5.55.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "4.2.4",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
SOURCE_URL = "https://telegram.org/js/telegram-web-app.js"
|
||||
TARGET_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "bot"
|
||||
/ "app"
|
||||
/ "web"
|
||||
/ "templates"
|
||||
/ "telegram-web-app.js"
|
||||
)
|
||||
|
||||
|
||||
def _download(source_url: str) -> bytes:
|
||||
request = Request(
|
||||
source_url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
||||
},
|
||||
)
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Download the latest Telegram Web App SDK into the local templates directory."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-url",
|
||||
default=SOURCE_URL,
|
||||
help="Telegram Web App SDK URL to download from.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
type=Path,
|
||||
default=TARGET_PATH,
|
||||
help="Output path for the vendored SDK.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
data = _download(args.source_url)
|
||||
args.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.target.write_bytes(data)
|
||||
print(f"Wrote {len(data)} bytes to {args.target}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,70 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
SOURCE_URL = "https://telegram.org/js/telegram-widget.js?23"
|
||||
TARGET_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "bot"
|
||||
/ "app"
|
||||
/ "web"
|
||||
/ "templates"
|
||||
/ "telegram-widget.js"
|
||||
)
|
||||
_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n"""
|
||||
_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n"""
|
||||
|
||||
|
||||
def _download(source_url: str) -> bytes:
|
||||
request = Request(
|
||||
source_url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
||||
},
|
||||
)
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def _normalize_widget_sdk(data: bytes) -> bytes:
|
||||
# Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin.
|
||||
text = data.decode("utf-8")
|
||||
normalized = text.replace(
|
||||
_UNPATCHED_WIDGET_ORIGIN_SNIPPET,
|
||||
_PATCHED_WIDGET_ORIGIN_SNIPPET,
|
||||
1,
|
||||
)
|
||||
return normalized.encode("utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Download the latest Telegram Login Widget SDK into the local templates directory."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-url",
|
||||
default=SOURCE_URL,
|
||||
help="Telegram Login Widget SDK URL to download from.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
type=Path,
|
||||
default=TARGET_PATH,
|
||||
help="Output path for the vendored SDK.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
data = _normalize_widget_sdk(_download(args.source_url))
|
||||
args.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.target.write_bytes(data)
|
||||
print(f"Wrote {len(data)} bytes to {args.target}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -80,3 +80,14 @@ class SettingsTests(unittest.TestCase):
|
||||
|
||||
self.assertIsNotNone(settings.tariffs_config)
|
||||
self.assertFalse(settings.traffic_sale_mode)
|
||||
|
||||
def test_trial_traffic_strategy_is_available(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
TRIAL_TRAFFIC_STRATEGY="WEEK",
|
||||
)
|
||||
|
||||
self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -6,9 +7,83 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot.app.web import subscription_webapp
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_serialize_plans_prefers_tariffs_config_over_legacy_packages(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "tariffs.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_tariff": "standard",
|
||||
"tariffs": [
|
||||
{
|
||||
"key": "standard",
|
||||
"names": {"en": "Standard"},
|
||||
"descriptions": {"en": "100 GB monthly"},
|
||||
"squad_uuids": ["uuid"],
|
||||
"billing_model": "period",
|
||||
"monthly_gb": 100,
|
||||
"prices_rub": {"1": 150},
|
||||
"prices_stars": {"1": 0},
|
||||
"enabled_periods": [1],
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"key": "traffic",
|
||||
"names": {"en": "Traffic"},
|
||||
"descriptions": {"en": "Pay as you go"},
|
||||
"squad_uuids": ["uuid"],
|
||||
"billing_model": "traffic",
|
||||
"traffic_packages": {
|
||||
"rub": [{"gb": 50, "price": 799}],
|
||||
"stars": [{"gb": 50, "price": 2500}],
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
TARIFFS_CONFIG_PATH=str(path),
|
||||
TRAFFIC_PACKAGES="10:199",
|
||||
)
|
||||
|
||||
plans = subscription_webapp._serialize_plans(settings, "en")
|
||||
|
||||
self.assertEqual([plan["tariff_key"] for plan in plans], ["standard", "traffic"])
|
||||
self.assertEqual(plans[0]["sale_mode"], "subscription")
|
||||
self.assertEqual(plans[0]["months"], 1)
|
||||
self.assertEqual(plans[1]["sale_mode"], "traffic_package")
|
||||
self.assertEqual(plans[1]["traffic_gb"], 50.0)
|
||||
self.assertEqual(plans[1]["stars_price"], 2500)
|
||||
|
||||
def test_serialize_plans_uses_traffic_packages_in_traffic_mode(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
TARIFFS_CONFIG_PATH="missing-tariffs.json",
|
||||
TRAFFIC_PACKAGES="10:199,50:799",
|
||||
STARS_TRAFFIC_PACKAGES="50:2500",
|
||||
)
|
||||
|
||||
plans = subscription_webapp._serialize_plans(settings, "en")
|
||||
|
||||
self.assertEqual([plan["traffic_gb"] for plan in plans], [10.0, 50.0])
|
||||
self.assertEqual(plans[0]["sale_mode"], "traffic")
|
||||
self.assertEqual(plans[0]["price"], 199.0)
|
||||
self.assertEqual(plans[1]["stars_price"], 2500)
|
||||
|
||||
def test_resolve_webapp_js_asset_name_prefers_latest_minified_build(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
asset_dir = Path(tmpdir)
|
||||
|
||||
Reference in New Issue
Block a user