diff --git a/.dockerignore b/.dockerignore index 4ce4d8e..43d619d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ # Git -.git .gitignore .github .gitattributes @@ -11,7 +10,6 @@ README.md scratch_*.py *.local.* node_modules/ -.git/ # CI diff --git a/Dockerfile b/Dockerfile index dd1f6db..524a2fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,12 +27,22 @@ FROM python:3.12-slim WORKDIR /app -LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" +ARG APP_VERSION="" +ARG APP_REVISION="" + +LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ + org.opencontainers.image.version="${APP_VERSION}" \ + org.opencontainers.image.revision="${APP_REVISION}" RUN useradd -u 10001 -m appuser COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && \ + apt-get install -y --no-install-recommends git + COPY . . # Replace template assets with freshly built ones @@ -46,7 +56,38 @@ COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \ bot/app/web/templates/ -RUN rm -rf /root/.cache +RUN set -eux; \ + if [ -n "$APP_VERSION" ]; then \ + printf '%s\n' "$APP_VERSION" > .build-version; \ + elif [ -d .git ]; then \ + tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"; \ + sha="$(git rev-parse --short HEAD 2>/dev/null || true)"; \ + dirty=""; \ + if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then dirty="-dirty"; fi; \ + if [ -n "$tag" ] && [ -n "$sha" ]; then \ + count="$(git rev-list "${tag}..HEAD" --count 2>/dev/null || true)"; \ + if [ -n "$count" ] && [ "$count" != "0" ]; then \ + printf '%s+%s.g%s%s\n' "$tag" "$count" "$sha" "$dirty" > .build-version; \ + else \ + printf '%s%s\n' "$tag" "$dirty" > .build-version; \ + fi; \ + elif [ -n "$sha" ]; then \ + printf 'dev+g%s%s\n' "$sha" "$dirty" > .build-version; \ + else \ + printf 'dev+container\n' > .build-version; \ + fi; \ + else \ + printf 'dev+container\n' > .build-version; \ + fi; \ + if [ -n "$APP_REVISION" ]; then \ + printf '%s\n' "$APP_REVISION" > .build-revision; \ + elif [ -d .git ]; then \ + git rev-parse HEAD > .build-revision 2>/dev/null || printf 'unknown\n' > .build-revision; \ + else \ + printf 'unknown\n' > .build-revision; \ + fi; \ + apt-get purge -y --auto-remove git; \ + rm -rf .git /root/.cache RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index f1dd698..f75d99d 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -33,8 +33,10 @@ from bot.services.settings_override_service import ( current_value, update_overrides, ) +from bot.services.referral_service import ReferralService from bot.utils import MessageContent, send_message_via_queue from bot.utils.message_queue import get_queue_manager +from urllib.parse import parse_qsl, urlsplit, urlunsplit from config.settings import Settings from config.tariffs_config import TariffsConfig from db.dal import ( @@ -523,6 +525,7 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) async_session_factory: sessionmaker = request.app["async_session_factory"] + settings: Settings = request.app["settings"] async with async_session_factory() as session: user = await user_dal.get_user_by_id(session, target_id) @@ -548,6 +551,50 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: log_count = await message_log_dal.count_user_message_logs(session, target_id) avatar_keys = await _bulk_user_avatar_keys(session, [target_id]) + # Referral links — both the bot deep-link and the webapp deep-link. + referral_code: Optional[str] = None + try: + referral_code = await user_dal.ensure_referral_code(session, user) + await session.commit() + except Exception as exc_ref: # pragma: no cover — defensive + logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref) + await session.rollback() + + referral_service: Optional[ReferralService] = request.app.get("referral_service") + bot_username = request.app.get("bot_username") or "" + referral_bot_link: Optional[str] = None + if referral_service and bot_username and referral_code: + try: + async with async_session_factory() as session: + referral_bot_link = await referral_service.generate_referral_link( + session, bot_username, target_id + ) + except Exception as exc_link: # pragma: no cover + logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link) + referral_webapp_link = _build_admin_webapp_referral_link( + getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None), + referral_code, + ) + + # Subscription page URL — the raw panel `subscriptionUrl` that the user + # imports into their VPN client. May be missing if the user has never + # been provisioned on the panel. + subscription_url: Optional[str] = None + panel_uuid = getattr(user, "panel_user_uuid", None) + if panel_uuid: + subscription_service = request.app.get("subscription_service") + panel_service = getattr(subscription_service, "panel_service", None) + if panel_service is not None: + try: + panel_data = await panel_service.get_user_by_uuid(panel_uuid) + if panel_data: + subscription_url = panel_data.get("subscriptionUrl") or None + except Exception as exc_panel: # pragma: no cover + logger.warning( + "Failed to fetch subscriptionUrl for user %s (uuid=%s): %s", + target_id, panel_uuid, exc_panel, + ) + serialized_user = _serialize_user(user) serialized_user["avatar_url"] = ( f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}" @@ -563,10 +610,33 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: "total_paid": float(total_paid), "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), + "subscription_url": subscription_url, + "referral": { + "code": referral_code, + "bot_link": referral_bot_link, + "webapp_link": referral_webapp_link, + }, } ) +def _build_admin_webapp_referral_link( + base_url: Optional[str], referral_code: Optional[str] +) -> Optional[str]: + """Mirror of ``subscription_webapp._build_webapp_referral_link``. + + Kept local to avoid a cross-module import cycle (subscription_webapp + imports admin_api). + """ + if not base_url or not referral_code: + return None + parts = urlsplit(base_url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["ref"] = f"u{referral_code}" + new_query = "&".join(f"{k}={v}" for k, v in query.items()) + return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)) + + async def admin_user_ban_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -1273,13 +1343,13 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/stats", admin_stats_route) router.add_get("/api/admin/users", admin_users_list_route) - router.add_get("/api/admin/users/{user_id:\\d+}", admin_user_detail_route) - router.add_get("/api/admin/users/{user_id:\\d+}/avatar", admin_user_avatar_route) - router.add_post("/api/admin/users/{user_id:\\d+}/ban", admin_user_ban_route) - router.add_post("/api/admin/users/{user_id:\\d+}/message", admin_user_message_route) - router.add_post("/api/admin/users/{user_id:\\d+}/reset-trial", admin_user_reset_trial_route) - router.add_post("/api/admin/users/{user_id:\\d+}/extend", admin_user_extend_route) - router.add_delete("/api/admin/users/{user_id:\\d+}", admin_user_delete_route) + router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route) + router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route) + router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route) + router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route) + router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route) + router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) + router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route) router.add_get("/api/admin/payments", admin_payments_list_route) router.add_get("/api/admin/payments/export.csv", admin_payments_export_route) diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index b39f045..a07969d 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -98,6 +98,8 @@ telegramLoginBotId: 1234567890, telegramOAuthClientId: 1234567890, telegramOAuthRequestAccess: ["write"], + appVersion: "dev+local", + appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop", }, data: { ok: true, @@ -759,18 +761,25 @@ function adminSectionFromPath(pathname) { const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, ""); - const m = normalized.match(/^\/admin\/([a-z0-9_-]+)$/); + const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/); if (m && ADMIN_SECTIONS.has(m[1])) return m[1]; return "stats"; } - function syncSectionPath(section, replace = false, adminSection = null) { + function adminUserIdFromPath(pathname) { + const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, ""); + const m = normalized.match(/^\/admin\/users\/(-?\d+)$/); + return m ? Number(m[1]) : null; + } + + function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) { if (window.location.protocol === "file:") return; const normalized = normalizeSection(section); let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; if (normalized === "admin") { const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats"; - targetPath = `/admin/${adm}`; + const uid = adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null); + targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`; } if (window.location.pathname === targetPath) return; const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; @@ -1117,6 +1126,12 @@ { payment_id: 11, amount: 790, currency: "RUB", provider: "stars", status: "succeeded", created_at: "2026-04-01T14:15:00Z" }, ], log_count: 18, + subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP", + referral: { + code: "ABCD1234", + bot_link: "https://t.me/preview_bot?start=ref_uABCD1234", + webapp_link: "https://app.example.com/?ref=uABCD1234", + }, }; } if (path === "/admin/tariffs") { @@ -2180,10 +2195,12 @@ syncSectionPath("settings"); } - function handleAdminSectionChange(adminSection) { + function handleAdminSectionChange(adminSection, adminUserId = null) { if (screen !== "admin") return; if (window.location.protocol === "file:") return; - const targetPath = `/admin/${adminSection}`; + const targetPath = adminSection === "users" && adminUserId + ? `/admin/users/${adminUserId}` + : `/admin/${adminSection}`; if (window.location.pathname === targetPath) return; window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`); } @@ -2937,9 +2954,15 @@ onClose={closeAdminPanel} onToast={(text) => showToast(text)} initialSection={adminSectionFromPath(window.location.pathname)} + initialUserId={adminUserIdFromPath(window.location.pathname)} onSectionChange={handleAdminSectionChange} onSettingsSaved={handleSettingsSaved} onTariffsSaved={handleTariffsSaved} + brandTitle={brandTitle} + logoUrl={CFG.logoUrl} + logoEmoji={brandEmoji} + appVersion={CFG.appVersion} + appRepositoryUrl={CFG.appRepositoryUrl} /> {:else}
diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 7903f9d..79efb12 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -7,12 +7,15 @@ ChevronLeft, ChevronRight, Coins, + Copy, + ExternalLink, CreditCard, Database, Download, Eye, EyeOff, FileText, + Link2, LayoutDashboard, Megaphone, Menu, @@ -35,15 +38,22 @@ import { onMount } from "svelte"; import { Accordion, Label, Select, Separator, Switch, Tabs } from "bits-ui"; + import BrandMark from "../BrandMark.svelte"; import Dialog from "../lib/components/ui/dialog.svelte"; export let api; export let onClose = () => {}; export let onToast = () => {}; export let initialSection = "stats"; + export let initialUserId = null; export let onSectionChange = () => {}; export let onSettingsSaved = () => {}; export let onTariffsSaved = () => {}; + export let brandTitle = "/minishop"; + export let logoUrl = ""; + export let logoEmoji = "рџ«Ґ"; + export let appVersion = "dev+local"; + export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop"; const NAV_GROUPS = [ { @@ -202,22 +212,41 @@ } active = next; sidebarOpen = false; + if (openedUser) { + openedUser = null; + openedUserDetail = null; + userDeleteOpen = false; + userBanConfirmOpen = false; + } onSectionChange(next); loadActive(); } function _readSectionFromPath() { if (typeof window === "undefined") return "stats"; - const m = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)$/i); + const m = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/i); return _normalizeSection(m ? m[1].toLowerCase() : "stats"); } + function _readUserIdFromPath() { + if (typeof window === "undefined") return null; + const m = window.location.pathname.match(/^\/admin\/users\/(-?\d+)$/); + return m ? Number(m[1]) : null; + } + function _onPopState() { const next = _readSectionFromPath(); - if (active === next) return; - active = next; - sidebarOpen = false; - loadActive(); + if (active !== next) { + active = next; + sidebarOpen = false; + loadActive(); + } + const uid = _readUserIdFromPath(); + if (uid) { + if (!openedUser || openedUser.user_id !== uid) openUser(uid, { skipPush: true }); + } else if (openedUser) { + closeUser({ skipPush: true }); + } } async function loadActive() { @@ -282,31 +311,65 @@ } } - async function openUser(user) { - openedUser = user; + async function openUser(userOrId, opts = {}) { + const userId = typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId); + if (!userId) return; + openedUser = typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }; openedUserDetail = null; userMessageDraft = ""; userExtendDays = 30; userDetailLoading = true; - userDetailTab = "profile"; + userDetailTab = "subscription"; + if (!opts.skipPush) _pushUserPath(userId); try { - const res = await api(`/admin/users/${user.user_id}`); + const res = await api(`/admin/users/${userId}`); if (res?.ok) { openedUserDetail = res; + if (res.user) openedUser = { ...res.user, ...openedUser, ...res.user }; } else { flash(res?.error || "load_failed"); openedUser = null; + if (!opts.skipPush) _pushUserPath(null); } } finally { userDetailLoading = false; } } - function closeUser() { + function closeUser(opts = {}) { + const wasOpen = Boolean(openedUser); openedUser = null; openedUserDetail = null; userDeleteOpen = false; userBanConfirmOpen = false; + if (wasOpen && !opts.skipPush) _pushUserPath(null); + } + + function _pushUserPath(userId) { + if (typeof window === "undefined") return; + if (window.location.protocol === "file:") return; + if (active !== "users") return; + const target = userId ? `/admin/users/${userId}` : `/admin/users`; + if (window.location.pathname === target) return; + window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`); + } + + function copyToClipboard(text, successMessage = "Ссылка скопирована") { + if (!text) return; + if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(text).then( + () => flash(successMessage), + () => flash(text), + ); + } else { + flash(text); + } + } + + function copyUserDeepLink() { + if (!openedUser || typeof window === "undefined") return; + const url = `${window.location.origin}/admin/users/${openedUser.user_id}`; + copyToClipboard(url); } function requestBanToggle() { @@ -369,7 +432,7 @@ }); if (res?.ok) { flash(`Подписка продлена на ${days} дн.`); - await openUser(openedUser); + await openUser(openedUser, { skipPush: true }); } else flash(res?.error || "Ошибка"); } finally { userActionBusy = false; @@ -1212,6 +1275,9 @@ window.addEventListener("popstate", _onPopState); } loadActive(); + if (active === "users" && initialUserId) { + openUser(initialUserId, { skipPush: true }); + } return () => { if (_compactMql) { if (_compactMql.removeEventListener) _compactMql.removeEventListener("change", _onCompactChange); @@ -1322,10 +1388,10 @@ @@ -1996,14 +2070,14 @@
- Ключ - Стабильный ID для платежей и подписок + Ключ тарифа + Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется
Модель тарификации - Период — фикс. длительность; Трафик — оплата за GB + Период — пользователь покупает фиксированный срок (1/3/12 мес. и т.д.). Трафик — пользователь покупает пакеты гигабайт по фиксированной цене за GB {tariffDraft.billing_model === "traffic" ? "Трафик" : "Период"} @@ -2034,8 +2108,8 @@ - {tariffDraft.enabled ? "Тариф включён" : "Тариф выключен"} - Скрытые тарифы не отображаются на витрине + {tariffDraft.enabled ? "Тариф виден на витрине" : "Тариф скрыт от пользователей"} + Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать
@@ -2062,8 +2136,8 @@
- Основные Internal Squads - {panelSquadsLoading ? "Загружаю список из панели…" : "Выберите сквады из Remnawave"} + Базовые Internal Squads + {panelSquadsLoading ? "Загружаю список из панели…" : "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько"} - Базовый лимит устройств - Пусто — значение из env, 0 — безлимит + Лимит устройств (HWID) + Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений {#if tariffDraft.billing_model === "period"} - Месячный лимит, GB - 0 — безлимит - + Месячный лимит трафика, GB + Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафик. Сверху можно докупать пакеты на вкладке «Докупки» + {:else} - Курс конвертации, RUB/GB - Нужен для перехода period → traffic + Курс конвертации, ₽ за 1 GB + По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик» {/if} @@ -2121,12 +2195,15 @@
- Premium-сквад и отдельный лимит +
+ Premium-доступ и отдельный счётчик трафика + Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно +
Premium Internal Squads - Ноды для учета трафика будут взяты из accessible nodes этих сквадов + Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes
- Premium лимит, GB/мес. - 0 или пусто — нет отдельного premium-лимита + Месячный лимит premium-трафика, GB + Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)
@@ -2168,29 +2245,46 @@
- Докупка premium-трафика +
+ Докупка premium-трафика + Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал +
- - + +
- RUB + Оплата рублями + {#if tariffDraft.premiumTopupRubRows.length} +
+ Объём, GB + Цена, ₽ + +
+ {/if} {#each tariffDraft.premiumTopupRubRows as row, index}
- - + +
{/each}
- Stars + Оплата Telegram Stars + {#if tariffDraft.premiumTopupStarsRows.length} +
+ Объём, GB + Цена, ⭐ + +
+ {/if} {#each tariffDraft.premiumTopupStarsRows as row, index}
- - + +
{/each} @@ -2203,53 +2297,80 @@ {#if tariffDraft.billing_model === "period"}
- Периоды и цены +
+ Периоды подписки и цены + Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит +
{#if !tariffDraft.periodRows.length} -

Добавьте хотя бы один период.

- {/if} -
- {#each tariffDraft.periodRows as row, index} -
- - - - +

Добавьте хотя бы один период — без него тариф не появится на витрине.

+ {:else} +
+
+ Срок, мес. + Цена, ₽ + Цена, ⭐ Stars +
- {/each} -
+ {#each tariffDraft.periodRows as row, index} +
+ + + + +
+ {/each} +
+ {/if}
{:else}
- Пакеты трафика +
+ Пакеты трафика + Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты» +
- - + +
- RUB + Оплата рублями + {#if tariffDraft.trafficRubRows.length} +
+ Объём, GB + Цена, ₽ + +
+ {/if} {#each tariffDraft.trafficRubRows as row, index}
- - + +
{/each}
- Stars + Оплата Telegram Stars + {#if tariffDraft.trafficStarsRows.length} +
+ Объём, GB + Цена, ⭐ + +
+ {/if} {#each tariffDraft.trafficStarsRows as row, index}
- - + +
{/each} @@ -2263,29 +2384,46 @@ {#if tariffDraft.billing_model === "period"}
- Докупка трафика для тарифа +
+ Докупка трафика поверх месячного лимита + Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки +
- - + +
- RUB + Оплата рублями + {#if tariffDraft.topupRubRows.length} +
+ Объём, GB + Цена, ₽ + +
+ {/if} {#each tariffDraft.topupRubRows as row, index}
- - + +
{/each}
- Stars + Оплата Telegram Stars + {#if tariffDraft.topupStarsRows.length} +
+ Объём, GB + Цена, ⭐ + +
+ {/if} {#each tariffDraft.topupStarsRows as row, index}
- - + +
{/each} @@ -2293,36 +2431,53 @@
{:else} -

Для трафиковой модели докупки не нужны — настройте пакеты трафика на вкладке «Цены».

+

Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания.

{/if}
- Пакеты HWID-устройств +
+ Пакеты дополнительных устройств (HWID) + Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты» +
- - + +
- RUB + Оплата рублями + {#if tariffDraft.hwidRubRows.length} +
+ + устройств + Цена, ₽ + +
+ {/if} {#each tariffDraft.hwidRubRows as row, index}
- - + +
{/each}
- Stars + Оплата Telegram Stars + {#if tariffDraft.hwidStarsRows.length} +
+ + устройств + Цена, ⭐ + +
+ {/if} {#each tariffDraft.hwidStarsRows as row, index}
- - + +
{/each} @@ -2368,54 +2523,114 @@ {#if userDetailLoading || !openedUserDetail}

Загрузка…

{:else} -
- - {#if resolvedAvatarUrl(openedUser)} - - {:else} - {userInitials(openedUser)} - {/if} - -
- {userDisplayName(openedUser)} - {userSecondaryName(openedUser)} -
- {#if openedUser.is_banned} - Бан - {:else} - Активен - {/if} - {#if openedUserDetail.active_subscription} - Подписка - {:else} - Без подписки - {/if} - Заплачено: {fmtMoney(openedUserDetail.total_paid)} +
+
-
- - - Профиль - Подписка - Активность - Действия - +
+
+ Заплачено + {fmtMoney(openedUserDetail.total_paid)} +
+
+ Логов + {openedUserDetail.log_count} +
+
- +
Профиль
  • ID{openedUser.user_id}
  • Telegram ID{openedUser.telegram_id || "—"}
  • Username{openedUser.username ? "@" + openedUser.username : "—"}
  • -
  • Email{openedUser.email || "—"}
  • +
  • Email{openedUser.email || "—"}
  • Регистрация{fmtDate(openedUser.registration_date)}
  • -
  • Реф. код{openedUserDetail.user?.referral_code || "—"}
  • -
  • Логов{openedUserDetail.log_count}
  • +
  • Реф. код{openedUserDetail.referral?.code || openedUserDetail.user?.referral_code || "—"}
-
- + {#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link} +
Ссылки
+ + {/if} + + + + +
+ + + Подписка + Активность + Действия + + + {#if openedUserDetail.active_subscription}
  • Активна до{fmtDate(openedUserDetail.active_subscription.end_date)}
  • @@ -2516,7 +2731,9 @@
- + + +
{/if} {/if} diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index d76f789..a047858 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -33,6 +33,19 @@ --danger: #ff6b6b; --blue: #2d9cff; --radius: 8px; + + /* Admin design tokens — kept on :root so portal-rendered admin + surfaces (dialogs, bits-ui Select.Portal content) inherit them. */ + --admin-bg: var(--bg); + --admin-surface: var(--panel); + --admin-surface-2: var(--panel-2); + --admin-elev: var(--panel-3); + --admin-border: var(--border); + --admin-border-strong: var(--border-strong); + --admin-text: var(--text); + --admin-muted: var(--muted); + --admin-dim: var(--dim); + --admin-ring: color-mix(in srgb, var(--accent) 50%, transparent); --screen-gutter: 18px; --safe-inline: max(env(safe-area-inset-left), env(safe-area-inset-right)); --nav-inline-gutter: max(var(--screen-gutter), var(--safe-inline)); @@ -2647,16 +2660,6 @@ a { .admin-screen-wrap { --admin-sidebar-w: 248px; --admin-header-h: 60px; - --admin-bg: var(--bg); - --admin-surface: var(--panel); - --admin-surface-2: var(--panel-2); - --admin-elev: var(--panel-3); - --admin-border: var(--border); - --admin-border-strong: var(--border-strong); - --admin-text: var(--text); - --admin-muted: var(--muted); - --admin-dim: var(--dim); - --admin-ring: color-mix(in srgb, var(--accent) 50%, transparent); position: fixed; inset: 0; @@ -2704,9 +2707,6 @@ a { .admin-sidebar-brand .admin-brand-mark { width: 36px; height: 36px; - border-radius: 10px; - background: color-mix(in srgb, var(--accent) 22%, var(--admin-surface-2)); - border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); display: grid; place-items: center; color: var(--accent); @@ -2791,6 +2791,30 @@ a { gap: 6px; } +.admin-version-link { + order: 2; + display: grid; + gap: 2px; + max-width: 100%; + overflow: hidden; + color: var(--admin-muted); + font-family: var(--font-mono); + font-size: 11px; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.12s ease; +} + +.admin-version-link span { + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-version-link:hover { + color: var(--accent); +} + .admin-content { flex: 1 1 auto; display: flex; @@ -3858,6 +3882,148 @@ a { overscroll-behavior: contain; } +/* User-detail dialog: constrain on desktop and lay out as a two-column + sidebar (profile facts) + main content (tabs). On mobile it stacks. */ +.admin-user-dialog { + width: min(100%, 1040px); + max-height: min(100%, 760px); +} + +.admin-user-dialog-body { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + min-width: 0; +} + +.admin-user-aside { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.admin-user-main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.admin-user-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.admin-user-stat { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--admin-border); + background: var(--admin-surface); + min-width: 0; +} + +.admin-user-stat span { + color: var(--admin-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-user-stat strong { + color: var(--admin-text); + font-size: 15px; + font-weight: 700; + word-break: break-word; +} + +.admin-user-link-btn { + align-self: stretch; + justify-content: center; +} + +.admin-link-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-link-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: var(--admin-surface); + min-width: 0; +} + +.admin-link-row-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.admin-link-row-label { + color: var(--admin-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-link-row-url { + color: var(--admin-text); + font-family: var(--font-mono); + font-size: 11px; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.admin-link-row-url:hover { + color: var(--accent); + text-decoration: underline; +} + +.admin-btn.admin-btn-icon { + width: 30px; + height: 30px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} + +.admin-meta-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (min-width: 860px) { + .admin-user-dialog-body { + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); + gap: 20px; + align-items: start; + } + + .admin-user-aside { + position: sticky; + top: 0; + } +} + .admin-tariff-grid { display: grid; grid-template-columns: minmax(0, 1fr); @@ -3975,6 +4141,44 @@ a { flex-wrap: wrap; } +/* Section title block: stacks `` heading + `` description. + Higher specificity than `.admin-editor-section-head > div` (which would + otherwise force flex-row on the wrapper). */ +.admin-editor-section-head > .admin-editor-section-title { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1 1 200px; + min-width: 0; +} + +.admin-editor-section-head > .admin-editor-section-title small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.4; + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} + +/* Column headers above an `.admin-row-editor` list — visually distinct, + inherits the same grid template as input rows so columns line up. */ +.admin-row-editor-line.admin-row-editor-header { + color: var(--admin-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0 4px; +} + +.admin-row-editor-line.admin-row-editor-header span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .admin-row-editor { display: grid; gap: 8px; @@ -4438,6 +4642,23 @@ a { line-height: 1.45; } +.admin-field-label > small code, +.admin-editor-section-title small code { + display: inline-block; + padding: 0 4px; + border-radius: 4px; + background: color-mix(in srgb, var(--admin-elev) 70%, transparent); + color: var(--admin-text); + font-family: var(--font-mono); + font-size: 11px; +} + +.admin-field-label > small b, +.admin-editor-section-title small b { + color: var(--admin-text); + font-weight: 600; +} + /* Action rows in dialogs */ .admin-action-row { display: flex; @@ -4513,7 +4734,6 @@ a { border-radius: 12px; background: var(--admin-surface-2); border: 1px solid var(--admin-border); - margin-bottom: 14px; min-width: 0; } diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 9e425c0..7060f63 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -6,9 +6,11 @@ import io import ipaddress import json import logging +import os import re import secrets import socket +import subprocess import time from collections import deque from datetime import datetime, timezone @@ -64,6 +66,7 @@ WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" WEBAPP_JS_PLACEHOLDER = "" +APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop" DEV_MOCK_START_MARKER = "" DEV_MOCK_END_MARKER = "" WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 @@ -77,6 +80,7 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf" WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state" WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token" WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +_APP_VERSION_CACHE: Optional[str] = None WEBAPP_CSRF_EXEMPT_PATHS = { "/api/auth/telegram/nonce", "/api/auth/token", @@ -210,6 +214,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/settings", index_route) app.router.add_get("/admin", index_route) app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) + app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route) app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/health", health_route) @@ -645,6 +650,63 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: return cache["data"] +def _run_git_command(*args: str) -> str: + repo_root = Path(__file__).resolve().parents[3] + try: + result = subprocess.run( + ["git", *args], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + timeout=1.5, + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout.strip() + + +def _resolve_app_version() -> str: + global _APP_VERSION_CACHE + if _APP_VERSION_CACHE: + return _APP_VERSION_CACHE + + env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip() + if env_version: + _APP_VERSION_CACHE = env_version + return env_version + + build_version_path = Path(__file__).resolve().parents[3] / ".build-version" + try: + build_version = build_version_path.read_text(encoding="utf-8").strip() + except OSError: + build_version = "" + if build_version: + _APP_VERSION_CACHE = build_version + return build_version + + tag = _run_git_command("describe", "--tags", "--abbrev=0") + sha = _run_git_command("rev-parse", "--short", "HEAD") + dirty = bool(_run_git_command("status", "--porcelain")) + + if tag and sha: + commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count") + if commits_since_tag and commits_since_tag != "0": + version = f"{tag}+{commits_since_tag}.g{sha}" + else: + version = tag + elif sha: + version = f"dev+g{sha}" + else: + version = "dev+unknown" + + if dirty: + version = f"{version}-dirty" + + _APP_VERSION_CACHE = version + return version + + async def _enforce_webapp_rate_limit( request: web.Request, *, @@ -727,6 +789,8 @@ async def index_route(request: web.Request) -> web.Response: "currency": cached["currency"], "language": cached["language"], "emailAuthEnabled": cached["email_auth_enabled"], + "appVersion": _resolve_app_version(), + "appRepositoryUrl": APP_REPOSITORY_URL, } html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) i18n_instance: Optional[object] = request.app.get("i18n") diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 67120af..5039cca 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -34,6 +34,17 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +async def _resolve_bot_username(bot: Optional[Bot]) -> Optional[str]: + """Best-effort resolution of the running bot's @username (cached by aiogram).""" + if bot is None: + return None + try: + me = await bot.me() + return getattr(me, "username", None) + except Exception: + return None + + def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]: if not strategy: return None @@ -181,12 +192,16 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, callback_data=f"user_action:refresh:{user_id}" ) - # Row 4: Quick links - builder.button( - text=_(key="user_card_open_profile_button"), - url=f"tg://user?id={user_id}" - ) - if referrer_id: + # Row 4: Quick links — only for users with a real Telegram profile + # (synthetic email-only users have a negative user_id with no tg profile). + has_self_link = user_id > 0 + has_referrer_link = bool(referrer_id) and referrer_id > 0 + if has_self_link: + builder.button( + text=_(key="user_card_open_profile_button"), + url=f"tg://user?id={user_id}" + ) + if has_referrer_link: builder.button( text=_(key="user_card_open_referrer_profile_button"), url=f"tg://user?id={referrer_id}" @@ -197,7 +212,7 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, text=_(key="admin_user_delete_button"), callback_data=f"user_action:delete_user:{user_id}" ) - + # Row 6: Navigation builder.button( text=_(key="admin_user_search_new_button"), @@ -207,9 +222,12 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, text=_(key="back_to_admin_panel_button"), callback_data="admin_action:main" ) - - quick_links_width = 2 if referrer_id else 1 - builder.adjust(2, 2, 2, quick_links_width, 1, 2) + + quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0) + if quick_links_count == 0: + builder.adjust(2, 2, 2, 1, 2) + else: + builder.adjust(2, 2, 2, quick_links_count, 1, 2) return builder @@ -241,10 +259,13 @@ async def _send_with_profile_link_fallback( await sender(**send_kwargs) -async def format_user_card(user: User, session: AsyncSession, +async def format_user_card(user: User, session: AsyncSession, subscription_service: SubscriptionService, i18n_instance, lang: str, - referral_service: Optional[ReferralService] = None) -> str: + referral_service: Optional[ReferralService] = None, + *, + settings: Optional[Settings] = None, + bot_username: Optional[str] = None) -> str: """Format user information as a detailed card""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -366,7 +387,55 @@ async def format_user_card(user: User, session: AsyncSession, except Exception as e: logging.error(f"Error getting user statistics for {user.user_id}: {e}") - + + # Links section: subscription page + both referral links. + link_lines: list[str] = [] + + # Subscription URL — the user's panel-issued config link. + if user.panel_user_uuid: + try: + panel_data = await subscription_service.panel_service.get_user_by_uuid(user.panel_user_uuid) + sub_url = panel_data.get("subscriptionUrl") if panel_data else None + if sub_url: + link_lines.append(f"{_('admin_user_subscription_url_label')} {sub_url}") + except Exception as exc_sub: + logging.warning("Failed to fetch subscriptionUrl for user %s: %s", user.user_id, exc_sub) + + # Referral links — bot deep-link + webapp deep-link. + if referral_service is not None and bot_username: + try: + bot_ref_link = await referral_service.generate_referral_link(session, bot_username, user.user_id) + if bot_ref_link: + link_lines.append(f"{_('admin_user_ref_bot_link_label')} {bot_ref_link}") + except Exception as exc_bot_ref: + logging.warning("Failed to build bot referral link for %s: %s", user.user_id, exc_bot_ref) + + if settings is not None: + webapp_base = getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) + if webapp_base: + try: + code = await user_dal.ensure_referral_code(session, user) + if code: + from urllib.parse import parse_qsl, urlsplit, urlunsplit + parts = urlsplit(webapp_base) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["ref"] = f"u{code}" + webapp_ref_link = urlunsplit(( + parts.scheme, + parts.netloc, + parts.path, + "&".join(f"{k}={v}" for k, v in query.items()), + parts.fragment, + )) + link_lines.append(f"{_('admin_user_ref_webapp_link_label')} {webapp_ref_link}") + except Exception as exc_web_ref: + logging.warning("Failed to build webapp referral link for %s: %s", user.user_id, exc_web_ref) + + if link_lines: + card_parts.append("") + card_parts.append(_('admin_user_links_section_title')) + card_parts.extend(link_lines) + return "\n".join(card_parts) @@ -400,7 +469,11 @@ async def process_user_search_handler(message: types.Message, state: FSMContext, # Format and send user card try: referral_service = ReferralService(settings, subscription_service, message.bot, i18n) - user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service) + bot_username = await _resolve_bot_username(message.bot) + user_card_text = await format_user_card( + user_model, session, subscription_service, i18n, current_lang, referral_service, + settings=settings, bot_username=bot_username, + ) keyboard = get_user_card_keyboard( user_model.user_id, i18n, @@ -662,7 +735,11 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User, from config.settings import Settings as _Settings _settings = _Settings() referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance) - user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service) + bot_username = await _resolve_bot_username(callback.message.bot) + user_card_text = await format_user_card( + fresh_user, session, subscription_service, i18n_instance, lang, referral_service, + settings=_settings, bot_username=bot_username, + ) keyboard = get_user_card_keyboard( fresh_user.user_id, i18n_instance, @@ -936,7 +1013,11 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo user = await user_dal.get_user_by_id(session, target_user_id) if user: referral_service = ReferralService(settings, subscription_service, message.bot, i18n) - user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service) + bot_username = await _resolve_bot_username(message.bot) + user_card_text = await format_user_card( + user, session, subscription_service, i18n, current_lang, referral_service, + settings=settings, bot_username=bot_username, + ) keyboard = get_user_card_keyboard( user.user_id, i18n, @@ -1045,7 +1126,11 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte async with PanelApiService(settings) as panel_service: subscription_service = SubscriptionService(settings, panel_service) referral_service = ReferralService(settings, subscription_service, bot, i18n) - user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service) + bot_username = await _resolve_bot_username(bot) + user_card_text = await format_user_card( + target_user, session, subscription_service, i18n, current_lang, referral_service, + settings=settings, bot_username=bot_username, + ) keyboard = get_user_card_keyboard( target_user.user_id, i18n, @@ -1334,7 +1419,11 @@ async def user_card_from_list_handler(callback: types.CallbackQuery, try: from bot.services.referral_service import ReferralService referral_service = ReferralService(settings, subscription_service, bot, i18n) - user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service) + bot_username = await _resolve_bot_username(bot) + user_card_text = await format_user_card( + user, session, subscription_service, i18n, current_lang, referral_service, + settings=settings, bot_username=bot_username, + ) markup = keyboard.as_markup() await _send_with_profile_link_fallback( diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 212e238..fdfc181 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -46,29 +46,29 @@ class NotificationService: user_id: int, referrer_id: Optional[int] = None, ) -> InlineKeyboardMarkup: - """Create inline keyboard with links to user (and referrer) profiles.""" - buttons = [ - [ - InlineKeyboardButton( - text=translate( - "log_open_profile_link", - ), - url=f"tg://user?id={user_id}", - ) - ] - ] + """Create inline keyboard with links to user (and referrer) profiles. - if referrer_id: + Email-only users have a synthetic negative ``user_id`` with no + Telegram profile, so we skip the tg:// button for them. + """ + buttons = [] + if user_id and user_id > 0: buttons.append([ InlineKeyboardButton( - text=translate( - "log_open_referrer_profile_button", - ), + text=translate("log_open_profile_link"), + url=f"tg://user?id={user_id}", + ) + ]) + + if referrer_id and referrer_id > 0: + buttons.append([ + InlineKeyboardButton( + text=translate("log_open_referrer_profile_button"), url=f"tg://user?id={referrer_id}", ) ]) - return InlineKeyboardMarkup(inline_keyboard=buttons) + return InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None async def _send_to_log_channel( self, diff --git a/locales/en.json b/locales/en.json index 6d9cf1a..87e1457 100644 --- a/locales/en.json +++ b/locales/en.json @@ -403,6 +403,10 @@ "admin_user_referral_revenue_label": "💸 Referral Revenue:", "admin_user_invited_friends_label": "👥 Friends invited:", "admin_user_ref_purchased_label": "💳 Purchased subscription:", + "admin_user_links_section_title": "🔗 Links", + "admin_user_subscription_url_label": "📡 Subscription:", + "admin_user_ref_bot_link_label": "🤖 Referral link (bot):", + "admin_user_ref_webapp_link_label": "🌐 Referral link (web):", "admin_user_subscription_active_until": "⏰ Active until:", "admin_user_subscription_error": "Loading error", "admin_promo_management_button": "🎟 Promo Management", diff --git a/locales/ru.json b/locales/ru.json index e112809..e78c72f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -403,6 +403,10 @@ "admin_user_referral_revenue_label": "💸 Доход по рефералам:", "admin_user_invited_friends_label": "👥 Приглашено друзей:", "admin_user_ref_purchased_label": "💳 Купили подписку:", + "admin_user_links_section_title": "🔗 Ссылки", + "admin_user_subscription_url_label": "📡 Подписка:", + "admin_user_ref_bot_link_label": "🤖 Реф. ссылка (бот):", + "admin_user_ref_webapp_link_label": "🌐 Реф. ссылка (веб):", "admin_user_subscription_active_until": "⏰ Действует до:", "admin_user_subscription_error": "Ошибка загрузки", "admin_promo_management_button": "🎟 Управление промокодами",