diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index b43ac82..f469da7 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) + provider = sub.provider + is_trial = str(provider or "").strip().lower() == "trial" + display_label = "Trial" if is_trial else sub.tariff_key return { "subscription_id": int(sub.subscription_id), "panel_user_uuid": sub.panel_user_uuid, @@ -118,8 +121,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), "tariff_key": sub.tariff_key, + "display_label": display_label, + "is_trial": is_trial, "auto_renew_enabled": bool(sub.auto_renew_enabled), - "provider": sub.provider, + "provider": provider, "is_throttled": bool(sub.is_throttled), } diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 31587c4..ecb8e4d 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -745,6 +745,24 @@ def _user_search_condition(query: str): return or_(*conditions) +def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]: + first_trial_sub = trial_subs[0] if trial_subs else None + latest_trial_sub = trial_subs[-1] if trial_subs else None + first_start = getattr(first_trial_sub, "start_date", None) + latest_start = getattr(latest_trial_sub, "start_date", None) + latest_end = getattr(latest_trial_sub, "end_date", None) + reset_at = getattr(user, "trial_eligibility_reset_at", None) + return { + "used": bool(trial_subs), + "count": len(trial_subs), + "first_activated_at": first_start.isoformat() if first_start else None, + "latest_activated_at": latest_start.isoformat() if latest_start else None, + "latest_end_date": latest_end.isoformat() if latest_end else None, + "active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)), + "last_reset_at": reset_at.isoformat() if reset_at else None, + } + + async def admin_user_detail_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: .limit(20) ) latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() + trial_subs_stmt = ( + select(Subscription) + .where( + Subscription.user_id == target_id, + sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial", + ) + .order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc()) + ) + trial_subs = (await session.execute(trial_subs_stmt)).scalars().all() total_paid = await payment_dal.get_user_total_paid(session, target_id) recent_payments_stmt = ( select(Payment) @@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: serialized_inviter = ( _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None ) + trial_payload = _serialize_trial_summary(user, trial_subs) return _ok( { "user": serialized_user, "active_subscription": _serialize_subscription(active_sub) if active_sub else None, "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], + "trial": trial_payload, "total_paid": float(total_paid), "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index cd8680e..7545b32 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -77,14 +77,20 @@ SETTINGS_MANIFEST: List[SettingField] = [ "int", "general", "ID обязательного канала", - "Telegram ID канала, в котором нужно состоять.", + ( + "Telegram ID канала для проверки подписки. Если бот видит канал, " + "ссылка кнопки будет получена автоматически." + ), ), SettingField( "REQUIRED_CHANNEL_LINK", "string", "general", "Ссылка на канал", - "Имя пользователя или invite-link.", + ( + "Необязательно: публичный @username или invite-link, " + "если ссылку нельзя получить по ID канала." + ), ), SettingField( "PANEL_API_URL", @@ -101,6 +107,42 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Секретный ключ API панели.", secret=True, ), + SettingField( + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API total timeout", + "Maximum total time for one Remnawave API request, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API connect timeout", + "Maximum time to get or open a Remnawave API connection, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket connect timeout", + "Maximum TCP/TLS connection time for Remnawave API, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket read timeout", + "Maximum time to wait for response data from Remnawave API, in seconds.", + optional=False, + min=1, + ), SettingField( "PANEL_WEBHOOK_SECRET", "string", diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 56189bc..6a3cf16 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -2,6 +2,7 @@ from ._runtime import * # noqa: F403,F405 from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches +from db.dal import message_log_dal def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]: @@ -395,6 +396,28 @@ async def activate_trial_route(request: web.Request) -> web.Response: except Exception: logger.exception("Failed to send WebApp trial activation notification") + try: + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": user_id, + "telegram_username": getattr(db_user, "username", None), + "telegram_first_name": getattr(db_user, "first_name", None), + "event_type": "webapp_trial_activate", + "content": ( + f"Trial activated via WebApp for user_id={user_id}; " + f"email={getattr(db_user, 'email', None) or 'N/A'}" + ), + "is_admin_event": False, + "target_user_id": user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + except Exception: + logger.exception("Failed to add WebApp trial activation audit log") + + await session.commit() + try: from db.dal import ad_dal as _ad_dal diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 79425a8..8446546 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback from bot.utils.channel_subscription import ( is_required_channel_access_error, normalize_required_channel_id, + resolve_required_channel_link, ) from bot.utils.install_links import ( append_install_share_link_text, @@ -437,11 +438,12 @@ async def ensure_required_channel_subscription( ) return True - keyboard = ( - get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK) - if i18n - else None + channel_link = await resolve_required_channel_link( + bot_instance, + required_channel_id, + settings.REQUIRED_CHANNEL_LINK, ) + keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None prompt_text = translate("channel_subscription_required") diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 6bd5fde..d0a5790 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from bot.middlewares.i18n import locale_language_options +from bot.utils.channel_subscription import normalize_required_channel_link from bot.utils.install_links import bot_install_guide_url from bot.utils.mini_app_url import subscription_mini_app_trial_url from config.settings import Settings @@ -718,10 +719,11 @@ def get_channel_subscription_keyboard( has_buttons = False - if channel_link: + channel_url = normalize_required_channel_link(channel_link) + if channel_url: builder.button( text=_(key="channel_subscription_join_button"), - url=channel_link, + url=channel_url, ) has_buttons = True diff --git a/backend/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py index 0c760ce..f63e81f 100644 --- a/backend/bot/middlewares/channel_subscription.py +++ b/backend/bot/middlewares/channel_subscription.py @@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard from bot.middlewares.i18n import JsonI18n -from bot.utils.channel_subscription import normalize_required_channel_id +from bot.utils.channel_subscription import ( + normalize_required_channel_id, + resolve_required_channel_link, +) from config.settings import Settings from db.dal import user_dal @@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): return i18n_instance.gettext(current_lang, key) return key + bot_instance = data.get("bot") or data.get("bot_instance") + channel_link = await resolve_required_channel_link( + bot_instance, + required_channel_id, + self.settings.REQUIRED_CHANNEL_LINK, + ) keyboard = ( - get_channel_subscription_keyboard( - current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK - ) + get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link) if i18n_instance else None ) diff --git a/backend/bot/services/panel_api_service.py b/backend/bot/services/panel_api_service.py index 4ff5c43..bac4e60 100644 --- a/backend/bot/services/panel_api_service.py +++ b/backend/bot/services/panel_api_service.py @@ -22,6 +22,11 @@ class PanelApiService: _TRANSIENT_STATUS_CODES = (-1, -3) _SAFE_METHODS = frozenset({"GET", "HEAD"}) _RETRY_BACKOFF_SECONDS = 0.5 + _MIN_TIMEOUT_SECONDS = 0.1 + _DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0 + _DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0 def __init__(self, settings: Settings): self.settings = settings @@ -70,17 +75,46 @@ class PanelApiService: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - # Separate connect/read timeouts so a stuck panel does not hold a - # bot worker for the full window; total caps worst-case latency. - timeout = aiohttp.ClientTimeout( - total=15, - connect=3, - sock_connect=3, - sock_read=10, - ) - self._session = aiohttp.ClientSession(timeout=timeout) + self._session = aiohttp.ClientSession(timeout=self._client_timeout()) return self._session + @classmethod + def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float: + raw_value = getattr(settings, name, default) + try: + value = float(raw_value) + except (TypeError, ValueError): + return default + if value <= 0: + return default + return max(cls._MIN_TIMEOUT_SECONDS, value) + + def _client_timeout(self) -> aiohttp.ClientTimeout: + # Separate connect/read timeouts so a slow panel route has more room, + # while genuinely stuck requests still cannot pin a worker forever. + return aiohttp.ClientTimeout( + total=self._timeout_setting( + self.settings, + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + self._DEFAULT_TOTAL_TIMEOUT_SECONDS, + ), + connect=self._timeout_setting( + self.settings, + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_CONNECT_TIMEOUT_SECONDS, + ), + sock_connect=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS, + ), + sock_read=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS, + ), + ) + async def close_session(self): if self._session and not self._session.closed: await self._session.close() @@ -121,6 +155,15 @@ class PanelApiService: for attempt in range(max_attempts): result = await self._request_once(method, endpoint, log_full_response, **kwargs) if attempt + 1 < max_attempts and self._is_transient_error(result): + logging.warning( + "Retrying transient Panel API request method=%s endpoint=%s " + "attempt=%s/%s status_code=%s", + method.upper(), + endpoint, + attempt + 1, + max_attempts, + result.get("status_code") if isinstance(result, dict) else None, + ) await asyncio.sleep(self._RETRY_BACKOFF_SECONDS) continue return result @@ -158,8 +201,8 @@ class PanelApiService: ) except Exception: log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." + started = time.monotonic() try: - started = time.monotonic() async with aiohttp_session.request( method.upper(), url_for_request, headers=headers, **kwargs ) as response: @@ -228,15 +271,48 @@ class PanelApiService: return {"error": True, "status_code": response_status, "details": error_details} except aiohttp.ClientConnectorError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}") return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"} + except aiohttp.ServerTimeoutError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint, + ) + logging.warning("Panel API timeout to %s: %s", url_for_request, e) + return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"} except aiohttp.ClientError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.exception("Panel API ClientError to %s.", url_for_request) return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"} except asyncio.TimeoutError: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error(f"Panel API request to {url_for_request} timed out.") return {"error": True, "status_code": -3, "message": "Request timed out"} except Exception as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error( f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True ) @@ -885,7 +961,14 @@ class PanelApiService: await self._devices_cache.invalidate_remote(f"user:{user_uuid}") async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: - return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + if squads is not None: + return squads + stale_squads = self._squads_cache.get_stale("list") + if stale_squads is not None: + logging.warning("Using stale internal squads cache after panel fetch failed.") + return stale_squads + return None async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/internal-squads", log_full_response=False) diff --git a/backend/bot/utils/channel_subscription.py b/backend/bot/utils/channel_subscription.py index 72ba65c..4f08287 100644 --- a/backend/bot/utils/channel_subscription.py +++ b/backend/bot/utils/channel_subscription.py @@ -1,4 +1,9 @@ -from typing import Optional +import logging +import re +from typing import Any, Optional + +_TELEGRAM_LINK_RE = re.compile(r"^(?:https?://|tg://)", re.IGNORECASE) +_TELEGRAM_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{5,64}$") def normalize_required_channel_id(value: object) -> Optional[int]: @@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]: return -int(f"100{raw_abs}") +def normalize_required_channel_link(value: object) -> Optional[str]: + if value is None: + return None + + raw = str(value).strip() + if not raw: + return None + + if _TELEGRAM_LINK_RE.match(raw): + return raw + + raw = raw.lstrip("@").strip() + if not raw or re.search(r"\s", raw): + return None + + if raw.startswith(("t.me/", "telegram.me/")): + return f"https://{raw}" + + if raw.startswith(("+", "joinchat/", "c/")): + return f"https://t.me/{raw}" + + if _TELEGRAM_USERNAME_RE.fullmatch(raw): + return f"https://t.me/{raw}" + + return None + + +def _required_channel_link_from_chat(chat: Any) -> Optional[str]: + username = str(getattr(chat, "username", "") or "").strip().lstrip("@") + if username: + return f"https://t.me/{username}" + + invite_link = normalize_required_channel_link(getattr(chat, "invite_link", None)) + if invite_link: + return invite_link + + return None + + +async def resolve_required_channel_link( + bot: Any, + required_channel_id: Optional[int], + configured_link: object, +) -> Optional[str]: + if bot is not None and required_channel_id: + try: + chat = await bot.get_chat(required_channel_id) + resolved_link = _required_channel_link_from_chat(chat) + if resolved_link: + return resolved_link + except Exception as error: + logging.warning( + "Failed to resolve required channel link from chat %s: %s", + required_channel_id, + error, + ) + + return normalize_required_channel_link(configured_link) + + def is_required_channel_access_error(error: BaseException) -> bool: message = str(error).lower() configuration_markers = ( diff --git a/backend/bot/utils/ttl_cache.py b/backend/bot/utils/ttl_cache.py index 18408fd..d182ae7 100644 --- a/backend/bot/utils/ttl_cache.py +++ b/backend/bot/utils/ttl_cache.py @@ -29,6 +29,15 @@ class AsyncTTLCache: return None return value + def get_stale(self, key: str) -> Optional[Any]: + entry = self._data.get(key) + if entry is None: + return None + _, value = entry + if not self._is_cacheable(value): + return None + return value + @staticmethod def _is_cacheable(value: Any) -> bool: if value is None: diff --git a/backend/config/settings.py b/backend/config/settings.py index 3c856c7..4b16f1a 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -100,6 +100,10 @@ class Settings(BaseSettings): PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000) + PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25) + PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15) ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15) ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5) ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3) diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index b7dbdf7..e110aed 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -59,6 +59,10 @@ | `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. | | `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. | | `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. | +| `PANEL_API_TOTAL_TIMEOUT_SECONDS` | Общий timeout запроса к Remnawave API. | +| `PANEL_API_CONNECT_TIMEOUT_SECONDS` | Timeout получения соединения с Remnawave API. | +| `PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS` | Timeout TCP/TLS-подключения к Remnawave API. | +| `PANEL_API_SOCK_READ_TIMEOUT_SECONDS` | Timeout ожидания данных ответа Remnawave API. | | `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. | | `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. | | `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. | @@ -104,8 +108,8 @@ | `TERMS_OF_SERVICE_URL` | Условия использования. | | `PRIVACY_POLICY_URL` | Политика конфиденциальности. | | `USER_AGREEMENT_URL` | Пользовательское соглашение. | -| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. | -| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. | +| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. | +| `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. | | `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. | | `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. | diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6c0eb53..c50d228 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -78,7 +78,7 @@ activationPaymentFailed, createActivationHandoff, } from "./lib/webapp/activationHandoff.js"; - import { buildGravatarUrl } from "./lib/webapp/gravatar.js"; + import { buildGravatarUrl, resolveProfileAvatarUrl } from "./lib/webapp/gravatar.js"; import { createBillingActions } from "./lib/webapp/billingActions.js"; import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js"; import { runWebappBoot } from "./lib/webapp/webappBoot.js"; @@ -494,7 +494,7 @@ $: telegramProfileName = telegramName(user); $: profileEmail = user?.email || t("wa_settings_email_not_linked"); $: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked"); - $: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || ""; + $: profileAvatarUrl = resolveProfileAvatarUrl(user, emailAvatarUrl); $: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim(); $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index f0ecc1d..35a36f2 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -53,6 +53,26 @@ return String(val ?? "—"); } + function isTrialSubscription(sub) { + return Boolean(sub?.is_trial || String(sub?.provider || "").toLowerCase() === "trial"); + } + + function subscriptionDisplayLabel(sub) { + if (!sub) return "—"; + if (isTrialSubscription(sub)) return at("user_subscription_trial", {}, "Триал"); + if (sub.display_label) return sub.display_label; + return sub.tariff_name || sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа"); + } + + function trialSummaryText(trial) { + if (!trial?.used) return at("user_trial_not_used", {}, "Не брал"); + const date = trial.latest_activated_at || trial.first_activated_at; + const base = date + ? at("user_trial_used_at", { date: fmtDate(date) }, `Брал ${fmtDate(date)}`) + : at("user_trial_used", {}, "Брал"); + return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base; + } + const usersStore = getContext("usersStore"); $: ({ @@ -404,7 +424,7 @@