feat: tune visual of web app admin panel

This commit is contained in:
3252a8
2026-05-10 20:19:43 +03:00
parent ff59eda3d7
commit e01f56bc34
11 changed files with 918 additions and 188 deletions
-2
View File
@@ -1,5 +1,4 @@
# Git # Git
.git
.gitignore .gitignore
.github .github
.gitattributes .gitattributes
@@ -11,7 +10,6 @@ README.md
scratch_*.py scratch_*.py
*.local.* *.local.*
node_modules/ node_modules/
.git/
# CI # CI
+43 -2
View File
@@ -27,12 +27,22 @@ FROM python:3.12-slim
WORKDIR /app 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 RUN useradd -u 10001 -m appuser
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages 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 . . COPY . .
# Replace template assets with freshly built ones # 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 \ COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
bot/app/web/templates/ 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 RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
+77 -7
View File
@@ -33,8 +33,10 @@ from bot.services.settings_override_service import (
current_value, current_value,
update_overrides, update_overrides,
) )
from bot.services.referral_service import ReferralService
from bot.utils import MessageContent, send_message_via_queue from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager from bot.utils.message_queue import get_queue_manager
from urllib.parse import parse_qsl, urlsplit, urlunsplit
from config.settings import Settings from config.settings import Settings
from config.tariffs_config import TariffsConfig from config.tariffs_config import TariffsConfig
from db.dal import ( from db.dal import (
@@ -523,6 +525,7 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request) _require_admin_user_id(request)
target_id = int(request.match_info["user_id"]) target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"] async_session_factory: sessionmaker = request.app["async_session_factory"]
settings: Settings = request.app["settings"]
async with async_session_factory() as session: async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id) 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) log_count = await message_log_dal.count_user_message_logs(session, target_id)
avatar_keys = await _bulk_user_avatar_keys(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 = _serialize_user(user)
serialized_user["avatar_url"] = ( serialized_user["avatar_url"] = (
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}" 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), "total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments], "recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0), "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: async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request) _require_admin_user_id(request)
target_id = int(request.match_info["user_id"]) 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/stats", admin_stats_route)
router.add_get("/api/admin/users", admin_users_list_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+}", admin_user_detail_route)
router.add_get("/api/admin/users/{user_id:\\d+}/avatar", admin_user_avatar_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+}/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+}/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+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:\\d+}/extend", admin_user_extend_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_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", admin_payments_list_route)
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route) router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
+28 -5
View File
@@ -98,6 +98,8 @@
telegramLoginBotId: 1234567890, telegramLoginBotId: 1234567890,
telegramOAuthClientId: 1234567890, telegramOAuthClientId: 1234567890,
telegramOAuthRequestAccess: ["write"], telegramOAuthRequestAccess: ["write"],
appVersion: "dev+local",
appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop",
}, },
data: { data: {
ok: true, ok: true,
@@ -759,18 +761,25 @@
function adminSectionFromPath(pathname) { function adminSectionFromPath(pathname) {
const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, ""); 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]; if (m && ADMIN_SECTIONS.has(m[1])) return m[1];
return "stats"; 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; if (window.location.protocol === "file:") return;
const normalized = normalizeSection(section); const normalized = normalizeSection(section);
let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home;
if (normalized === "admin") { if (normalized === "admin") {
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats"; 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; if (window.location.pathname === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; 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" }, { payment_id: 11, amount: 790, currency: "RUB", provider: "stars", status: "succeeded", created_at: "2026-04-01T14:15:00Z" },
], ],
log_count: 18, 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") { if (path === "/admin/tariffs") {
@@ -2180,10 +2195,12 @@
syncSectionPath("settings"); syncSectionPath("settings");
} }
function handleAdminSectionChange(adminSection) { function handleAdminSectionChange(adminSection, adminUserId = null) {
if (screen !== "admin") return; if (screen !== "admin") return;
if (window.location.protocol === "file:") 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; if (window.location.pathname === targetPath) return;
window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`); window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`);
} }
@@ -2937,9 +2954,15 @@
onClose={closeAdminPanel} onClose={closeAdminPanel}
onToast={(text) => showToast(text)} onToast={(text) => showToast(text)}
initialSection={adminSectionFromPath(window.location.pathname)} initialSection={adminSectionFromPath(window.location.pathname)}
initialUserId={adminUserIdFromPath(window.location.pathname)}
onSectionChange={handleAdminSectionChange} onSectionChange={handleAdminSectionChange}
onSettingsSaved={handleSettingsSaved} onSettingsSaved={handleSettingsSaved}
onTariffsSaved={handleTariffsSaved} onTariffsSaved={handleTariffsSaved}
brandTitle={brandTitle}
logoUrl={CFG.logoUrl}
logoEmoji={brandEmoji}
appVersion={CFG.appVersion}
appRepositoryUrl={CFG.appRepositoryUrl}
/> />
{:else} {:else}
<div class="phone-screen" class:home-screen={screen === "home"}> <div class="phone-screen" class:home-screen={screen === "home"}>
+341 -124
View File
@@ -7,12 +7,15 @@
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Coins, Coins,
Copy,
ExternalLink,
CreditCard, CreditCard,
Database, Database,
Download, Download,
Eye, Eye,
EyeOff, EyeOff,
FileText, FileText,
Link2,
LayoutDashboard, LayoutDashboard,
Megaphone, Megaphone,
Menu, Menu,
@@ -35,15 +38,22 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { Accordion, Label, Select, Separator, Switch, Tabs } from "bits-ui"; import { Accordion, Label, Select, Separator, Switch, Tabs } from "bits-ui";
import BrandMark from "../BrandMark.svelte";
import Dialog from "../lib/components/ui/dialog.svelte"; import Dialog from "../lib/components/ui/dialog.svelte";
export let api; export let api;
export let onClose = () => {}; export let onClose = () => {};
export let onToast = () => {}; export let onToast = () => {};
export let initialSection = "stats"; export let initialSection = "stats";
export let initialUserId = null;
export let onSectionChange = () => {}; export let onSectionChange = () => {};
export let onSettingsSaved = () => {}; export let onSettingsSaved = () => {};
export let onTariffsSaved = () => {}; 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 = [ const NAV_GROUPS = [
{ {
@@ -202,22 +212,41 @@
} }
active = next; active = next;
sidebarOpen = false; sidebarOpen = false;
if (openedUser) {
openedUser = null;
openedUserDetail = null;
userDeleteOpen = false;
userBanConfirmOpen = false;
}
onSectionChange(next); onSectionChange(next);
loadActive(); loadActive();
} }
function _readSectionFromPath() { function _readSectionFromPath() {
if (typeof window === "undefined") return "stats"; 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"); 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() { function _onPopState() {
const next = _readSectionFromPath(); const next = _readSectionFromPath();
if (active === next) return; if (active !== next) {
active = next; active = next;
sidebarOpen = false; sidebarOpen = false;
loadActive(); 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() { async function loadActive() {
@@ -282,31 +311,65 @@
} }
} }
async function openUser(user) { async function openUser(userOrId, opts = {}) {
openedUser = user; 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; openedUserDetail = null;
userMessageDraft = ""; userMessageDraft = "";
userExtendDays = 30; userExtendDays = 30;
userDetailLoading = true; userDetailLoading = true;
userDetailTab = "profile"; userDetailTab = "subscription";
if (!opts.skipPush) _pushUserPath(userId);
try { try {
const res = await api(`/admin/users/${user.user_id}`); const res = await api(`/admin/users/${userId}`);
if (res?.ok) { if (res?.ok) {
openedUserDetail = res; openedUserDetail = res;
if (res.user) openedUser = { ...res.user, ...openedUser, ...res.user };
} else { } else {
flash(res?.error || "load_failed"); flash(res?.error || "load_failed");
openedUser = null; openedUser = null;
if (!opts.skipPush) _pushUserPath(null);
} }
} finally { } finally {
userDetailLoading = false; userDetailLoading = false;
} }
} }
function closeUser() { function closeUser(opts = {}) {
const wasOpen = Boolean(openedUser);
openedUser = null; openedUser = null;
openedUserDetail = null; openedUserDetail = null;
userDeleteOpen = false; userDeleteOpen = false;
userBanConfirmOpen = 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() { function requestBanToggle() {
@@ -369,7 +432,7 @@
}); });
if (res?.ok) { if (res?.ok) {
flash(`Подписка продлена на ${days} дн.`); flash(`Подписка продлена на ${days} дн.`);
await openUser(openedUser); await openUser(openedUser, { skipPush: true });
} else flash(res?.error || "Ошибка"); } else flash(res?.error || "Ошибка");
} finally { } finally {
userActionBusy = false; userActionBusy = false;
@@ -1212,6 +1275,9 @@
window.addEventListener("popstate", _onPopState); window.addEventListener("popstate", _onPopState);
} }
loadActive(); loadActive();
if (active === "users" && initialUserId) {
openUser(initialUserId, { skipPush: true });
}
return () => { return () => {
if (_compactMql) { if (_compactMql) {
if (_compactMql.removeEventListener) _compactMql.removeEventListener("change", _onCompactChange); if (_compactMql.removeEventListener) _compactMql.removeEventListener("change", _onCompactChange);
@@ -1322,10 +1388,10 @@
<aside class="admin-sidebar" aria-label="Навигация админки"> <aside class="admin-sidebar" aria-label="Навигация админки">
<div class="admin-sidebar-brand"> <div class="admin-sidebar-brand">
<span class="admin-brand-mark"><Shield size={18} /></span> <BrandMark class="admin-brand-mark" logoUrl={logoUrl} emoji={logoEmoji} />
<div> <div>
<strong>Админ-панель</strong> <strong class="admin-brand-title">{brandTitle}</strong>
<small>Web App</small> <small>Админ-панель</small>
</div> </div>
<button type="button" class="admin-btn admin-btn-icon admin-btn-ghost" on:click={onClose} aria-label="Выйти"> <button type="button" class="admin-btn admin-btn-icon admin-btn-ghost" on:click={onClose} aria-label="Выйти">
<ArrowLeft size={16} /> <ArrowLeft size={16} />
@@ -1351,8 +1417,16 @@
{/each} {/each}
<div class="admin-sidebar-footer"> <div class="admin-sidebar-footer">
<span>Минприложение</span> <a
<span>v1 · {new Date().getFullYear()}</span> class="admin-version-link"
href={appRepositoryUrl}
target="_blank"
rel="noopener noreferrer"
title="GitHub"
>
<span>remnawave-minishop</span>
<span>{appVersion || "dev+local"}</span>
</a>
</div> </div>
</aside> </aside>
@@ -1996,14 +2070,14 @@
<Tabs.Content value="general" class="admin-tabs-content"> <Tabs.Content value="general" class="admin-tabs-content">
<div class="admin-form-row admin-form-row-2"> <div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>Ключ</span> <span>Ключ тарифа</span>
<small>Стабильный ID для платежей и подписок</small> <small>Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется</small>
<input class="input" type="text" placeholder="standard" bind:value={tariffDraft.key} /> <input class="input" type="text" placeholder="standard" bind:value={tariffDraft.key} />
</Label.Root> </Label.Root>
<div class="admin-field-label"> <div class="admin-field-label">
<span>Модель тарификации</span> <span>Модель тарификации</span>
<small>Период — фикс. длительность; Трафик — оплата за GB</small> <small><b>Период</b> — пользователь покупает фиксированный срок (1/3/12 мес. и т.д.). <b>Трафик</b> — пользователь покупает пакеты гигабайт по фиксированной цене за GB</small>
<Select.Root type="single" bind:value={tariffDraft.billing_model}> <Select.Root type="single" bind:value={tariffDraft.billing_model}>
<Select.Trigger class="admin-select-trigger" aria-label="Модель"> <Select.Trigger class="admin-select-trigger" aria-label="Модель">
<span>{tariffDraft.billing_model === "traffic" ? "Трафик" : "Период"}</span> <span>{tariffDraft.billing_model === "traffic" ? "Трафик" : "Период"}</span>
@@ -2034,8 +2108,8 @@
<Switch.Thumb class="admin-switch-thumb" /> <Switch.Thumb class="admin-switch-thumb" />
</Switch.Root> </Switch.Root>
<Label.Root class="admin-action-label"> <Label.Root class="admin-action-label">
<strong>{tariffDraft.enabled ? "Тариф включён" : "Тариф выключен"}</strong> <strong>{tariffDraft.enabled ? "Тариф виден на витрине" : "Тариф скрыт от пользователей"}</strong>
<small>Скрытые тарифы не отображаются на витрине</small> <small>Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать</small>
</Label.Root> </Label.Root>
</div> </div>
@@ -2062,8 +2136,8 @@
</div> </div>
<div class="admin-field-label"> <div class="admin-field-label">
<span>Основные Internal Squads</span> <span>Базовые Internal Squads</span>
<small>{panelSquadsLoading ? "Загружаю список из панели…" : "Выберите сквады из Remnawave"}</small> <small>{panelSquadsLoading ? "Загружаю список из панели…" : "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько"}</small>
<Select.Root <Select.Root
type="single" type="single"
bind:value={selectedBaseSquad} bind:value={selectedBaseSquad}
@@ -2098,20 +2172,20 @@
<div class="admin-form-row admin-form-row-2"> <div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>Базовый лимит устройств</span> <span>Лимит устройств (HWID)</span>
<small>Пусто — значение из env, 0 — безлимит</small> <small>Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, <code>0</code> — без ограничений</small>
<input class="input" type="number" min="0" placeholder="5" bind:value={tariffDraft.hwid_device_limit} /> <input class="input" type="number" min="0" placeholder="5" bind:value={tariffDraft.hwid_device_limit} />
</Label.Root> </Label.Root>
{#if tariffDraft.billing_model === "period"} {#if tariffDraft.billing_model === "period"}
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>Месячный лимит, GB</span> <span>Месячный лимит трафика, GB</span>
<small>0 — безлимит</small> <small>Сколько GB включено в тариф на каждый месяц. <code>0</code> — безлимитный трафик. Сверху можно докупать пакеты на вкладке «Докупки»</small>
<input class="input" type="number" min="0" step="0.1" bind:value={tariffDraft.monthly_gb} /> <input class="input" type="number" min="0" step="0.1" placeholder="100" bind:value={tariffDraft.monthly_gb} />
</Label.Root> </Label.Root>
{:else} {:else}
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>Курс конвертации, RUB/GB</span> <span>Курс конвертации, ₽ за 1 GB</span>
<small>Нужен для перехода period → traffic</small> <small>По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»</small>
<input class="input" type="number" min="0" step="0.01" placeholder="20" bind:value={tariffDraft.conversion_rate_rub_per_gb} /> <input class="input" type="number" min="0" step="0.01" placeholder="20" bind:value={tariffDraft.conversion_rate_rub_per_gb} />
</Label.Root> </Label.Root>
{/if} {/if}
@@ -2121,12 +2195,15 @@
<Tabs.Content value="premium" class="admin-tabs-content"> <Tabs.Content value="premium" class="admin-tabs-content">
<section class="admin-editor-section"> <section class="admin-editor-section">
<header class="admin-editor-section-head"> <header class="admin-editor-section-head">
<strong>Premium-сквад и отдельный лимит</strong> <div class="admin-editor-section-title">
<strong>Premium-доступ и отдельный счётчик трафика</strong>
<small>Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно</small>
</div>
</header> </header>
<div class="admin-form-row admin-form-row-2"> <div class="admin-form-row admin-form-row-2">
<div class="admin-field-label"> <div class="admin-field-label">
<span>Premium Internal Squads</span> <span>Premium Internal Squads</span>
<small>Ноды для учета трафика будут взяты из accessible nodes этих сквадов</small> <small>Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes</small>
<Select.Root <Select.Root
type="single" type="single"
bind:value={selectedPremiumSquad} bind:value={selectedPremiumSquad}
@@ -2159,8 +2236,8 @@
</div> </div>
</div> </div>
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>Premium лимит, GB/мес.</span> <span>Месячный лимит premium-трафика, GB</span>
<small>0 или пусто — нет отдельного premium-лимита</small> <small>Сколько GB через premium-сквады включено в тариф каждый месяц. <code>0</code> или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)</small>
<input class="input" type="number" min="0" step="0.1" placeholder="50" bind:value={tariffDraft.premium_monthly_gb} /> <input class="input" type="number" min="0" step="0.1" placeholder="50" bind:value={tariffDraft.premium_monthly_gb} />
</Label.Root> </Label.Root>
</div> </div>
@@ -2168,29 +2245,46 @@
<section class="admin-editor-section"> <section class="admin-editor-section">
<header class="admin-editor-section-head"> <header class="admin-editor-section-head">
<strong>Докупка premium-трафика</strong> <div class="admin-editor-section-title">
<strong>Докупка premium-трафика</strong>
<small>Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал</small>
</div>
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}><Plus size={12} /> RUB</button> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ₽</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Stars</button> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ⭐</button>
</div> </div>
</header> </header>
<div class="admin-package-columns"> <div class="admin-package-columns">
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">RUB</span> <span class="admin-row-editor-caption">Оплата рублями</span>
{#if tariffDraft.premiumTopupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>Объём, GB</span>
<span>Цена, ₽</span>
<span></span>
</div>
{/if}
{#each tariffDraft.premiumTopupRubRows as row, index} {#each tariffDraft.premiumTopupRubRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Premium GB" /> <input class="input" type="number" min="0.1" step="0.1" placeholder="10" bind:value={row.gb} aria-label="Объём premium-пакета в GB" />
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" /> <input class="input" type="number" min="0" step="0.01" placeholder="199" bind:value={row.price} aria-label="Цена premium-пакета в рублях" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button> <button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div> </div>
{/each} {/each}
</div> </div>
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">Stars</span> <span class="admin-row-editor-caption">Оплата Telegram Stars</span>
{#if tariffDraft.premiumTopupStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>Объём, GB</span>
<span>Цена, ⭐</span>
<span></span>
</div>
{/if}
{#each tariffDraft.premiumTopupStarsRows as row, index} {#each tariffDraft.premiumTopupStarsRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Premium GB" /> <input class="input" type="number" min="0.1" step="0.1" placeholder="10" bind:value={row.gb} aria-label="Объём premium-пакета в GB" />
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" /> <input class="input" type="number" min="0" step="1" placeholder="100" bind:value={row.price} aria-label="Цена premium-пакета в Telegram Stars" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button> <button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div> </div>
{/each} {/each}
@@ -2203,53 +2297,80 @@
{#if tariffDraft.billing_model === "period"} {#if tariffDraft.billing_model === "period"}
<section class="admin-editor-section"> <section class="admin-editor-section">
<header class="admin-editor-section-head"> <header class="admin-editor-section-head">
<strong>Периоды и цены</strong> <div class="admin-editor-section-title">
<strong>Периоды подписки и цены</strong>
<small>Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит</small>
</div>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}>
<Plus size={13} /> Период <Plus size={13} /> Период
</button> </button>
</header> </header>
{#if !tariffDraft.periodRows.length} {#if !tariffDraft.periodRows.length}
<p class="admin-muted">Добавьте хотя бы один период.</p> <p class="admin-muted">Добавьте хотя бы один период — без него тариф не появится на витрине.</p>
{/if} {:else}
<div class="admin-row-editor"> <div class="admin-row-editor">
{#each tariffDraft.periodRows as row, index} <div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
<div class="admin-row-editor-line admin-row-editor-4"> <span>Срок, мес.</span>
<input class="input" type="number" min="1" placeholder="Мес." bind:value={row.months} aria-label="Период (месяцы)" /> <span>Цена, ₽</span>
<input class="input" type="number" min="0" step="0.01" placeholder="RUB" bind:value={row.rub} aria-label="Цена RUB" /> <span>Цена, ⭐ Stars</span>
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.stars} aria-label="Цена Stars" /> <span></span>
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("periodRows", index)} aria-label="Удалить">
<Trash2 size={13} />
</button>
</div> </div>
{/each} {#each tariffDraft.periodRows as row, index}
</div> <div class="admin-row-editor-line admin-row-editor-4">
<input class="input" type="number" min="1" placeholder="1" bind:value={row.months} aria-label="Срок (месяцы)" />
<input class="input" type="number" min="0" step="0.01" placeholder="299" bind:value={row.rub} aria-label="Цена в рублях" />
<input class="input" type="number" min="0" step="1" placeholder="150" bind:value={row.stars} aria-label="Цена в Telegram Stars" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("periodRows", index)} aria-label="Удалить">
<Trash2 size={13} />
</button>
</div>
{/each}
</div>
{/if}
</section> </section>
{:else} {:else}
<section class="admin-editor-section"> <section class="admin-editor-section">
<header class="admin-editor-section-head"> <header class="admin-editor-section-head">
<strong>Пакеты трафика</strong> <div class="admin-editor-section-title">
<strong>Пакеты трафика</strong>
<small>Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»</small>
</div>
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("trafficRubRows", { gb: 10, price: "" })}><Plus size={12} /> RUB</button> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("trafficRubRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ₽</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("trafficStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Stars</button> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("trafficStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ⭐</button>
</div> </div>
</header> </header>
<div class="admin-package-columns"> <div class="admin-package-columns">
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">RUB</span> <span class="admin-row-editor-caption">Оплата рублями</span>
{#if tariffDraft.trafficRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>Объём, GB</span>
<span>Цена, ₽</span>
<span></span>
</div>
{/if}
{#each tariffDraft.trafficRubRows as row, index} {#each tariffDraft.trafficRubRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Объём GB" /> <input class="input" type="number" min="0.1" step="0.1" placeholder="50" bind:value={row.gb} aria-label="Объём пакета в GB" />
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" /> <input class="input" type="number" min="0" step="0.01" placeholder="299" bind:value={row.price} aria-label="Цена пакета в рублях" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("trafficRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button> <button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("trafficRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div> </div>
{/each} {/each}
</div> </div>
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">Stars</span> <span class="admin-row-editor-caption">Оплата Telegram Stars</span>
{#if tariffDraft.trafficStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>Объём, GB</span>
<span>Цена, ⭐</span>
<span></span>
</div>
{/if}
{#each tariffDraft.trafficStarsRows as row, index} {#each tariffDraft.trafficStarsRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Объём GB" /> <input class="input" type="number" min="0.1" step="0.1" placeholder="50" bind:value={row.gb} aria-label="Объём пакета в GB" />
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" /> <input class="input" type="number" min="0" step="1" placeholder="150" bind:value={row.price} aria-label="Цена пакета в Telegram Stars" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("trafficStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button> <button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("trafficStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div> </div>
{/each} {/each}
@@ -2263,29 +2384,46 @@
{#if tariffDraft.billing_model === "period"} {#if tariffDraft.billing_model === "period"}
<section class="admin-editor-section"> <section class="admin-editor-section">
<header class="admin-editor-section-head"> <header class="admin-editor-section-head">
<strong>Докупка трафика для тарифа</strong> <div class="admin-editor-section-title">
<strong>Докупка трафика поверх месячного лимита</strong>
<small>Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки</small>
</div>
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("topupRubRows", { gb: 10, price: "" })}><Plus size={12} /> RUB</button> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("topupRubRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ₽</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("topupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Stars</button> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("topupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ⭐</button>
</div> </div>
</header> </header>
<div class="admin-package-columns"> <div class="admin-package-columns">
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">RUB</span> <span class="admin-row-editor-caption">Оплата рублями</span>
{#if tariffDraft.topupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>Объём, GB</span>
<span>Цена, ₽</span>
<span></span>
</div>
{/if}
{#each tariffDraft.topupRubRows as row, index} {#each tariffDraft.topupRubRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Объём GB" /> <input class="input" type="number" min="0.1" step="0.1" placeholder="20" bind:value={row.gb} aria-label="Объём пакета в GB" />
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" /> <input class="input" type="number" min="0" step="0.01" placeholder="149" bind:value={row.price} aria-label="Цена пакета в рублях" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("topupRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button> <button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("topupRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div> </div>
{/each} {/each}
</div> </div>
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">Stars</span> <span class="admin-row-editor-caption">Оплата Telegram Stars</span>
{#if tariffDraft.topupStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>Объём, GB</span>
<span>Цена, ⭐</span>
<span></span>
</div>
{/if}
{#each tariffDraft.topupStarsRows as row, index} {#each tariffDraft.topupStarsRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Объём GB" /> <input class="input" type="number" min="0.1" step="0.1" placeholder="20" bind:value={row.gb} aria-label="Объём пакета в GB" />
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" /> <input class="input" type="number" min="0" step="1" placeholder="75" bind:value={row.price} aria-label="Цена пакета в Telegram Stars" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("topupStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button> <button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("topupStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div> </div>
{/each} {/each}
@@ -2293,36 +2431,53 @@
</div> </div>
</section> </section>
{:else} {:else}
<p class="admin-muted">Для трафиковой модели докупки не нужны — настройте пакеты трафика на вкладке «Цены».</p> <p class="admin-muted">Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания.</p>
{/if} {/if}
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="hwid" class="admin-tabs-content"> <Tabs.Content value="hwid" class="admin-tabs-content">
<section class="admin-editor-section"> <section class="admin-editor-section">
<header class="admin-editor-section-head"> <header class="admin-editor-section-head">
<strong>Пакеты HWID-устройств</strong> <div class="admin-editor-section-title">
<strong>Пакеты дополнительных устройств (HWID)</strong>
<small>Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»</small>
</div>
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("hwidRubRows", { count: 1, price: "" })}><Plus size={12} /> RUB</button> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("hwidRubRows", { count: 1, price: "" })}><Plus size={12} /> Пакет ₽</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("hwidStarsRows", { count: 1, price: "" })}><Plus size={12} /> Stars</button> <button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("hwidStarsRows", { count: 1, price: "" })}><Plus size={12} /> Пакет ⭐</button>
</div> </div>
</header> </header>
<div class="admin-package-columns"> <div class="admin-package-columns">
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">RUB</span> <span class="admin-row-editor-caption">Оплата рублями</span>
{#if tariffDraft.hwidRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>+ устройств</span>
<span>Цена, ₽</span>
<span></span>
</div>
{/if}
{#each tariffDraft.hwidRubRows as row, index} {#each tariffDraft.hwidRubRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input class="input" type="number" min="1" step="1" placeholder="Шт." bind:value={row.count} aria-label="Количество устройств" /> <input class="input" type="number" min="1" step="1" placeholder="1" bind:value={row.count} aria-label="Сколько устройств добавляет пакет" />
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" /> <input class="input" type="number" min="0" step="0.01" placeholder="99" bind:value={row.price} aria-label="Цена пакета в рублях" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("hwidRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button> <button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("hwidRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div> </div>
{/each} {/each}
</div> </div>
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">Stars</span> <span class="admin-row-editor-caption">Оплата Telegram Stars</span>
{#if tariffDraft.hwidStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>+ устройств</span>
<span>Цена, ⭐</span>
<span></span>
</div>
{/if}
{#each tariffDraft.hwidStarsRows as row, index} {#each tariffDraft.hwidStarsRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input class="input" type="number" min="1" step="1" placeholder="Шт." bind:value={row.count} aria-label="Количество устройств" /> <input class="input" type="number" min="1" step="1" placeholder="1" bind:value={row.count} aria-label="Сколько устройств добавляет пакет" />
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" /> <input class="input" type="number" min="0" step="1" placeholder="50" bind:value={row.price} aria-label="Цена пакета в Telegram Stars" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("hwidStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button> <button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("hwidStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div> </div>
{/each} {/each}
@@ -2368,54 +2523,114 @@
{#if userDetailLoading || !openedUserDetail} {#if userDetailLoading || !openedUserDetail}
<p class="admin-muted">Загрузка…</p> <p class="admin-muted">Загрузка…</p>
{:else} {:else}
<div class="admin-user-summary"> <div class="admin-user-dialog-body">
<span class="admin-avatar admin-avatar-lg"> <aside class="admin-user-aside">
{#if resolvedAvatarUrl(openedUser)} <div class="admin-user-summary">
<img src={resolvedAvatarUrl(openedUser)} alt="" loading="lazy" referrerpolicy="no-referrer" /> <span class="admin-avatar admin-avatar-lg">
{:else} {#if resolvedAvatarUrl(openedUser)}
<span>{userInitials(openedUser)}</span> <img src={resolvedAvatarUrl(openedUser)} alt="" loading="lazy" referrerpolicy="no-referrer" />
{/if} {:else}
</span> <span>{userInitials(openedUser)}</span>
<div class="admin-user-summary-meta"> {/if}
<strong>{userDisplayName(openedUser)}</strong> </span>
<small>{userSecondaryName(openedUser)}</small> <div class="admin-user-summary-meta">
<div class="admin-user-summary-tags"> <strong>{userDisplayName(openedUser)}</strong>
{#if openedUser.is_banned} <small>{userSecondaryName(openedUser)}</small>
<span class="admin-badge admin-badge-danger">Бан</span> <div class="admin-user-summary-tags">
{:else} {#if openedUser.is_banned}
<span class="admin-badge admin-badge-success">Активен</span> <span class="admin-badge admin-badge-danger">Бан</span>
{/if} {:else}
{#if openedUserDetail.active_subscription} <span class="admin-badge admin-badge-success">Активен</span>
<span class="admin-badge admin-badge-success">Подписка</span> {/if}
{:else} {#if openedUserDetail.active_subscription}
<span class="admin-badge admin-badge-muted">Без подписки</span> <span class="admin-badge admin-badge-success">Подписка</span>
{/if} {:else}
<span class="admin-badge admin-badge-muted">Заплачено: {fmtMoney(openedUserDetail.total_paid)}</span> <span class="admin-badge admin-badge-muted">Без подписки</span>
{/if}
</div>
</div>
</div> </div>
</div>
</div>
<Tabs.Root bind:value={userDetailTab} class="admin-tabs-root"> <div class="admin-user-stats">
<Tabs.List class="admin-tabs-list"> <div class="admin-user-stat">
<Tabs.Trigger value="profile" class="admin-tabs-trigger">Профиль</Tabs.Trigger> <span>Заплачено</span>
<Tabs.Trigger value="subscription" class="admin-tabs-trigger">Подписка</Tabs.Trigger> <strong>{fmtMoney(openedUserDetail.total_paid)}</strong>
<Tabs.Trigger value="activity" class="admin-tabs-trigger">Активность</Tabs.Trigger> </div>
<Tabs.Trigger value="actions" class="admin-tabs-trigger">Действия</Tabs.Trigger> <div class="admin-user-stat">
</Tabs.List> <span>Логов</span>
<strong>{openedUserDetail.log_count}</strong>
</div>
</div>
<Tabs.Content value="profile" class="admin-tabs-content"> <div class="admin-subsection-title">Профиль</div>
<ul class="admin-meta-list"> <ul class="admin-meta-list">
<li><span>ID</span><strong>{openedUser.user_id}</strong></li> <li><span>ID</span><strong>{openedUser.user_id}</strong></li>
<li><span>Telegram ID</span><strong>{openedUser.telegram_id || "—"}</strong></li> <li><span>Telegram ID</span><strong>{openedUser.telegram_id || "—"}</strong></li>
<li><span>Username</span><strong>{openedUser.username ? "@" + openedUser.username : "—"}</strong></li> <li><span>Username</span><strong>{openedUser.username ? "@" + openedUser.username : "—"}</strong></li>
<li><span>Email</span><strong>{openedUser.email || "—"}</strong></li> <li><span>Email</span><strong class="admin-meta-truncate">{openedUser.email || "—"}</strong></li>
<li><span>Регистрация</span><strong>{fmtDate(openedUser.registration_date)}</strong></li> <li><span>Регистрация</span><strong>{fmtDate(openedUser.registration_date)}</strong></li>
<li><span>Реф. код</span><strong>{openedUserDetail.user?.referral_code || "—"}</strong></li> <li><span>Реф. код</span><strong>{openedUserDetail.referral?.code || openedUserDetail.user?.referral_code || "—"}</strong></li>
<li><span>Логов</span><strong>{openedUserDetail.log_count}</strong></li>
</ul> </ul>
</Tabs.Content>
<Tabs.Content value="subscription" class="admin-tabs-content"> {#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
<div class="admin-subsection-title">Ссылки</div>
<div class="admin-link-list">
{#if openedUserDetail.subscription_url}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label">Подписка</span>
<a class="admin-link-row-url" href={openedUserDetail.subscription_url} target="_blank" rel="noopener">
{openedUserDetail.subscription_url}
</a>
</div>
<button type="button" class="admin-btn admin-btn-icon" title="Скопировать" on:click={() => copyToClipboard(openedUserDetail.subscription_url, "Ссылка на подписку скопирована")}>
<Copy size={14} />
</button>
</div>
{/if}
{#if openedUserDetail.referral?.bot_link}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label">Реф. ссылка (бот)</span>
<a class="admin-link-row-url" href={openedUserDetail.referral.bot_link} target="_blank" rel="noopener">
{openedUserDetail.referral.bot_link}
</a>
</div>
<button type="button" class="admin-btn admin-btn-icon" title="Скопировать" on:click={() => copyToClipboard(openedUserDetail.referral.bot_link, "Реф. ссылка скопирована")}>
<Copy size={14} />
</button>
</div>
{/if}
{#if openedUserDetail.referral?.webapp_link}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label">Реф. ссылка (веб)</span>
<a class="admin-link-row-url" href={openedUserDetail.referral.webapp_link} target="_blank" rel="noopener">
{openedUserDetail.referral.webapp_link}
</a>
</div>
<button type="button" class="admin-btn admin-btn-icon" title="Скопировать" on:click={() => copyToClipboard(openedUserDetail.referral.webapp_link, "Реф. ссылка скопирована")}>
<Copy size={14} />
</button>
</div>
{/if}
</div>
{/if}
<button type="button" class="admin-btn admin-user-link-btn" on:click={copyUserDeepLink}>
<Link2 size={14} /> Скопировать ссылку на карточку
</button>
</aside>
<main class="admin-user-main">
<Tabs.Root bind:value={userDetailTab} class="admin-tabs-root">
<Tabs.List class="admin-tabs-list">
<Tabs.Trigger value="subscription" class="admin-tabs-trigger">Подписка</Tabs.Trigger>
<Tabs.Trigger value="activity" class="admin-tabs-trigger">Активность</Tabs.Trigger>
<Tabs.Trigger value="actions" class="admin-tabs-trigger">Действия</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="subscription" class="admin-tabs-content">
{#if openedUserDetail.active_subscription} {#if openedUserDetail.active_subscription}
<ul class="admin-meta-list"> <ul class="admin-meta-list">
<li><span>Активна до</span><strong>{fmtDate(openedUserDetail.active_subscription.end_date)}</strong></li> <li><span>Активна до</span><strong>{fmtDate(openedUserDetail.active_subscription.end_date)}</strong></li>
@@ -2516,7 +2731,9 @@
</div> </div>
</section> </section>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
</main>
</div>
{/if} {/if}
{/if} {/if}
</Dialog> </Dialog>
+234 -14
View File
@@ -33,6 +33,19 @@
--danger: #ff6b6b; --danger: #ff6b6b;
--blue: #2d9cff; --blue: #2d9cff;
--radius: 8px; --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; --screen-gutter: 18px;
--safe-inline: max(env(safe-area-inset-left), env(safe-area-inset-right)); --safe-inline: max(env(safe-area-inset-left), env(safe-area-inset-right));
--nav-inline-gutter: max(var(--screen-gutter), var(--safe-inline)); --nav-inline-gutter: max(var(--screen-gutter), var(--safe-inline));
@@ -2647,16 +2660,6 @@ a {
.admin-screen-wrap { .admin-screen-wrap {
--admin-sidebar-w: 248px; --admin-sidebar-w: 248px;
--admin-header-h: 60px; --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; position: fixed;
inset: 0; inset: 0;
@@ -2704,9 +2707,6 @@ a {
.admin-sidebar-brand .admin-brand-mark { .admin-sidebar-brand .admin-brand-mark {
width: 36px; width: 36px;
height: 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; display: grid;
place-items: center; place-items: center;
color: var(--accent); color: var(--accent);
@@ -2791,6 +2791,30 @@ a {
gap: 6px; 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 { .admin-content {
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
@@ -3858,6 +3882,148 @@ a {
overscroll-behavior: contain; 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 { .admin-tariff-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -3975,6 +4141,44 @@ a {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* Section title block: stacks `<strong>` heading + `<small>` 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 { .admin-row-editor {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -4438,6 +4642,23 @@ a {
line-height: 1.45; 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 */ /* Action rows in dialogs */
.admin-action-row { .admin-action-row {
display: flex; display: flex;
@@ -4513,7 +4734,6 @@ a {
border-radius: 12px; border-radius: 12px;
background: var(--admin-surface-2); background: var(--admin-surface-2);
border: 1px solid var(--admin-border); border: 1px solid var(--admin-border);
margin-bottom: 14px;
min-width: 0; min-width: 0;
} }
+64
View File
@@ -6,9 +6,11 @@ import io
import ipaddress import ipaddress
import json import json
import logging import logging
import os
import re import re
import secrets import secrets
import socket import socket
import subprocess
import time import time
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -64,6 +66,7 @@ WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->" WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->" WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->" WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop"
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->" DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->" DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 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_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token" WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
_APP_VERSION_CACHE: Optional[str] = None
WEBAPP_CSRF_EXEMPT_PATHS = { WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/telegram/nonce", "/api/auth/telegram/nonce",
"/api/auth/token", "/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("/settings", index_route)
app.router.add_get("/admin", 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/{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/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_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"] 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( async def _enforce_webapp_rate_limit(
request: web.Request, request: web.Request,
*, *,
@@ -727,6 +789,8 @@ async def index_route(request: web.Request) -> web.Response:
"currency": cached["currency"], "currency": cached["currency"],
"language": cached["language"], "language": cached["language"],
"emailAuthEnabled": cached["email_auth_enabled"], "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) html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
i18n_instance: Optional[object] = request.app.get("i18n") i18n_instance: Optional[object] = request.app.get("i18n")
+107 -18
View File
@@ -34,6 +34,17 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") 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]: def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
if not strategy: if not strategy:
return None 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}" callback_data=f"user_action:refresh:{user_id}"
) )
# Row 4: Quick links # Row 4: Quick links — only for users with a real Telegram profile
builder.button( # (synthetic email-only users have a negative user_id with no tg profile).
text=_(key="user_card_open_profile_button"), has_self_link = user_id > 0
url=f"tg://user?id={user_id}" has_referrer_link = bool(referrer_id) and referrer_id > 0
) if has_self_link:
if referrer_id: builder.button(
text=_(key="user_card_open_profile_button"),
url=f"tg://user?id={user_id}"
)
if has_referrer_link:
builder.button( builder.button(
text=_(key="user_card_open_referrer_profile_button"), text=_(key="user_card_open_referrer_profile_button"),
url=f"tg://user?id={referrer_id}" 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"), text=_(key="admin_user_delete_button"),
callback_data=f"user_action:delete_user:{user_id}" callback_data=f"user_action:delete_user:{user_id}"
) )
# Row 6: Navigation # Row 6: Navigation
builder.button( builder.button(
text=_(key="admin_user_search_new_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"), text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main" callback_data="admin_action:main"
) )
quick_links_width = 2 if referrer_id else 1 quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
builder.adjust(2, 2, 2, quick_links_width, 1, 2) 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 return builder
@@ -241,10 +259,13 @@ async def _send_with_profile_link_fallback(
await sender(**send_kwargs) 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, subscription_service: SubscriptionService,
i18n_instance, lang: str, 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""" """Format user information as a detailed card"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = 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: except Exception as e:
logging.error(f"Error getting user statistics for {user.user_id}: {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) 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 # Format and send user card
try: try:
referral_service = ReferralService(settings, subscription_service, message.bot, i18n) 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( keyboard = get_user_card_keyboard(
user_model.user_id, user_model.user_id,
i18n, i18n,
@@ -662,7 +735,11 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
from config.settings import Settings as _Settings from config.settings import Settings as _Settings
_settings = _Settings() _settings = _Settings()
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance) 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( keyboard = get_user_card_keyboard(
fresh_user.user_id, fresh_user.user_id,
i18n_instance, 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) user = await user_dal.get_user_by_id(session, target_user_id)
if user: if user:
referral_service = ReferralService(settings, subscription_service, message.bot, i18n) 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( keyboard = get_user_card_keyboard(
user.user_id, user.user_id,
i18n, i18n,
@@ -1045,7 +1126,11 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
async with PanelApiService(settings) as panel_service: async with PanelApiService(settings) as panel_service:
subscription_service = SubscriptionService(settings, panel_service) subscription_service = SubscriptionService(settings, panel_service)
referral_service = ReferralService(settings, subscription_service, bot, i18n) 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( keyboard = get_user_card_keyboard(
target_user.user_id, target_user.user_id,
i18n, i18n,
@@ -1334,7 +1419,11 @@ async def user_card_from_list_handler(callback: types.CallbackQuery,
try: try:
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
referral_service = ReferralService(settings, subscription_service, bot, i18n) 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() markup = keyboard.as_markup()
await _send_with_profile_link_fallback( await _send_with_profile_link_fallback(
+16 -16
View File
@@ -46,29 +46,29 @@ class NotificationService:
user_id: int, user_id: int,
referrer_id: Optional[int] = None, referrer_id: Optional[int] = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
"""Create inline keyboard with links to user (and referrer) profiles.""" """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}",
)
]
]
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([ buttons.append([
InlineKeyboardButton( InlineKeyboardButton(
text=translate( text=translate("log_open_profile_link"),
"log_open_referrer_profile_button", 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}", 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( async def _send_to_log_channel(
self, self,
+4
View File
@@ -403,6 +403,10 @@
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>", "admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>", "admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>", "admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
"admin_user_links_section_title": "🔗 <b>Links</b>",
"admin_user_subscription_url_label": "📡 <b>Subscription:</b>",
"admin_user_ref_bot_link_label": "🤖 <b>Referral link (bot):</b>",
"admin_user_ref_webapp_link_label": "🌐 <b>Referral link (web):</b>",
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>", "admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
"admin_user_subscription_error": "Loading error", "admin_user_subscription_error": "Loading error",
"admin_promo_management_button": "🎟 Promo Management", "admin_promo_management_button": "🎟 Promo Management",
+4
View File
@@ -403,6 +403,10 @@
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>", "admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>", "admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>", "admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
"admin_user_links_section_title": "🔗 <b>Ссылки</b>",
"admin_user_subscription_url_label": "📡 <b>Подписка:</b>",
"admin_user_ref_bot_link_label": "🤖 <b>Реф. ссылка (бот):</b>",
"admin_user_ref_webapp_link_label": "🌐 <b>Реф. ссылка (веб):</b>",
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>", "admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
"admin_user_subscription_error": "Ошибка загрузки", "admin_user_subscription_error": "Ошибка загрузки",
"admin_promo_management_button": "🎟 Управление промокодами", "admin_promo_management_button": "🎟 Управление промокодами",