feat: tune visual of web app admin panel
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "🎟 Управление промокодами",
|
||||||
|
|||||||
Reference in New Issue
Block a user