Merge pull request #21 from 3252a8/feature/ui-improvements

Improve admin UX, trial reset behavior, and channel subscription checks
This commit is contained in:
3252a8
2026-06-01 22:29:24 +03:00
committed by GitHub
34 changed files with 1031 additions and 84 deletions
+6 -9
View File
@@ -1172,10 +1172,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request) actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"]) target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
panel_service = request.app.get("panel_service")
subscription_service = request.app.get("subscription_service")
if panel_service is None or subscription_service is None:
return _error(503, "service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"] async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session: async with async_session_factory() as session:
@@ -1183,16 +1179,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
if not user: if not user:
return _error(404, "not_found") return _error(404, "not_found")
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
if active: if reset_at is None:
await session.delete(active) await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log( await message_log_dal.create_message_log_no_commit(
session, session,
{ {
"user_id": actor_id, "user_id": actor_id,
"event_type": "admin_reset_trial_webapp", "event_type": "admin_reset_trial_webapp",
"content": f"Reset trial for user_id={target_id}", "content": f"Reset trial eligibility for user_id={target_id}",
"is_admin_event": True, "is_admin_event": True,
"target_user_id": target_id, "target_user_id": target_id,
}, },
@@ -109,6 +109,8 @@
let attempted = false; let attempted = false;
let pageLeft = false; let pageLeft = false;
let state = "opening"; let state = "opening";
let closeAttemptTimer = null;
const CLOSE_ATTEMPT_DELAY_MS = 2500;
function hasControlChars(value) { function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => { return Array.from(String(value || "")).some((char) => {
@@ -167,7 +169,10 @@
function markDone() { function markDone() {
if (state === "done" || isUnsafe) return; if (state === "done" || isUnsafe) return;
render("done"); render("done");
window.setTimeout(tryCloseWindow, 120); if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
closeAttemptTimer = window.setTimeout(() => {
if (pageLeft || document.hidden) tryCloseWindow();
}, CLOSE_ATTEMPT_DELAY_MS);
} }
function notePageLeft() { function notePageLeft() {
@@ -201,7 +206,6 @@
render("done"); render("done");
}); });
window.addEventListener("pagehide", notePageLeft); window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (!attempted) return; if (!attempted) return;
if (document.hidden) { if (document.hidden) {
+1 -1
View File
@@ -67,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
trial_available = bool( trial_available = bool(
settings.TRIAL_ENABLED settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0 and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_had_any_subscription(session, user_id) and not await subscription_service.has_trial_blocking_subscription(session, user_id)
) )
avatar = await _ensure_cached_telegram_avatar(request, session, db_user) avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try: try:
@@ -932,8 +932,7 @@ async def handle_reset_trial(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try: try:
# Delete all user subscriptions to reset trial eligibility await user_dal.mark_trial_eligibility_reset(session, user.user_id)
await subscription_dal.delete_all_user_subscriptions(session, user.user_id)
await session.commit() await session.commit()
await callback.answer(_("admin_user_trial_reset_success"), show_alert=True) await callback.answer(_("admin_user_trial_reset_success"), show_alert=True)
+32 -5
View File
@@ -24,6 +24,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
from bot.utils.callback_answer import safe_answer_callback from bot.utils.callback_answer import safe_answer_callback
from bot.utils.channel_subscription import (
is_required_channel_access_error,
normalize_required_channel_id,
)
from bot.utils.install_links import ( from bot.utils.install_links import (
append_install_share_link_text, append_install_share_link_text,
ensure_user_install_guide_links, ensure_user_install_guide_links,
@@ -45,12 +49,12 @@ async def should_show_trial_button(
if not settings.TRIAL_ENABLED: if not settings.TRIAL_ENABLED:
return False return False
if hasattr(subscription_service, "has_had_any_subscription") and callable( if hasattr(subscription_service, "has_trial_blocking_subscription") and callable(
getattr(subscription_service, "has_had_any_subscription") getattr(subscription_service, "has_trial_blocking_subscription")
): ):
return not await subscription_service.has_had_any_subscription(session, user_id) return not await subscription_service.has_trial_blocking_subscription(session, user_id)
logging.error("Method has_had_any_subscription is missing in SubscriptionService!") logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!")
return False return False
@@ -215,7 +219,7 @@ async def ensure_required_channel_subscription(
Verify that the user is a member of the required channel (if configured). Verify that the user is a member of the required channel (if configured).
Returns True when access can proceed, False when user must subscribe first. Returns True when access can proceed, False when user must subscribe first.
""" """
required_channel_id = settings.REQUIRED_CHANNEL_ID required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID)
if not required_channel_id: if not required_channel_id:
return True return True
@@ -279,6 +283,29 @@ async def ensure_required_channel_subscription(
if status_value in allowed_statuses: if status_value in allowed_statuses:
is_member = True is_member = True
except TelegramBadRequest as bad_request: except TelegramBadRequest as bad_request:
if is_required_channel_access_error(bad_request):
logging.error(
"Required channel check failed due to channel access/configuration error "
"(configured=%s, normalized=%s): %s",
settings.REQUIRED_CHANNEL_ID,
required_channel_id,
bad_request,
)
error_text = translate("channel_subscription_check_failed")
if isinstance(event, types.CallbackQuery):
try:
await event.answer(error_text, show_alert=True)
except Exception:
pass
if message_obj:
try:
await message_obj.answer(error_text)
except Exception:
pass
else:
await event.answer(error_text)
return False
logging.info( logging.info(
"Required channel check: user %s not subscribed (details: %s)", "Required channel check: user %s not subscribed (details: %s)",
user_id, user_id,
+9 -7
View File
@@ -46,7 +46,7 @@ async def request_trial_confirmation_handler(
return return
if settings.TRIAL_ENABLED: if settings.TRIAL_ENABLED:
if not await subscription_service.has_had_any_subscription(session, user_id): if not await subscription_service.has_trial_blocking_subscription(session, user_id):
pass pass
if not settings.TRIAL_ENABLED: if not settings.TRIAL_ENABLED:
@@ -60,7 +60,7 @@ async def request_trial_confirmation_handler(
pass pass
return return
if await subscription_service.has_had_any_subscription(session, user_id): if await subscription_service.has_trial_blocking_subscription(session, user_id):
await callback.message.edit_text( await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"), _("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False), reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
@@ -147,8 +147,9 @@ async def request_trial_confirmation_handler(
await callback.answer(final_message_text_in_chat, show_alert=True) await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception: except Exception:
pass pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( if (
session, user_id settings.TRIAL_ENABLED
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
): ):
show_trial_button_after_action = True show_trial_button_after_action = True
@@ -218,7 +219,7 @@ async def confirm_activate_trial_handler(
callback, settings, i18n_data, subscription_service, session, is_edit=True callback, settings, i18n_data, subscription_service, session, is_edit=True
) )
return return
if await subscription_service.has_had_any_subscription(session, user_id): if await subscription_service.has_trial_blocking_subscription(session, user_id):
try: try:
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True) await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
except Exception: except Exception:
@@ -283,8 +284,9 @@ async def confirm_activate_trial_handler(
await callback.answer(final_message_text_in_chat, show_alert=True) await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception: except Exception:
pass pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( if (
session, user_id settings.TRIAL_ENABLED
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
): ):
show_trial_button_after_action = True show_trial_button_after_action = True
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.channel_subscription import normalize_required_channel_id
from config.settings import Settings from config.settings import Settings
from db.dal import user_dal from db.dal import user_dal
@@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
event: Update, event: Update,
data: Dict[str, Any], data: Dict[str, Any],
) -> Any: ) -> Any:
required_channel_id = self.settings.REQUIRED_CHANNEL_ID required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID)
if not required_channel_id: if not required_channel_id:
return await handler(event, data) return await handler(event, data)
@@ -62,6 +62,9 @@ class PaymentContextMixin:
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool: async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_any_subscription_for_user(session, user_id) return await subscription_dal.has_any_subscription_for_user(session, user_id)
async def has_trial_blocking_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_trial_blocking_subscription_for_user(session, user_id)
async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool: async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool:
"""Return True if user currently has an active subscription (end_date in future).""" """Return True if user currently has an active subscription (end_date in future)."""
try: try:
@@ -22,7 +22,7 @@ class TrialSubscriptionMixin:
"message_key": "user_not_found_for_trial", "message_key": "user_not_found_for_trial",
} }
if await self.has_had_any_subscription(session, user_id): if await self.has_trial_blocking_subscription(session, user_id):
return { return {
"eligible": False, "eligible": False,
"activated": False, "activated": False,
+40
View File
@@ -0,0 +1,40 @@
from typing import Optional
def normalize_required_channel_id(value: object) -> Optional[int]:
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
try:
channel_id = int(raw)
except (TypeError, ValueError):
return None
if channel_id == 0:
return None
if channel_id > 0:
return int(f"-100{channel_id}")
raw_abs = str(abs(channel_id))
if raw.startswith("-100"):
return channel_id
if abs(channel_id) < 1_000_000_000:
return channel_id
return -int(f"100{raw_abs}")
def is_required_channel_access_error(error: BaseException) -> bool:
message = str(error).lower()
configuration_markers = (
"chat not found",
"bot is not a member",
"not enough rights",
"have no rights",
"kicked",
)
return any(marker in message for marker in configuration_markers)
+1
View File
@@ -1018,6 +1018,7 @@ class Settings(BaseSettings):
"LOG_SUPPORT_THREAD_ID", "LOG_SUPPORT_THREAD_ID",
"BACKUP_CHAT_ID", "BACKUP_CHAT_ID",
"BACKUP_THREAD_ID", "BACKUP_THREAD_ID",
"REQUIRED_CHANNEL_ID",
mode="before", mode="before",
) )
@classmethod @classmethod
+25 -3
View File
@@ -4,12 +4,12 @@ import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, or_, update from sqlalchemy import and_, delete, func, or_, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from db.models import Subscription, SubscriptionNotification from db.models import Subscription, SubscriptionNotification, User
INSTALL_SHARE_TOKEN_BYTES = 16 INSTALL_SHARE_TOKEN_BYTES = 16
@@ -252,7 +252,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int)
async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int: async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int:
"""Completely delete all user subscriptions (for trial reset)""" """Completely delete all user subscriptions."""
stmt = delete(Subscription).where(Subscription.user_id == user_id) stmt = delete(Subscription).where(Subscription.user_id == user_id)
result = await session.execute(stmt) result = await session.execute(stmt)
if result.rowcount > 0: if result.rowcount > 0:
@@ -284,6 +284,28 @@ async def has_any_subscription_for_user(session: AsyncSession, user_id: int) ->
return result.scalar_one_or_none() is not None return result.scalar_one_or_none() is not None
async def has_trial_blocking_subscription_for_user(session: AsyncSession, user_id: int) -> bool:
now_utc = datetime.now(timezone.utc)
reset_at = (
select(User.trial_eligibility_reset_at).where(User.user_id == user_id).scalar_subquery()
)
subscription_anchor = func.coalesce(Subscription.start_date, Subscription.end_date)
stmt = (
select(Subscription.subscription_id)
.where(
Subscription.user_id == user_id,
or_(
reset_at.is_(None),
and_(Subscription.is_active == True, Subscription.end_date > now_utc),
subscription_anchor > reset_at,
),
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def get_subscriptions_near_expiration( async def get_subscriptions_near_expiration(
session: AsyncSession, days_threshold: int session: AsyncSession, days_threshold: int
) -> List[Subscription]: ) -> List[Subscription]:
+14
View File
@@ -261,6 +261,20 @@ async def create_email_user(
) )
async def mark_trial_eligibility_reset(
session: AsyncSession,
user_id: int,
*,
reset_at: Optional[datetime] = None,
) -> Optional[datetime]:
reset_at = reset_at or datetime.now(timezone.utc)
stmt = update(User).where(User.user_id == user_id).values(trial_eligibility_reset_at=reset_at)
result = await session.execute(stmt)
if result.rowcount <= 0:
return None
return reset_at
async def _has_active_panel_subscription( async def _has_active_panel_subscription(
session: AsyncSession, user_id: int, panel_user_uuid: str session: AsyncSession, user_id: int, panel_user_uuid: str
) -> bool: ) -> bool:
+14
View File
@@ -1061,6 +1061,15 @@ def _migration_0032_add_telegram_notification_status(connection: Connection) ->
connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}")) connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}"))
def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "trial_eligibility_reset_at" not in columns:
connection.execute(
text("ALTER TABLE users ADD COLUMN trial_eligibility_reset_at TIMESTAMPTZ")
)
MIGRATIONS: List[Migration] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -1233,6 +1242,11 @@ MIGRATIONS: List[Migration] = [
description="Track whether the bot can message Telegram-linked users", description="Track whether the bot can message Telegram-linked users",
upgrade=_migration_0032_add_telegram_notification_status, upgrade=_migration_0032_add_telegram_notification_status,
), ),
Migration(
id="0033_add_trial_eligibility_reset_marker",
description="Track admin resets of per-user trial eligibility without deleting history",
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
),
] ]
+1
View File
@@ -47,6 +47,7 @@ class User(Base):
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True) lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
trial_eligibility_reset_at = Column(DateTime(timezone=True), nullable=True)
channel_subscription_verified = Column(Boolean, nullable=True) channel_subscription_verified = Column(Boolean, nullable=True)
channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True) channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True)
channel_subscription_verified_for = Column(BigInteger, nullable=True) channel_subscription_verified_for = Column(BigInteger, nullable=True)
+19 -2
View File
@@ -183,6 +183,7 @@
let languageClickGuardArmed = false; let languageClickGuardArmed = false;
let languageClickGuardTimer = null; let languageClickGuardTimer = null;
let languageClickGuardArmTimer = null; let languageClickGuardArmTimer = null;
let guestLanguage = "";
let emailAvatarUrl = ""; let emailAvatarUrl = "";
let avatarHashToken = ""; let avatarHashToken = "";
let token = MOCK ? "local-preview" : ""; let token = MOCK ? "local-preview" : "";
@@ -213,12 +214,13 @@
const i18n = createI18n({ const i18n = createI18n({
messages: I18N, messages: I18N,
defaultLang: "ru", defaultLang: "ru",
getLang: () => user?.language_code || CFG.language || "ru", getLang: () => user?.language_code || guestLanguage || CFG.language || "ru",
}); });
const normalizeLangCode = i18n.normalizeLangCode; const normalizeLangCode = i18n.normalizeLangCode;
const t = i18n.t; const t = i18n.t;
const termUnitLabel = i18n.termUnitLabel; const termUnitLabel = i18n.termUnitLabel;
const languageName = i18n.languageName; const languageName = i18n.languageName;
guestLanguage = normalizeLangCode(CFG.language || "ru");
const apiClient = createApiClient({ const apiClient = createApiClient({
apiBase: CFG.apiBase, apiBase: CFG.apiBase,
csrfCookieName: CSRF_COOKIE_NAME, csrfCookieName: CSRF_COOKIE_NAME,
@@ -460,7 +462,7 @@
activeTab = "settings"; activeTab = "settings";
} }
$: referral = data?.referral || MOCK_SOURCE.data.referral; $: referral = data?.referral || MOCK_SOURCE.data.referral;
$: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru"); $: currentLang = normalizeLangCode(user?.language_code || guestLanguage || CFG.language || "ru");
$: languageCodes = uniqueLanguageCodes( $: languageCodes = uniqueLanguageCodes(
WEBAPP_LANGUAGE_ORDER, WEBAPP_LANGUAGE_ORDER,
CFG.languages, CFG.languages,
@@ -939,6 +941,13 @@
}, 260); }, 260);
} }
function updateGuestLanguage(nextValue) {
const language = normalizeLangCode(nextValue);
setLanguageMenuOpen(false);
if (!language || language === currentLang) return;
guestLanguage = language;
}
function readTelegramMiniAppInitDataFromLocation() { function readTelegramMiniAppInitDataFromLocation() {
return telegramSdk.readInitDataFromLocation(); return telegramSdk.readInitDataFromLocation();
} }
@@ -2212,7 +2221,15 @@
{telegramLoginUnavailableMessage} {telegramLoginUnavailableMessage}
{privacyPolicyUrl} {privacyPolicyUrl}
{userAgreementUrl} {userAgreementUrl}
{currentLang}
{currentLanguageOption}
{languageOptions}
{languageMenuOpen}
{languageClickGuard}
{languageClickGuardArmed}
{t} {t}
{setLanguageMenuOpen}
updateLoginLanguage={updateGuestLanguage}
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))} requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
loginWithEmailPassword={authStore.loginWithEmailPassword} loginWithEmailPassword={authStore.loginWithEmailPassword}
verifyEmailCode={authStore.verifyEmailCode} verifyEmailCode={authStore.verifyEmailCode}
+9 -5
View File
@@ -228,6 +228,7 @@
let adminLanguageClickGuardArmed = false; let adminLanguageClickGuardArmed = false;
let adminLanguageClickGuardTimer = null; let adminLanguageClickGuardTimer = null;
let adminLanguageClickGuardArmTimer = null; let adminLanguageClickGuardArmTimer = null;
$: adminLanguageGuardActive = isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard);
function readReduceMotion() { function readReduceMotion() {
return ( return (
@@ -295,6 +296,8 @@
} }
function changeLanguage(value) { function changeLanguage(value) {
adminLanguageMenuOpen = false;
clearAdminLanguageClickGuard();
onLanguageChange(value, { section: "admin", adminSection: active }); onLanguageChange(value, { section: "admin", adminSection: active });
} }
@@ -472,8 +475,6 @@
function setAdminLanguageMenuOpen(open) { function setAdminLanguageMenuOpen(open) {
adminLanguageMenuOpen = Boolean(open); adminLanguageMenuOpen = Boolean(open);
clearAdminLanguageClickGuard(); clearAdminLanguageClickGuard();
// Desktop doesn't need the click-guard overlay and it can block
// option clicks in portaled select content.
if (!isCompact) return; if (!isCompact) return;
if (adminLanguageMenuOpen) { if (adminLanguageMenuOpen) {
adminLanguageClickGuard = true; adminLanguageClickGuard = true;
@@ -557,7 +558,11 @@
} }
</script> </script>
<div class="admin-screen-wrap" class:is-sidebar-open={sidebarOpen}> <div
class="admin-screen-wrap"
class:is-sidebar-open={sidebarOpen}
class:is-admin-language-open={adminLanguageGuardActive}
>
{#if sidebarOpen} {#if sidebarOpen}
<button <button
type="button" type="button"
@@ -568,7 +573,7 @@
on:click={() => (sidebarOpen = false)} on:click={() => (sidebarOpen = false)}
></button> ></button>
{/if} {/if}
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)} {#if adminLanguageGuardActive}
<button <button
class="language-select-guard" class="language-select-guard"
class:language-select-guard--armed={adminLanguageClickGuardArmed} class:language-select-guard--armed={adminLanguageClickGuardArmed}
@@ -578,7 +583,6 @@
on:click={closeAdminLanguageFromGuard} on:click={closeAdminLanguageFromGuard}
></button> ></button>
{/if} {/if}
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}> <aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
<div class="admin-sidebar-brand"> <div class="admin-sidebar-brand">
<BrandMark class="admin-brand-mark" {brand} /> <BrandMark class="admin-brand-mark" {brand} />
+309 -1
View File
@@ -5,8 +5,11 @@
ArrowUp, ArrowUp,
ChevronsUpDown, ChevronsUpDown,
DollarSign, DollarSign,
Sliders,
X,
UsersRound, UsersRound,
} from "$components/ui/icons.js"; } from "$components/ui/icons.js";
import Dialog from "$components/ui/dialog.svelte";
import { Label } from "$components/ui/primitives.js"; import { Label } from "$components/ui/primitives.js";
import { import {
AdminBadge, AdminBadge,
@@ -44,6 +47,7 @@
} = $usersStore); } = $usersStore);
const USERS_PAGE_SIZE = 25; const USERS_PAGE_SIZE = 25;
let usersFilterSheetOpen = false;
$: usersHasMore = users.length === USERS_PAGE_SIZE; $: usersHasMore = users.length === USERS_PAGE_SIZE;
const USERS_FILTER_OPTIONS = [ const USERS_FILTER_OPTIONS = [
@@ -102,6 +106,29 @@
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") }, { value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
]; ];
function optionLabel(options, value) {
return options.find((item) => item.value === value)?.label || value;
}
function updateUsersFilterState(patch) {
usersStore.updateState({ ...patch, usersPage: 0 });
usersStore.loadUsers();
}
function resetUsersFilters() {
updateUsersFilterState({
usersFilter: "all",
usersPanelStatus: "all",
usersPremiumTraffic: "all",
});
}
function clearUsersFilter(key) {
if (key === "usersFilter") updateUsersFilterState({ usersFilter: "all" });
if (key === "usersPanelStatus") updateUsersFilterState({ usersPanelStatus: "all" });
if (key === "usersPremiumTraffic") updateUsersFilterState({ usersPremiumTraffic: "all" });
}
/** @param {Record<string, unknown> | null | undefined} pt */ /** @param {Record<string, unknown> | null | undefined} pt */
function premiumTrafficBadgeVariant(pt) { function premiumTrafficBadgeVariant(pt) {
if (!pt || pt.state === "none") return "muted"; if (!pt || pt.state === "none") return "muted";
@@ -187,6 +214,24 @@
return fmtMoney(user?.payments_total_amount ?? 0, user?.payments_currency || "RUB"); return fmtMoney(user?.payments_total_amount ?? 0, user?.payments_currency || "RUB");
} }
$: activeUserFilterChips = [
usersFilter !== "all" && {
key: "usersFilter",
label: at("filter", {}, "Фильтр"),
value: optionLabel(USERS_FILTER_OPTIONS, usersFilter),
},
usersPanelStatus !== "all" && {
key: "usersPanelStatus",
label: at("panel_status", {}, "Статус панели"),
value: optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus),
},
usersPremiumTraffic !== "all" && {
key: "usersPremiumTraffic",
label: at("premium_traffic_filter_label", {}, "Премиум трафик"),
value: optionLabel(USERS_PREMIUM_TRAFFIC_OPTIONS, usersPremiumTraffic),
},
].filter(Boolean);
$: activeUsersFilterCount = activeUserFilterChips.length;
$: userTableHeaders = userTableColumns().map((column) => column.label); $: userTableHeaders = userTableColumns().map((column) => column.label);
onMount(() => { onMount(() => {
@@ -194,6 +239,65 @@
}); });
</script> </script>
{#snippet renderUserFilterControls()}
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label">{at("filter", {}, "Фильтр")}</span>
<AdminSelect
value={usersFilter}
items={USERS_FILTER_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("filter", {}, "Фильтр")}
onValueChange={(value) => updateUsersFilterState({ usersFilter: value })}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label">{at("panel_status", {}, "Статус панели")}</span>
<AdminSelect
value={usersPanelStatus}
items={USERS_PANEL_STATUS_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("panel_status", {}, "Статус панели")}
onValueChange={(value) => updateUsersFilterState({ usersPanelStatus: value })}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label"
>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span
>
<AdminSelect
value={usersPremiumTraffic}
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
onValueChange={(value) => updateUsersFilterState({ usersPremiumTraffic: value })}
/>
</Label.Root>
{/snippet}
{#snippet renderActiveUserFilterChips()}
{#if activeUsersFilterCount}
<div class="admin-users-filter-chips" aria-label={at("active_filters", {}, "Активные фильтры")}>
{#each activeUserFilterChips as chip (chip.key)}
<span class="admin-users-filter-chip">
<span class="admin-users-filter-chip-text">
<strong>{chip.label}</strong>
<span>{chip.value}</span>
</span>
<button
type="button"
aria-label={at("clear_filter", { label: chip.label }, "Сбросить фильтр")}
on:click={() => clearUsersFilter(chip.key)}
>
<X size={12} />
</button>
</span>
{/each}
</div>
{/if}
{/snippet}
<div class="admin-toolbar admin-toolbar-users"> <div class="admin-toolbar admin-toolbar-users">
<div class="admin-toolbar-search"> <div class="admin-toolbar-search">
<Input <Input
@@ -207,11 +311,28 @@
/> />
<AdminButton <AdminButton
variant="primary" variant="primary"
class="admin-users-search-button"
onclick={() => { onclick={() => {
usersStore.updateState({ usersPage: 0 }); usersStore.updateState({ usersPage: 0 });
usersStore.loadUsers(); usersStore.loadUsers();
}}>{at("find", {}, "Найти")}</AdminButton }}>{at("find", {}, "Найти")}</AdminButton
> >
<AdminButton
variant={activeUsersFilterCount ? "primary" : "default"}
class="admin-users-filter-toggle"
aria-label={at("users_filters_open", {}, "Открыть фильтры")}
aria-haspopup="dialog"
aria-expanded={usersFilterSheetOpen}
onclick={() => {
usersFilterSheetOpen = true;
}}
>
<Sliders size={15} />
<span class="admin-users-filter-toggle-label">{at("filters", {}, "Фильтры")}</span>
{#if activeUsersFilterCount}
<span class="admin-users-filter-count">{activeUsersFilterCount}</span>
{/if}
</AdminButton>
</div> </div>
<div class="admin-toolbar-controls"> <div class="admin-toolbar-controls">
@@ -264,8 +385,45 @@
<strong>{usersTotal}</strong> <strong>{usersTotal}</strong>
</div> </div>
</div> </div>
{@render renderActiveUserFilterChips()}
</div> </div>
<Dialog
open={usersFilterSheetOpen}
class="admin-dialog admin-users-filter-dialog"
title={at("users_filters_title", {}, "Фильтры пользователей")}
description={at("users_filters_description", {}, "Уточните список пользователей")}
closeLabel={at("close_menu", {}, "Закрыть меню")}
onclose={() => {
usersFilterSheetOpen = false;
}}
>
<div class="admin-users-filter-sheet-body">
<div class="admin-users-filter-fields admin-users-filter-fields-sheet">
{@render renderUserFilterControls()}
</div>
{@render renderActiveUserFilterChips()}
<div class="admin-users-filter-sheet-actions">
<AdminButton
variant="ghost"
disabled={activeUsersFilterCount === 0}
onclick={resetUsersFilters}
>
{at("reset", {}, "Сбросить")}
</AdminButton>
<AdminButton
variant="primary"
onclick={() => {
usersFilterSheetOpen = false;
}}
>
{at("done", {}, "Готово")}
</AdminButton>
</div>
</div>
</Dialog>
<div class="admin-users-table-wrap"> <div class="admin-users-table-wrap">
{#if usersLoading} {#if usersLoading}
<AdminTableSkeleton <AdminTableSkeleton
@@ -429,7 +587,111 @@
<style> <style>
:global(.admin-toolbar-users .admin-toolbar-controls) { :global(.admin-toolbar-users .admin-toolbar-controls) {
grid-template-columns: repeat(3, minmax(130px, 1fr)) minmax(96px, auto); grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(82px, auto);
gap: 10px;
}
:global(.admin-users-search-button) {
min-width: 82px;
}
:global(.admin-users-filter-toggle) {
display: none;
position: relative;
align-items: center;
gap: 7px;
min-width: 0;
}
.admin-users-filter-count {
display: inline-grid;
min-width: 18px;
height: 18px;
place-items: center;
padding: 0 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--admin-bg) 74%, transparent);
color: inherit;
font-size: 11px;
font-weight: 750;
line-height: 1;
}
.admin-users-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.admin-users-filter-chip {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
min-height: 28px;
padding: 3px 5px 3px 10px;
border: 1px solid var(--admin-border);
border-radius: 999px;
background: color-mix(in srgb, var(--admin-muted) 8%, transparent);
color: var(--admin-text);
font-size: 12px;
}
.admin-users-filter-chip-text {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 260px;
}
.admin-users-filter-chip strong {
color: var(--admin-muted);
font-size: 11px;
font-weight: 650;
}
.admin-users-filter-chip-text > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-users-filter-chip button {
display: inline-grid;
width: 20px;
height: 20px;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--admin-muted);
cursor: pointer;
}
.admin-users-filter-chip button:hover,
.admin-users-filter-chip button:focus-visible {
background: color-mix(in srgb, var(--admin-muted) 14%, transparent);
color: var(--admin-text);
outline: none;
}
.admin-users-filter-fields-sheet,
.admin-users-filter-sheet-body {
display: grid;
gap: 12px;
}
.admin-users-filter-sheet-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
padding-top: 2px;
}
:global(.admin-users-filter-dialog) {
width: min(100%, 420px);
} }
.admin-users-table-wrap :global(.admin-table-wrap) { .admin-users-table-wrap :global(.admin-table-wrap) {
@@ -571,6 +833,52 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
:global(.admin-toolbar-users .admin-toolbar-search) {
grid-template-columns: minmax(0, 1fr) auto auto;
}
:global(.admin-toolbar-users .admin-toolbar-controls) {
display: none;
}
:global(.admin-users-search-button) {
min-width: 0;
padding-inline: 10px;
}
:global(.admin-users-filter-toggle) {
display: inline-flex;
min-width: 38px;
padding-inline: 10px;
}
.admin-users-filter-toggle-label {
display: none;
}
.admin-users-filter-chips {
gap: 5px;
}
.admin-users-filter-chip-text {
max-width: min(250px, calc(100vw - 96px));
}
:global(.dialog:has(.admin-users-filter-dialog)) {
align-items: end;
padding: max(12px, env(safe-area-inset-top)) 0 0;
}
:global(.admin-users-filter-dialog) {
width: 100%;
max-height: min(82dvh, 620px);
padding: 16px;
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 18px 18px 0 0;
}
.admin-users-table-wrap :global(.admin-users-table thead) { .admin-users-table-wrap :global(.admin-users-table thead) {
display: table-header-group; display: table-header-group;
} }
+5 -2
View File
@@ -469,8 +469,11 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
state.update((st) => ({ ...st, userActionBusy: true })); state.update((st) => ({ ...st, userActionBusy: true }));
try { try {
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" }); const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен")); if (res?.ok) {
else onToast(res?.error || at("error", {}, "Ошибка")); onToast(at("trial_reset", {}, "Триал сброшен"));
await openUser(s.openedUser.user_id, { skipPush: true, pathContext: _pathContext });
if (_activeRef === "users") await loadUsers();
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally { } finally {
state.update((st) => ({ ...st, userActionBusy: false })); state.update((st) => ({ ...st, userActionBusy: false }));
} }
@@ -7,7 +7,10 @@
export let ariaLabel = ""; export let ariaLabel = "";
export let placeholder = ""; export let placeholder = "";
export let disabled = false; export let disabled = false;
export let side = "bottom";
export let align = "start";
export let sideOffset = 6; export let sideOffset = 6;
export let collisionPadding = 12;
export let onValueChange = () => {}; export let onValueChange = () => {};
let className = ""; let className = "";
export { className as class }; export { className as class };
@@ -29,7 +32,7 @@
<ChevronDown size={14} class="admin-select-icon" /> <ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger> </Select.Trigger>
<Select.Portal> <Select.Portal>
<Select.Content class="admin-select-content" {sideOffset}> <Select.Content class="admin-select-content" {side} {align} {sideOffset} {collisionPadding}>
<Select.Viewport class="admin-select-viewport"> <Select.Viewport class="admin-select-viewport">
{#each items as item (item.value)} {#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item"> <Select.Item value={item.value} label={item.label} class="admin-select-item">
+17
View File
@@ -3605,6 +3605,15 @@
transform: translateX(0); transform: translateX(0);
} }
.admin-screen-wrap.is-admin-language-open .admin-sidebar {
z-index: 120;
pointer-events: none;
}
.admin-screen-wrap.is-admin-language-open .admin-language-switch {
pointer-events: auto;
}
.admin-sidebar-backdrop { .admin-sidebar-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -4045,6 +4054,7 @@
.admin-select-content { .admin-select-content {
z-index: 90; z-index: 90;
min-width: var(--bits-select-anchor-width); min-width: var(--bits-select-anchor-width);
max-width: calc(100vw - 24px);
max-height: var(--bits-select-content-available-height, 320px); max-height: var(--bits-select-content-available-height, 320px);
overflow: hidden; overflow: hidden;
border: 1px solid var(--admin-border); border: 1px solid var(--admin-border);
@@ -4098,6 +4108,13 @@
user-select: none; user-select: none;
} }
.admin-select-item span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-select-item[data-highlighted], .admin-select-item[data-highlighted],
.admin-select-item:hover { .admin-select-item:hover {
background: var(--admin-surface-2); background: var(--admin-surface-2);
+56
View File
@@ -336,6 +336,18 @@ a {
margin-left: 0; margin-left: 0;
justify-content: center; justify-content: center;
} }
.subscription-renew-action {
min-height: 54px;
padding-block: 12px;
font-size: 15px;
}
.subscription-renew-action svg {
width: 19px;
height: 19px;
flex: 0 0 auto;
}
} }
.traffic-top, .traffic-top,
@@ -2366,6 +2378,50 @@ a {
color: var(--accent); color: var(--accent);
} }
.auth-language-trigger {
appearance: none;
-webkit-tap-highlight-color: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 26px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.auth-language-trigger:hover,
.auth-language-trigger[data-state="open"] {
color: var(--accent);
}
.auth-language-trigger:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
}
.auth-language-trigger > span:last-of-type {
text-decoration: underline;
text-underline-offset: 2px;
}
.auth-language-trigger > svg,
.auth-language-trigger > .emoji-flag {
flex: 0 0 auto;
}
.auth-language-content {
width: min(188px, calc(100vw - 40px));
min-width: 154px;
transform-origin: top center;
}
.auth-bottom strong { .auth-bottom strong {
color: var(--accent); color: var(--accent);
} }
+110 -34
View File
@@ -1,6 +1,14 @@
<script> <script>
import { LockKeyhole, Mail, Send, TriangleAlert } from "$components/ui/icons.js"; import {
import { Tooltip } from "$components/ui/primitives.js"; Check,
ChevronsUpDown,
Globe2,
LockKeyhole,
Mail,
Send,
TriangleAlert,
} from "$components/ui/icons.js";
import { Select, Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte"; import Button from "$components/ui/button.svelte";
import BrandMark from "$lib/webapp/BrandMark.svelte"; import BrandMark from "$lib/webapp/BrandMark.svelte";
@@ -32,7 +40,15 @@
export let telegramLoginUnavailableMessage; export let telegramLoginUnavailableMessage;
export let privacyPolicyUrl; export let privacyPolicyUrl;
export let userAgreementUrl; export let userAgreementUrl;
export let currentLang = "ru";
export let currentLanguageOption = null;
export let languageOptions = [];
export let languageMenuOpen = false;
export let languageClickGuard = false;
export let languageClickGuardArmed = false;
export let t; export let t;
export let setLanguageMenuOpen = () => {};
export let updateLoginLanguage = () => {};
export let requestEmailCode; export let requestEmailCode;
export let loginWithEmailPassword; export let loginWithEmailPassword;
export let verifyEmailCode; export let verifyEmailCode;
@@ -48,6 +64,13 @@
$: emailAuthEnabled = CFG.emailAuthEnabled !== false; $: emailAuthEnabled = CFG.emailAuthEnabled !== false;
$: passwordModeActive = Boolean(passwordLoginMode && emailAuthEnabled); $: passwordModeActive = Boolean(passwordLoginMode && emailAuthEnabled);
$: authCardHeight = authPanelHeight ? `${authPanelHeight}px` : undefined; $: authCardHeight = authPanelHeight ? `${authPanelHeight}px` : undefined;
$: showLanguageSelect = languageOptions.length > 1;
function closeLanguageFromGuard(event) {
event.preventDefault();
event.stopPropagation();
if (languageClickGuardArmed) setLanguageMenuOpen(false);
}
</script> </script>
{#if screen === "code"} {#if screen === "code"}
@@ -236,40 +259,93 @@
</div> </div>
{/key} {/key}
</section> </section>
{#if userAgreementUrl || privacyPolicyUrl} {#if userAgreementUrl || privacyPolicyUrl || showLanguageSelect}
<div class="auth-legal"> <div class="auth-legal">
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span> {#if userAgreementUrl || privacyPolicyUrl}
<div class="auth-legal-links"> <span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
{#if privacyPolicyUrl} <div class="auth-legal-links">
<a {#if privacyPolicyUrl}
href={privacyPolicyUrl} <a
target="_blank" href={privacyPolicyUrl}
rel="noopener noreferrer" target="_blank"
onclick={(e) => { rel="noopener noreferrer"
e.preventDefault(); onclick={(e) => {
openExternalLink(privacyPolicyUrl); e.preventDefault();
}} openExternalLink(privacyPolicyUrl);
}}
>
{t("wa_auth_legal_privacy")}
</a>
{/if}
{#if privacyPolicyUrl && userAgreementUrl}
<span>{t("wa_auth_legal_and")}</span>
{/if}
{#if userAgreementUrl}
<a
href={userAgreementUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => {
e.preventDefault();
openExternalLink(userAgreementUrl);
}}
>
{t("wa_auth_legal_agreement")}
</a>
{/if}
</div>
{/if}
{#if showLanguageSelect}
{#if languageMenuOpen || languageClickGuard}
<button
class="language-select-guard"
class:language-select-guard--armed={languageClickGuardArmed}
type="button"
aria-label={t("wa_close")}
onpointerdown={closeLanguageFromGuard}
onclick={closeLanguageFromGuard}
></button>
{/if}
<Select.Root
type="single"
bind:open={languageMenuOpen}
value={currentLang}
items={languageOptions}
onOpenChange={setLanguageMenuOpen}
onValueChange={updateLoginLanguage}
>
<Select.Trigger class="auth-language-trigger" aria-label={t("wa_settings_language")}>
<Globe2 size={13} />
<span class="emoji-flag" aria-hidden="true"
>{currentLanguageOption?.flag || "🏳️"}</span
>
<span>{currentLanguageOption?.label || currentLang}</span>
<ChevronsUpDown size={12} />
</Select.Trigger>
<Select.Content
class="language-select-content auth-language-content"
side="bottom"
align="center"
sideOffset={7}
> >
{t("wa_auth_legal_privacy")} <Select.Viewport class="language-select-viewport">
</a> {#each languageOptions as option (option.value)}
{/if} <Select.Item
{#if privacyPolicyUrl && userAgreementUrl} value={option.value}
<span>{t("wa_auth_legal_and")}</span> label={option.label}
{/if} class="language-select-item"
{#if userAgreementUrl} >
<a <span class="language-select-item-main">
href={userAgreementUrl} <span class="emoji-flag" aria-hidden="true">{option.flag}</span>
target="_blank" <span>{option.label}</span>
rel="noopener noreferrer" </span>
onclick={(e) => { <Check size={15} class="language-select-item-check" />
e.preventDefault(); </Select.Item>
openExternalLink(userAgreementUrl); {/each}
}} </Select.Viewport>
> </Select.Content>
{t("wa_auth_legal_agreement")} </Select.Root>
</a> {/if}
{/if}
</div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -6,7 +6,7 @@
const AUTO_OPEN_DELAY_MS = 80; const AUTO_OPEN_DELAY_MS = 80;
const MANUAL_STATE_DELAY_MS = 1600; const MANUAL_STATE_DELAY_MS = 1600;
const DONE_STATE_DELAY_MS = 900; const DONE_STATE_DELAY_MS = 900;
const CLOSE_ATTEMPT_DELAY_MS = 120; const CLOSE_ATTEMPT_DELAY_MS = 2500;
export let brand = {}; export let brand = {};
export let appLaunchTarget = ""; export let appLaunchTarget = "";
@@ -54,7 +54,6 @@
autoOpenTimer = window.setTimeout(openTarget, AUTO_OPEN_DELAY_MS); autoOpenTimer = window.setTimeout(openTarget, AUTO_OPEN_DELAY_MS);
window.addEventListener("pagehide", notePageLeft); window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
return () => { return () => {
@@ -63,7 +62,6 @@
clearTimer(doneStateTimer); clearTimer(doneStateTimer);
clearTimer(closeAttemptTimer); clearTimer(closeAttemptTimer);
window.removeEventListener("pagehide", notePageLeft); window.removeEventListener("pagehide", notePageLeft);
window.removeEventListener("blur", notePageLeft);
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}); });
@@ -91,7 +89,9 @@
if (!attempted || state === "done" || !activeTarget) return; if (!attempted || state === "done" || !activeTarget) return;
state = "done"; state = "done";
clearTimer(closeAttemptTimer); clearTimer(closeAttemptTimer);
closeAttemptTimer = window.setTimeout(tryCloseWindow, CLOSE_ATTEMPT_DELAY_MS); closeAttemptTimer = window.setTimeout(() => {
if (pageLeft || document.hidden) tryCloseWindow();
}, CLOSE_ATTEMPT_DELAY_MS);
} }
function notePageLeft() { function notePageLeft() {
@@ -343,7 +343,7 @@
</Button> </Button>
{/if} {/if}
<Button <Button
class="wide" class={`wide${subscription.active ? " subscription-renew-action" : ""}`}
variant={subscription.active ? "secondary" : "default"} variant={subscription.active ? "secondary" : "default"}
onclick={openPaymentModal} onclick={openPaymentModal}
> >
+7
View File
@@ -1043,6 +1043,13 @@
"admin_users_search_placeholder": "ID, @username, or email", "admin_users_search_placeholder": "ID, @username, or email",
"admin_find": "Find", "admin_find": "Find",
"admin_filter": "Filter", "admin_filter": "Filter",
"admin_filters": "Filters",
"admin_active_filters": "Active filters",
"admin_clear_filter": "Clear {label}",
"admin_done": "Done",
"admin_users_filters_open": "Open filters",
"admin_users_filters_title": "User filters",
"admin_users_filters_description": "Refine the user list without leaving the table.",
"admin_sort": "Sort", "admin_sort": "Sort",
"admin_total": "Total", "admin_total": "Total",
"admin_users_empty": "No users found", "admin_users_empty": "No users found",
+7
View File
@@ -1043,6 +1043,13 @@
"admin_users_search_placeholder": "ID, @username или email", "admin_users_search_placeholder": "ID, @username или email",
"admin_find": "Найти", "admin_find": "Найти",
"admin_filter": "Фильтр", "admin_filter": "Фильтр",
"admin_filters": "Фильтры",
"admin_active_filters": "Активные фильтры",
"admin_clear_filter": "Сбросить {label}",
"admin_done": "Готово",
"admin_users_filters_open": "Открыть фильтры",
"admin_users_filters_title": "Фильтры пользователей",
"admin_users_filters_description": "Уточните список пользователей, не уходя из таблицы.",
"admin_sort": "Сортировка", "admin_sort": "Сортировка",
"admin_total": "Всего", "admin_total": "Всего",
"admin_users_empty": "Никого не найдено", "admin_users_empty": "Никого не найдено",
+77
View File
@@ -0,0 +1,77 @@
import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.app.web.admin_api_impl import users as admin_users
class FakeSession:
def __init__(self):
self.committed = False
self.rolled_back = False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def commit(self):
self.committed = True
async def rollback(self):
self.rolled_back = True
class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase):
def _request(self, session: FakeSession):
return SimpleNamespace(
app={
"settings": SimpleNamespace(),
"async_session_factory": lambda: session,
},
match_info={"user_id": "42"},
)
async def test_marks_trial_reset_without_deleting_subscription_history(self):
session = FakeSession()
request = self._request(session)
user = SimpleNamespace(user_id=42)
with (
patch.object(admin_users, "_require_admin_user_id", return_value=100),
patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)),
patch.object(
admin_users.user_dal,
"mark_trial_eligibility_reset",
AsyncMock(return_value=object()),
) as mark_reset,
patch.object(
admin_users.subscription_dal,
"delete_all_user_subscriptions",
AsyncMock(),
) as delete_all,
patch.object(
admin_users.message_log_dal, "create_message_log_no_commit", AsyncMock()
) as log,
patch.object(
admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()
) as invalidate,
):
response = await admin_users.admin_user_reset_trial_route(request)
self.assertEqual(response.status, 200)
self.assertEqual(json.loads(response.text)["ok"], True)
mark_reset.assert_awaited_once_with(session, 42)
delete_all.assert_not_awaited()
log_payload = log.await_args.args[1]
self.assertEqual(log_payload["event_type"], "admin_reset_trial_webapp")
self.assertEqual(log_payload["target_user_id"], 42)
invalidate.assert_awaited_once()
self.assertTrue(session.committed)
self.assertFalse(session.rolled_back)
if __name__ == "__main__":
unittest.main()
+151
View File
@@ -0,0 +1,151 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from aiogram.exceptions import TelegramBadRequest
from bot.handlers.user.start import ensure_required_channel_subscription
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
from bot.utils.channel_subscription import (
is_required_channel_access_error,
normalize_required_channel_id,
)
class I18nStub:
def gettext(self, _lang, key, **_kwargs):
return {
"channel_subscription_required": "subscribe first",
"channel_subscription_check_failed": "check failed",
}.get(key, key)
class FakeBot:
def __init__(self, *, status="member", error=None):
self.status = status
self.error = error
self.calls = []
async def get_chat_member(self, chat_id, user_id):
self.calls.append((chat_id, user_id))
if self.error:
raise self.error
return SimpleNamespace(status=self.status)
def _settings(required_channel_id):
return SimpleNamespace(
REQUIRED_CHANNEL_ID=required_channel_id,
REQUIRED_CHANNEL_LINK="https://t.me/example",
ADMIN_IDS=[],
DEFAULT_LANGUAGE="en",
)
def _message_event(bot, user_id=42):
return SimpleNamespace(
from_user=SimpleNamespace(id=user_id),
bot=bot,
answer=AsyncMock(),
)
def _db_user(*, verified=False, verified_for=None):
return SimpleNamespace(
channel_subscription_verified=verified,
channel_subscription_verified_for=verified_for,
)
class RequiredChannelIdNormalizationTests(unittest.TestCase):
def test_normalizes_raw_channel_ids_to_bot_api_chat_ids(self):
self.assertEqual(normalize_required_channel_id(1234567890), -1001234567890)
self.assertEqual(normalize_required_channel_id("1234567890"), -1001234567890)
self.assertEqual(normalize_required_channel_id(-1234567890), -1001234567890)
self.assertEqual(normalize_required_channel_id(-1001234567890), -1001234567890)
self.assertEqual(normalize_required_channel_id(-123456789), -123456789)
def test_ignores_empty_channel_ids(self):
self.assertIsNone(normalize_required_channel_id(None))
self.assertIsNone(normalize_required_channel_id(""))
self.assertIsNone(normalize_required_channel_id(0))
def test_detects_channel_configuration_errors(self):
self.assertTrue(
is_required_channel_access_error(
TelegramBadRequest(method=None, message="Bad Request: chat not found")
)
)
self.assertFalse(
is_required_channel_access_error(
TelegramBadRequest(method=None, message="Bad Request: user not found")
)
)
class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase):
async def test_check_uses_normalized_channel_id_and_persists_it(self):
bot = FakeBot(status="member")
event = _message_event(bot)
user = _db_user()
with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()) as update_user:
result = await ensure_required_channel_subscription(
event,
_settings(1234567890),
I18nStub(),
"en",
AsyncMock(),
db_user=user,
)
self.assertTrue(result)
self.assertEqual(bot.calls, [(-1001234567890, 42)])
payload = update_user.await_args.args[2]
self.assertTrue(payload["channel_subscription_verified"])
self.assertEqual(payload["channel_subscription_verified_for"], -1001234567890)
async def test_channel_access_errors_show_check_failed_without_persisting_false_status(self):
bot = FakeBot(error=TelegramBadRequest(method=None, message="Bad Request: chat not found"))
event = _message_event(bot)
user = _db_user()
with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()) as update_user:
result = await ensure_required_channel_subscription(
event,
_settings(1234567890),
I18nStub(),
"en",
AsyncMock(),
db_user=user,
)
self.assertFalse(result)
event.answer.assert_awaited_once_with("check failed")
update_user.assert_not_awaited()
class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self):
middleware = ChannelSubscriptionMiddleware(_settings(1234567890), I18nStub())
handler = AsyncMock(return_value="ok")
event = SimpleNamespace(callback_query=None, message=None)
data = {
"event_from_user": SimpleNamespace(id=42),
"session": AsyncMock(),
"i18n_data": {"current_language": "en", "i18n_instance": I18nStub()},
}
user = _db_user(verified=True, verified_for=-1001234567890)
with patch(
"bot.middlewares.channel_subscription.user_dal.get_user_by_id",
AsyncMock(return_value=user),
):
result = await middleware(handler, event, data)
self.assertEqual(result, "ok")
handler.assert_awaited_once_with(event, data)
if __name__ == "__main__":
unittest.main()
+43 -2
View File
@@ -202,7 +202,7 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
TRIAL_SQUAD_UUIDS="trial-squad", TRIAL_SQUAD_UUIDS="trial-squad",
) )
service = _make_service(settings) service = _make_service(settings)
service.has_had_any_subscription = AsyncMock(return_value=False) service.has_trial_blocking_subscription = AsyncMock(return_value=False)
service._get_or_create_panel_user_link_details = AsyncMock( service._get_or_create_panel_user_link_details = AsyncMock(
return_value=("panel-user", "panel-sub", "short", True) return_value=("panel-user", "panel-sub", "short", True)
) )
@@ -255,7 +255,7 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
TRIAL_SQUAD_UUIDS=" , ", TRIAL_SQUAD_UUIDS=" , ",
) )
service = _make_service(settings) service = _make_service(settings)
service.has_had_any_subscription = AsyncMock(return_value=False) service.has_trial_blocking_subscription = AsyncMock(return_value=False)
service._get_or_create_panel_user_link_details = AsyncMock( service._get_or_create_panel_user_link_details = AsyncMock(
return_value=("panel-user", "panel-sub", "short", True) return_value=("panel-user", "panel-sub", "short", True)
) )
@@ -292,6 +292,47 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1] panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
self.assertEqual(panel_payload["activeInternalSquads"], ["fallback-a", "fallback-b"]) self.assertEqual(panel_payload["activeInternalSquads"], ["fallback-a", "fallback-b"])
async def test_activate_trial_rejects_users_with_blocking_subscription_history(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(
_tariffs_config_payload(),
tmpdir,
TRIAL_ENABLED=True,
TRIAL_DURATION_DAYS=3,
)
service = _make_service(settings)
service.has_trial_blocking_subscription = AsyncMock(return_value=True)
service._get_or_create_panel_user_link_details = AsyncMock()
service.panel_service.update_user_details_on_panel = AsyncMock()
session = AsyncMock()
db_user = SimpleNamespace(
user_id=42,
telegram_id=42,
email=None,
username="trial-user",
first_name="Trial",
last_name="User",
)
with (
patch(
"bot.services.subscription_service_impl.trial.user_dal.get_user_by_id",
AsyncMock(return_value=db_user),
),
patch(
"bot.services.subscription_service_impl.trial.subscription_dal.upsert_subscription",
AsyncMock(),
) as upsert_subscription,
):
result = await service.activate_trial_subscription(session, user_id=42)
self.assertFalse(result["activated"])
self.assertFalse(result["eligible"])
self.assertEqual(result["message_key"], "trial_already_had_subscription_or_trial")
service._get_or_create_panel_user_link_details.assert_not_awaited()
service.panel_service.update_user_details_on_panel.assert_not_awaited()
upsert_subscription.assert_not_awaited()
async def test_activate_subscription_dispatches_traffic_sale_mode(self): async def test_activate_subscription_dispatches_traffic_sale_mode(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(_tariffs_config_payload(), tmpdir) settings = _make_settings(_tariffs_config_payload(), tmpdir)
+10
View File
@@ -29,3 +29,13 @@ def test_support_models_expose_expected_tables():
def test_user_model_tracks_lifetime_traffic_sync_timestamp(): def test_user_model_tracks_lifetime_traffic_sync_timestamp():
assert "lifetime_used_traffic_synced_at" in User.__table__.columns assert "lifetime_used_traffic_synced_at" in User.__table__.columns
def test_trial_eligibility_reset_migration_and_model_are_registered():
ids = [migration.id for migration in MIGRATIONS]
assert "0033_add_trial_eligibility_reset_marker" in ids
assert ids.index("0033_add_trial_eligibility_reset_marker") > ids.index(
"0032_add_telegram_notification_status"
)
assert "trial_eligibility_reset_at" in User.__table__.columns
+39 -1
View File
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from sqlalchemy.sql.dml import Delete, Update from sqlalchemy.sql.dml import Delete, Update
from db.dal import user_dal from db.dal import subscription_dal, user_dal
class FakeResult: class FakeResult:
@@ -122,6 +122,44 @@ class UserDalReferralTests(unittest.IsolatedAsyncioTestCase):
self.assertIs(result, referrer) self.assertIs(result, referrer)
async def test_mark_trial_eligibility_reset_updates_user_marker(self):
reset_at = datetime(2026, 6, 1, tzinfo=timezone.utc)
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(rowcount=1)))
result = await user_dal.mark_trial_eligibility_reset(session, 42, reset_at=reset_at)
self.assertEqual(result, reset_at)
stmt = session.execute.await_args.args[0]
sql = str(
stmt.compile(
dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True},
)
).upper()
self.assertIn("UPDATE USERS", sql)
self.assertIn("TRIAL_ELIGIBILITY_RESET_AT", sql)
self.assertIn("USER_ID = 42", sql)
class SubscriptionDalTrialEligibilityTests(unittest.IsolatedAsyncioTestCase):
async def test_trial_blocking_history_honors_user_reset_marker(self):
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(7)))
result = await subscription_dal.has_trial_blocking_subscription_for_user(session, 42)
self.assertTrue(result)
stmt = session.execute.await_args.args[0]
sql = str(
stmt.compile(
dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True},
)
).upper()
self.assertIn("TRIAL_ELIGIBILITY_RESET_AT", sql)
self.assertIn("SUBSCRIPTIONS.IS_ACTIVE = TRUE", sql)
self.assertIn("COALESCE(SUBSCRIPTIONS.START_DATE, SUBSCRIPTIONS.END_DATE)", sql)
self.assertIn("SUBSCRIPTIONS.USER_ID = 42", sql)
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self): async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
+4
View File
@@ -296,6 +296,10 @@ class WebAppRouteContractTests(unittest.TestCase):
self.assertIn("URLSearchParams", response.text) self.assertIn("URLSearchParams", response.text)
self.assertIn("Settings added", response.text) self.assertIn("Settings added", response.text)
self.assertIn("window.close()", response.text) self.assertIn("window.close()", response.text)
self.assertNotIn('window.addEventListener("blur", notePageLeft)', response.text)
self.assertNotIn("window.setTimeout(tryCloseWindow, 120)", response.text)
self.assertIn("const CLOSE_ATTEMPT_DELAY_MS = 2500", response.text)
self.assertIn("if (pageLeft || document.hidden) tryCloseWindow();", response.text)
self.assertIn(r"/^(?:javascript|data|vbscript|https?):/i", response.text) self.assertIn(r"/^(?:javascript|data|vbscript|https?):/i", response.text)
def test_app_deeplink_gateway_uses_i18n_template(self): def test_app_deeplink_gateway_uses_i18n_template(self):
+3
View File
@@ -44,6 +44,9 @@ def test_open_app_route_uses_fallback_screen_without_auth_flow():
assert "AppLaunchScreen" in app_source assert "AppLaunchScreen" in app_source
assert 'mode = isAppLaunchRoute ? "appLaunch"' in app_source assert 'mode = isAppLaunchRoute ? "appLaunch"' in app_source
assert "window.close()" in screen_source assert "window.close()" in screen_source
assert 'window.addEventListener("blur", notePageLeft)' not in screen_source
assert "CLOSE_ATTEMPT_DELAY_MS = 2500" in screen_source
assert "if (pageLeft || document.hidden) tryCloseWindow();" in screen_source
launch_guard_pos = app_source.index("if (isAppLaunchRoute) return;") launch_guard_pos = app_source.index("if (isAppLaunchRoute) return;")
boot_pos = app_source.index("boot();", launch_guard_pos) boot_pos = app_source.index("boot();", launch_guard_pos)