feat: add telegram guardrails for trials and referrals

This commit is contained in:
3252a8
2026-06-04 10:51:58 +03:00
parent 33707b7257
commit 2ad6b14513
26 changed files with 1944 additions and 125 deletions
+53 -3
View File
@@ -407,6 +407,18 @@ SETTINGS_MANIFEST: List[SettingField] = [
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Триал без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для "
"активации триала. Disposable email домены всегда требуют Telegram."
),
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_SQUAD_UUIDS",
"string",
@@ -417,12 +429,50 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
# ─── Referral program ──────────────────────────────────────────
SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
"REFERRAL_ONE_BONUS_PER_REFEREE",
"bool",
"pricing",
"Один бонус на приглашённого",
subsection="referral",
),
SettingField(
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
"REFERRAL_WELCOME_BONUS_DAYS",
"int",
"pricing",
"Приветственный бонус (дней)",
min=0,
subsection="referral",
),
SettingField(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Приветственный бонус без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для получения "
"реферального приветственного бонуса. Disposable email домены всегда требуют Telegram."
),
subsection="referral",
),
SettingField(
"LEGACY_REFS",
"bool",
"pricing",
"Поддержка старых ref-ссылок",
subsection="referral",
),
SettingField(
"DISPOSABLE_EMAIL_DOMAINS",
"text",
"pricing",
"Disposable email домены",
(
"Домены по одному на строку или через запятую. Пользователи без Telegram с такими "
"email не смогут получить trial или реферальный приветственный бонус."
),
placeholder="mailinator.com\ntemp-mail.org\nyopmail.com",
subsection="referral",
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
"bool",
+1 -1
View File
@@ -42,7 +42,7 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token,
)
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.email_auth_service import EmailAuthService, is_disposable_email, normalize_email
from bot.services.email_templates import render_account_merged
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
+120 -4
View File
@@ -1012,13 +1012,53 @@ async def _request_email_code(
def _telegram_id_for_user(user: User) -> Optional[int]:
if user.telegram_id:
return int(user.telegram_id)
if user.user_id and int(user.user_id) > 0:
return int(user.user_id)
telegram_id = getattr(user, "telegram_id", None)
if telegram_id:
return int(telegram_id)
user_id = getattr(user, "user_id", None)
if user_id and int(user_id) > 0:
return int(user_id)
return None
def _user_has_linked_telegram(user: User) -> bool:
return bool(getattr(user, "telegram_id", None))
def _email_only_telegram_required_reason(
settings: Settings,
user: User,
*,
without_telegram_enabled_attr: str,
) -> Optional[str]:
if _user_has_linked_telegram(user):
return None
if is_disposable_email(getattr(user, "email", None), settings):
return "disposable_email"
if not bool(getattr(settings, without_telegram_enabled_attr, True)):
return "telegram_required"
return None
def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]:
return _email_only_telegram_required_reason(
settings,
user,
without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED",
)
def _referral_welcome_telegram_required_reason(
settings: Settings,
user: User,
) -> Optional[str]:
return _email_only_telegram_required_reason(
settings,
user,
without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
)
def _panel_description_for_user(user: User) -> str:
return panel_description_from_profile(
user.username,
@@ -1433,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed(
if not raw_referral_param or not user.referred_by_id:
return None
settings: Settings = request.app["settings"]
if _referral_welcome_telegram_required_reason(settings, user):
return None
return await _grant_referral_welcome_bonus_if_eligible(request, session, user)
async def _grant_referral_welcome_bonus_if_eligible(
request: web.Request,
session: AsyncSession,
user: User,
) -> Optional[datetime]:
if not user.referred_by_id:
return None
settings: Settings = request.app["settings"]
referral_welcome_days = max(
0,
@@ -1456,6 +1511,67 @@ async def _apply_referral_welcome_bonus_if_needed(
)
def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]:
if not value:
return None
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="referral_welcome_claim",
)
if rate_limit_response:
return rate_limit_response
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
reason = _referral_welcome_telegram_required_reason(settings, db_user)
if reason:
await session.rollback()
return _json_error(400, "referral_welcome_telegram_required", reason)
end_date = await _grant_referral_welcome_bonus_if_eligible(
request,
session,
db_user,
)
if not end_date:
await session.rollback()
return _json_error(
400,
"referral_welcome_unavailable",
"Referral welcome bonus is not available",
)
await session.commit()
except Exception:
await session.rollback()
logger.exception("Referral welcome bonus claim failed")
return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed")
await _invalidate_webapp_user_caches(settings, user_id, include_devices=True)
return web.json_response(
{
"ok": True,
"claimed": True,
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
"end_date_text": _webapp_datetime_text(end_date),
}
)
async def _ensure_user_from_telegram(
session: AsyncSession,
telegram_user: Dict[str, Any],
+8
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.auth import _trial_telegram_required_reason
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from db.dal import message_log_dal
@@ -390,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
telegram_required_reason = _trial_telegram_required_reason(settings, db_user)
if telegram_required_reason:
return _json_error(
400,
"trial_telegram_required",
telegram_required_reason,
)
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
if not activation_result or not activation_result.get("activated"):
+1
View File
@@ -85,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
"/api/account/telegram/notifications/probe",
account_telegram_notifications_probe_route,
)
app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_route)
app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route)
+32 -4
View File
@@ -1,6 +1,11 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.auth import (
_referral_welcome_telegram_required_reason,
_trial_telegram_required_reason,
_user_has_linked_telegram,
)
from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload
from bot.services.telegram_notifications import (
@@ -64,11 +69,15 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if active and local_sub
else None
)
trial_available = bool(
trial_base_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
)
trial_telegram_required_reason = (
_trial_telegram_required_reason(settings, db_user) if trial_base_available else None
)
trial_available = bool(trial_base_available and not trial_telegram_required_reason)
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
plans_payload = _serialize_plans(
settings,
@@ -95,6 +104,15 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
telegram_linked = _user_has_linked_telegram(db_user)
referral_welcome_days = max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
)
referral_welcome_telegram_required_reason = (
_referral_welcome_telegram_required_reason(settings, db_user)
if db_user.referred_by_id and not active and referral_welcome_days > 0
else None
)
telegram_notifications_status = normalize_telegram_notification_status(
getattr(db_user, "telegram_notifications_status", None)
)
@@ -111,7 +129,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
db_user.email and db_user.email_verified_at and db_user.password_hash
),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"telegram_linked": telegram_linked,
"telegram_notifications_status": telegram_notifications_status,
"telegram_notifications_enabled": (
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
@@ -137,9 +155,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"webapp_link": webapp_referral_link,
"invited_count": referral_stats.get("invited_count", 0),
"purchased_count": referral_stats.get("purchased_count", 0),
"welcome_bonus_days": max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
"welcome_bonus_days": referral_welcome_days,
"welcome_bonus_without_telegram_enabled": bool(
getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True)
),
"welcome_bonus_requires_telegram": bool(
referral_welcome_telegram_required_reason and not telegram_linked
),
"welcome_bonus_block_reason": referral_welcome_telegram_required_reason,
"one_bonus_per_referee": bool(
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
),
@@ -174,6 +197,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
),
"trial_enabled": bool(settings.TRIAL_ENABLED),
"trial_available": trial_available,
"trial_without_telegram_enabled": bool(
getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True)
),
"trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked),
"trial_block_reason": trial_telegram_required_reason,
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
@@ -61,11 +61,39 @@ def normalize_email(value: str) -> str:
return (value or "").strip().lower()
def email_domain(value: Optional[str]) -> str:
email = normalize_email(value or "")
if "@" not in email:
return ""
return email.rsplit("@", 1)[1].strip().lower().rstrip(".")
def is_valid_email(value: str) -> bool:
email = normalize_email(value)
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
def _split_disposable_domain_values(value: str) -> list[str]:
return [item.strip() for item in re.split(r"[,;\s]+", value or "") if item.strip()]
def is_disposable_email(value: Optional[str], settings: Settings) -> bool:
domain = email_domain(value)
if not domain:
return False
blocked_domains = getattr(settings, "disposable_email_domains", None)
if blocked_domains is None:
blocked_domains = _split_disposable_domain_values(
str(getattr(settings, "DISPOSABLE_EMAIL_DOMAINS", "") or "")
)
blocked_domains = blocked_domains or []
for blocked in blocked_domains:
normalized = str(blocked or "").strip().lower().lstrip("@.")
if normalized and (domain == normalized or domain.endswith(f".{normalized}")):
return True
return False
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
target_part = "none" if target_user_id is None else str(target_user_id)
return f"{purpose}:{target_part}:{email}"
+143 -1
View File
@@ -1,5 +1,6 @@
import logging
import os
import re
import secrets
from typing import Any, Dict, List, Optional
@@ -25,7 +26,117 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = (
def _split_csv(value: Optional[str]) -> List[str]:
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
return [item.strip() for item in re.split(r"[,;\r\n]+", value) if item.strip()]
DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join(
[
"10minutemail.com",
"10minutemail.net",
"10minutemail.org",
"20minutemail.com",
"33mail.com",
"anonbox.net",
"anonymbox.com",
"armyspy.com",
"byom.de",
"crazymailing.com",
"cuvox.de",
"dayrep.com",
"deadaddress.com",
"dispostable.com",
"dodgeit.com",
"dodgit.com",
"dropmail.me",
"easytrashmail.com",
"emailfake.com",
"emailondeck.com",
"emailtemporanea.com",
"emailtemporanea.net",
"einrot.com",
"fakeinbox.com",
"filzmail.com",
"fleckens.hu",
"generator.email",
"getairmail.com",
"getnada.com",
"grr.la",
"guerrillamail.biz",
"guerrillamail.com",
"guerrillamail.de",
"guerrillamail.info",
"guerrillamail.net",
"guerrillamail.org",
"guerrillamailblock.com",
"gustr.com",
"hmamail.com",
"incognitomail.org",
"inboxbear.com",
"jetable.org",
"jourrapide.com",
"kasmail.com",
"mail-temp.com",
"mailcatch.com",
"maildrop.cc",
"mailexpire.com",
"mailinator.com",
"mailinator.net",
"mailinator.org",
"mailmetrash.com",
"mailnesia.com",
"mailnull.com",
"mailpoof.com",
"mailtothis.com",
"mail.tm",
"mintemail.com",
"mohmal.com",
"moakt.com",
"mytemp.email",
"mytrashmail.com",
"nada.email",
"no-spam.ws",
"pookmail.com",
"rhyta.com",
"sharklasers.com",
"sofort-mail.de",
"spam4.me",
"spambog.com",
"spamdecoy.net",
"spamfree24.org",
"spamgourmet.com",
"spamhole.com",
"spam.la",
"spammotel.com",
"superrito.com",
"teleworm.us",
"tempail.com",
"temp-mail.io",
"temp-mail.org",
"tempmail.com",
"tempmail.dev",
"tempmail.net",
"tempmailo.com",
"temporaryemail.net",
"temporary-mail.net",
"tempr.email",
"throwawaymail.com",
"trash-mail.com",
"trash-mail.de",
"trashmail.com",
"trashmail.me",
"trashmail.net",
"trashmailer.com",
"trashymail.com",
"weg-werf-email.de",
"wegwerfmail.de",
"wegwerfmail.net",
"wegwerfmail.org",
"yomail.info",
"yopmail.com",
"yopmail.fr",
"yopmail.net",
]
)
class DBSettings(BaseModel):
@@ -282,6 +393,13 @@ class Settings(BaseSettings):
default=3,
description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501
)
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED: bool = Field(
default=True,
description=(
"Allow referral welcome bonus grants for users who have not linked Telegram. "
"Disposable email domains are still blocked until Telegram is linked."
),
)
LEGACY_REFS: bool = Field(
default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
@@ -346,6 +464,13 @@ class Settings(BaseSettings):
TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
TRIAL_WITHOUT_TELEGRAM_ENABLED: bool = Field(
default=True,
description=(
"Allow trial activation for users who have not linked Telegram. "
"Disposable email domains are still blocked until Telegram is linked."
),
)
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
default=None,
description=(
@@ -449,6 +574,13 @@ class Settings(BaseSettings):
SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
SMTP_FROM_NAME: Optional[str] = Field(default=None)
DISPOSABLE_EMAIL_DOMAINS: str = Field(
default=DEFAULT_DISPOSABLE_EMAIL_DOMAINS,
description=(
"Disposable email domains treated as requiring Telegram for trial and "
"referral welcome bonus abuse protection. Accepts commas or one domain per line."
),
)
SMTP_STARTTLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False)
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
@@ -633,6 +765,16 @@ class Settings(BaseSettings):
return trial_squads
return self.parsed_user_squad_uuids
@computed_field
@property
def disposable_email_domains(self) -> List[str]:
domains: List[str] = []
for domain in _split_csv(self.DISPOSABLE_EMAIL_DOMAINS):
normalized = domain.strip().lower().lstrip("@.")
if normalized and normalized not in domains:
domains.append(normalized)
return domains
@computed_field
@property
def parsed_user_external_squad_uuid(self) -> Optional[str]:
+3
View File
@@ -386,10 +386,13 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `TRIAL_DURATION_DAYS` | Длительность пробного периода. |
| `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. |
| `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. |
| `TRIAL_WITHOUT_TELEGRAM_ENABLED` | Разрешает активацию trial пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. |
| `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. |
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
| `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` | Разрешает начислять реферальный приветственный бонус пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. |
| `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. |
| `DISPOSABLE_EMAIL_DOMAINS` | Домены одноразовой почты через запятую. Для таких email trial и реферальный welcome bonus доступны только после привязки Telegram. |
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_inviter`. |
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_referee`. |
| `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
+3 -1
View File
@@ -350,12 +350,14 @@ Remnawave ограничивает доступ при достижении `tra
Автопродление через YooKassa применяется к подпискам на срок. Для режима продажи трафика без JSON-каталога автопродление пропускается. Для traffic-тарифов JSON-каталога покупка является пакетом трафика, а не периодической подпиской.
Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`.
Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`. Переключатель `TRIAL_WITHOUT_TELEGRAM_ENABLED` управляет активацией trial для аккаунтов без Telegram, а домены из `DISPOSABLE_EMAIL_DOMAINS` требуют привязки Telegram независимо от этого переключателя.
Промокоды с бонусными днями применяются к покупке period-подписки.
Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются.
Приветственный бонус приглашённому (`REFERRAL_WELCOME_BONUS_DAYS`) настраивается в отдельном блоке **Реферальная программа** на странице тарифов. `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` разрешает или запрещает выдачу этого бонуса аккаунтам без Telegram; disposable email домены из `DISPOSABLE_EMAIL_DOMAINS` всегда требуют Telegram перед начислением.
Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного.
В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу.
+222 -4
View File
@@ -74,6 +74,10 @@
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500;
const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500;
const TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY = "rw_webapp_telegram_link_pending_action_v1";
const TELEGRAM_LINK_PENDING_TTL_MS = 10 * 60 * 1000;
const TELEGRAM_LINK_ACTION_TRIAL = "trial";
const TELEGRAM_LINK_ACTION_REFERRAL_WELCOME = "referral_welcome";
import {
activationPaymentFailed,
createActivationHandoff,
@@ -171,6 +175,7 @@
let telegramNotificationsBotOpenedAt = 0;
let telegramNotificationsResumeRefreshBusy = false;
let telegramNotificationsResumeLastCheckAt = 0;
let telegramLinkPendingActionBusy = false;
let promoCode = "";
let promoBusy = false;
let promoStatus = "";
@@ -1183,6 +1188,161 @@
}
}
function currentTelegramLinkPendingUserId() {
const currentUser = data?.user || user || {};
const id = currentUser.user_id ?? currentUser.id;
return id == null ? "" : String(id);
}
function isTelegramLinkPendingAction(action) {
return [TELEGRAM_LINK_ACTION_TRIAL, TELEGRAM_LINK_ACTION_REFERRAL_WELCOME].includes(action);
}
function rememberTelegramLinkPendingAction(action) {
if (typeof window === "undefined" || !isTelegramLinkPendingAction(action)) return;
try {
window.sessionStorage.setItem(
TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY,
JSON.stringify({
action,
userId: currentTelegramLinkPendingUserId(),
createdAt: Date.now(),
})
);
} catch (_error) {
void _error;
}
}
function clearTelegramLinkPendingAction() {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY);
} catch (_error) {
void _error;
}
}
function readTelegramLinkPendingAction() {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
const action = String(payload?.action || "");
const createdAt = Number(payload?.createdAt || 0);
const pendingUserId = String(payload?.userId || "");
const currentUserId = currentTelegramLinkPendingUserId();
if (
!isTelegramLinkPendingAction(action) ||
!createdAt ||
Date.now() - createdAt > TELEGRAM_LINK_PENDING_TTL_MS ||
(pendingUserId && currentUserId && pendingUserId !== currentUserId)
) {
clearTelegramLinkPendingAction();
return null;
}
return action;
} catch (_error) {
clearTelegramLinkPendingAction();
return null;
}
}
async function runTelegramLinkedAction(action) {
if (action === TELEGRAM_LINK_ACTION_TRIAL) {
await activateTrial();
return true;
}
if (action === TELEGRAM_LINK_ACTION_REFERRAL_WELCOME) {
await claimReferralWelcomeBonus();
return true;
}
return false;
}
async function continueTelegramLinkPendingAction() {
if (telegramLinkPendingActionBusy) return false;
const currentUser = data?.user || user || {};
if (!currentUser?.telegram_linked) return false;
const action = readTelegramLinkPendingAction();
if (!action) return false;
telegramLinkPendingActionBusy = true;
clearTelegramLinkPendingAction();
try {
return await runTelegramLinkedAction(action);
} finally {
telegramLinkPendingActionBusy = false;
}
}
async function linkTelegramWithPayloadForPendingAction(payload) {
accountStore.update((s) => ({ ...s, linkTelegramBusy: true }));
try {
const response = await api("/account/telegram/link", {
method: "POST",
body: JSON.stringify(payload),
});
if (!response?.ok) throw response;
if (response?.csrf_token) setToken("", response.csrf_token);
await loadData({ fresh: true, preserveView: true });
const handled = await continueTelegramLinkPendingAction();
if (!handled) {
clearTelegramLinkPendingAction();
showToast(t("wa_settings_linked"));
}
} catch (error) {
clearTelegramLinkPendingAction();
showToast(error?.message || t("wa_auth_telegram_not_confirmed"));
} finally {
accountStore.update((s) => ({ ...s, linkTelegramBusy: false }));
}
}
async function linkTelegramForPendingAction(action) {
if (!isTelegramLinkPendingAction(action) || linkTelegramBusy || telegramLinkPendingActionBusy) {
return;
}
const currentUser = data?.user || user || {};
if (currentUser?.telegram_linked) {
await runTelegramLinkedAction(action);
return;
}
rememberTelegramLinkPendingAction(action);
if (demoAuthLogin) {
await linkTelegramWithPayloadForPendingAction({ auth_data: demoTelegramAuthPayload() });
return;
}
const isTelegramMiniAppAttempt = hasTelegramLaunchParams();
if (isTelegramMiniAppAttempt) {
await telegramSdk.ensureForAction();
}
const initData =
telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation();
if (initData) {
await linkTelegramWithPayloadForPendingAction({ init_data: initData });
return;
}
if (!telegramOAuthClientId) {
clearTelegramLinkPendingAction();
showToast(t("wa_auth_telegram_not_configured"));
return;
}
await accountStore.linkTelegramAccount(
() => telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation()
);
}
function linkTelegramAndActivateTrial() {
return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_TRIAL);
}
function linkTelegramAndClaimReferralWelcome() {
return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_REFERRAL_WELCOME);
}
function openTelegramNotificationsBot() {
const link = telegramNotificationsStartLink;
telegramNotificationsBotOpenedAt = Date.now();
@@ -1449,9 +1609,12 @@
getCsrfToken: () => csrfToken,
});
if (mode === "app" && screen !== "admin") {
if (hasPendingActivationHandoff()) await loadData({ fresh: true });
const shown = await maybeShowActivationSuccessDialog({ source: "boot" });
if (!shown) startPendingActivationWatch();
const telegramActionHandled = await continueTelegramLinkPendingAction();
if (!telegramActionHandled) {
if (hasPendingActivationHandoff()) await loadData({ fresh: true });
const shown = await maybeShowActivationSuccessDialog({ source: "boot" });
if (!shown) startPendingActivationWatch();
}
}
}
@@ -1901,6 +2064,55 @@
}
}
function trialActivationFailureMessage(error) {
if (
error?.error === "trial_telegram_required" ||
error?.message === "telegram_required" ||
error?.message === "disposable_email"
) {
return t(
"wa_trial_telegram_required_error",
{},
"Для активации пробного периода привяжите Telegram."
);
}
return error?.message || t("wa_trial_activation_failed");
}
function referralWelcomeFailureMessage(error) {
if (
error?.error === "referral_welcome_telegram_required" ||
error?.message === "telegram_required" ||
error?.message === "disposable_email"
) {
return t(
"wa_referral_welcome_telegram_required_error",
{},
"Для получения реферального бонуса привяжите Telegram."
);
}
return error?.message || t("wa_referral_welcome_claim_failed");
}
async function claimReferralWelcomeBonus() {
try {
const response = await api("/referral/welcome-bonus/claim", {
method: "POST",
body: JSON.stringify({}),
});
if (!response.ok) throw response;
showToast(
response.end_date_text
? t("wa_referral_welcome_claimed_until", { date: response.end_date_text })
: t("wa_referral_welcome_claimed")
);
await loadData({ fresh: true });
await maybeShowActivationSuccessDialog({ source: "referral_welcome", force: true });
} catch (error) {
showToast(referralWelcomeFailureMessage(error));
}
}
async function activateTrial() {
if (trialBusy) return;
trialBusy = true;
@@ -1917,7 +2129,7 @@
await loadData({ fresh: true });
await maybeShowActivationSuccessDialog({ source: "trial", force: true });
} catch (error) {
const message = error?.message || t("wa_trial_activation_failed");
const message = trialActivationFailureMessage(error);
trialActivationError = message;
showToast(message);
} finally {
@@ -2280,7 +2492,9 @@
{premiumTrafficTopupUnlocked}
{regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked}
{referral}
{subscription}
{linkTelegramBusy}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
@@ -2288,6 +2502,8 @@
{trafficMode}
{trialBusy}
{activateTrial}
{linkTelegramAndActivateTrial}
{linkTelegramAndClaimReferralWelcome}
{openTelegramNotificationsBot}
openConnectLink={openInstallOrConnect}
{openPaymentModal}
@@ -2317,9 +2533,11 @@
{brandTitle}
{subscription}
{trialBusy}
{linkTelegramBusy}
trialResult={trialActivationResult}
trialError={trialActivationError}
{activateTrial}
{linkTelegramAndActivateTrial}
openInstallOrConnect={openTrialInstallOrConnect}
{goHome}
{t}
@@ -1,5 +1,5 @@
<script>
import { Input } from "$components/ui/index.js";
import { Input, Textarea } from "$components/ui/index.js";
import {
ChevronRight,
RefreshCw,
@@ -31,12 +31,30 @@
"TRIAL_DURATION_DAYS",
"TRIAL_TRAFFIC_LIMIT_GB",
"TRIAL_TRAFFIC_STRATEGY",
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
"TRIAL_SQUAD_UUIDS",
];
const TRIAL_SWITCH_KEYS = ["TRIAL_ENABLED"];
const TRIAL_SWITCH_KEYS = ["TRIAL_ENABLED", "TRIAL_WITHOUT_TELEGRAM_ENABLED"];
const TRIAL_GENERAL_KEYS = ["TRIAL_DURATION_DAYS", "TRIAL_TRAFFIC_LIMIT_GB"];
const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
const TRIAL_SQUAD_KEYS = ["TRIAL_SQUAD_UUIDS"];
const REFERRAL_SETTING_KEYS = [
"REFERRAL_WELCOME_BONUS_DAYS",
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
"REFERRAL_ONE_BONUS_PER_REFEREE",
"LEGACY_REFS",
"DISPOSABLE_EMAIL_DOMAINS",
];
const REFERRAL_WELCOME_KEYS = [
"REFERRAL_WELCOME_BONUS_DAYS",
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
];
const REFERRAL_RULE_KEYS = [
"REFERRAL_ONE_BONUS_PER_REFEREE",
"LEGACY_REFS",
"DISPOSABLE_EMAIL_DOMAINS",
];
const DISPOSABLE_EMAIL_DOMAINS_PLACEHOLDER = "mailinator.com\ntemp-mail.org\nyopmail.com";
const LEGACY_PERIODS = [
[
"1",
@@ -102,6 +120,7 @@
.map((field) => [field.key, field])
);
$: trialDirtyCount = TRIAL_SETTING_KEYS.filter((key) => Boolean(settingsDirty[key])).length;
$: referralDirtyCount = REFERRAL_SETTING_KEYS.filter((key) => Boolean(settingsDirty[key])).length;
$: legacyDirtyCount = LEGACY_TARIFF_SETTING_KEYS.filter((key) =>
Boolean(settingsDirty[key])
).length;
@@ -410,6 +429,53 @@
{/if}
</div>
</div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("TRIAL_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_trial_without_telegram_enabled", {}, "Триал без Telegram")}
{#if isSettingDirty("TRIAL_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>TRIAL_WITHOUT_TELEGRAM_ENABLED</code>
</div>
<div class="admin-setting-control">
<div class="admin-setting-switch">
<Switch.Root
checked={boolValue(
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
settingsDirty,
settingsFieldMap
)}
onCheckedChange={(checked) =>
setSetting("TRIAL_WITHOUT_TELEGRAM_ENABLED", checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{boolValue("TRIAL_WITHOUT_TELEGRAM_ENABLED", settingsDirty, settingsFieldMap)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("TRIAL_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("TRIAL_WITHOUT_TELEGRAM_ENABLED")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</div>
</section>
@@ -677,6 +743,352 @@
</div>
</article>
<article class="admin-card admin-tariff-settings-card">
<header class="admin-card-head">
<div>
<h3>{at("tariffs_referral_title", {}, "Реферальная программа")}</h3>
<small>
{at(
"tariffs_referral_subtitle",
{},
"Настройки приветственного бонуса, правил начисления и защиты от одноразовых email."
)}
</small>
</div>
<div class="admin-editor-section-actions">
<AdminBadge
variant={Number(
valueForKey("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty, settingsFieldMap) || 0
) > 0
? "success"
: "muted"}
>
{Number(
valueForKey("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty, settingsFieldMap) || 0
) > 0
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}
</AdminBadge>
{#if referralDirtyCount}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: referralDirtyCount },
`Изменений: ${referralDirtyCount}`
)}
</AdminBadge>
<AdminButton
size="sm"
variant="primary"
onclick={saveTariffSettings}
disabled={settingsSaving}
>
<Save size={13} />
{settingsSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
</header>
<div class="admin-card-body admin-trial-settings-body">
<div class="admin-settings-field-groups admin-trial-settings-groups">
<section
class="admin-settings-field-group"
class:is-dirty={dirtyCount(REFERRAL_WELCOME_KEYS, settingsDirty)}
>
<header class="admin-settings-field-group-head">
<div class="admin-settings-field-group-head-copy">
<strong>{at("tariffs_referral_group_welcome", {}, "Приветственный бонус")}</strong>
<small>
{at(
"tariffs_referral_group_welcome_hint",
{},
"Дни, которые получает приглашённый пользователь после регистрации по ссылке."
)}
</small>
</div>
{#if dirtyCount(REFERRAL_WELCOME_KEYS, settingsDirty)}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: dirtyCount(REFERRAL_WELCOME_KEYS, settingsDirty) },
`Изменений: ${dirtyCount(REFERRAL_WELCOME_KEYS, settingsDirty)}`
)}
</AdminBadge>
{/if}
</header>
<div class="admin-settings-field-group-body">
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_referral_welcome_bonus_days", {}, "Приветственный бонус, дней")}
{#if isSettingDirty("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>REFERRAL_WELCOME_BONUS_DAYS</code>
</div>
<div class="admin-setting-control">
<Input
class="input"
type="number"
min="0"
step="1"
value={valueForKey(
"REFERRAL_WELCOME_BONUS_DAYS",
settingsDirty,
settingsFieldMap
)}
oninput={(event) =>
setSetting("REFERRAL_WELCOME_BONUS_DAYS", event.currentTarget.value)}
/>
{#if isSettingDirty("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("REFERRAL_WELCOME_BONUS_DAYS")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
settingsDirty
)}
>
<div class="admin-setting-meta">
<strong>
{at(
"tariffs_referral_without_telegram",
{},
"Начислять welcome bonus без Telegram"
)}
{#if isSettingDirty("REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED</code>
</div>
<div class="admin-setting-control">
<div class="admin-setting-switch">
<Switch.Root
checked={boolValue(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
settingsDirty,
settingsFieldMap
)}
onCheckedChange={(checked) =>
setSetting("REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{boolValue(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
settingsDirty,
settingsFieldMap
)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</div>
</section>
<section
class="admin-settings-field-group"
class:is-dirty={dirtyCount(REFERRAL_RULE_KEYS, settingsDirty)}
>
<header class="admin-settings-field-group-head">
<div class="admin-settings-field-group-head-copy">
<strong>{at("tariffs_referral_group_rules", {}, "Правила и антиабьюз")}</strong>
<small>
{at(
"tariffs_referral_group_rules_hint",
{},
"Ограничения повторных бонусов и домены одноразовой почты для no-Telegram аккаунтов."
)}
</small>
</div>
{#if dirtyCount(REFERRAL_RULE_KEYS, settingsDirty)}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: dirtyCount(REFERRAL_RULE_KEYS, settingsDirty) },
`Изменений: ${dirtyCount(REFERRAL_RULE_KEYS, settingsDirty)}`
)}
</AdminBadge>
{/if}
</header>
<div class="admin-settings-field-group-body">
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("REFERRAL_ONE_BONUS_PER_REFEREE", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_referral_one_bonus_per_referee", {}, "Один бонус на приглашённого")}
{#if isSettingDirty("REFERRAL_ONE_BONUS_PER_REFEREE", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>REFERRAL_ONE_BONUS_PER_REFEREE</code>
</div>
<div class="admin-setting-control">
<div class="admin-setting-switch">
<Switch.Root
checked={boolValue(
"REFERRAL_ONE_BONUS_PER_REFEREE",
settingsDirty,
settingsFieldMap
)}
onCheckedChange={(checked) =>
setSetting("REFERRAL_ONE_BONUS_PER_REFEREE", checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{boolValue("REFERRAL_ONE_BONUS_PER_REFEREE", settingsDirty, settingsFieldMap)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("REFERRAL_ONE_BONUS_PER_REFEREE", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("REFERRAL_ONE_BONUS_PER_REFEREE")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("LEGACY_REFS", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_referral_legacy_refs", {}, "Старые ref-ссылки")}
{#if isSettingDirty("LEGACY_REFS", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>LEGACY_REFS</code>
</div>
<div class="admin-setting-control">
<div class="admin-setting-switch">
<Switch.Root
checked={boolValue("LEGACY_REFS", settingsDirty, settingsFieldMap)}
onCheckedChange={(checked) => setSetting("LEGACY_REFS", checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{boolValue("LEGACY_REFS", settingsDirty, settingsFieldMap)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("LEGACY_REFS", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("LEGACY_REFS")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("DISPOSABLE_EMAIL_DOMAINS", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_referral_disposable_domains", {}, "Disposable email домены")}
{#if isSettingDirty("DISPOSABLE_EMAIL_DOMAINS", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>DISPOSABLE_EMAIL_DOMAINS</code>
<small>
{at(
"tariffs_referral_disposable_domains_hint",
{},
"По одному домену на строку или через запятую. Поддомены тоже считаются совпадением."
)}
</small>
</div>
<div class="admin-setting-control">
<Textarea
class="admin-setting-textarea"
rows="8"
placeholder={DISPOSABLE_EMAIL_DOMAINS_PLACEHOLDER}
value={valueForKey("DISPOSABLE_EMAIL_DOMAINS", settingsDirty, settingsFieldMap)}
oninput={(event) =>
setSetting("DISPOSABLE_EMAIL_DOMAINS", event.currentTarget.value)}
/>
{#if isSettingDirty("DISPOSABLE_EMAIL_DOMAINS", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("DISPOSABLE_EMAIL_DOMAINS")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</div>
</section>
</div>
</div>
</article>
<div class="admin-tariff-management">
<div class="admin-tariff-overview-grid">
<article class="admin-card admin-tariff-currency-card">
+8 -2
View File
@@ -14,8 +14,8 @@ export const DEMO_DATASET = {
payments: 482,
logs: 1600,
supportTickets: 3,
translationKeys: 1994,
settingsFields: 222,
translationKeys: 2200,
settingsFields: 236,
},
},
config: {
@@ -98,6 +98,9 @@ export const DEMO_DATASET = {
user_hwid_device_limit: 5,
trial_enabled: true,
trial_available: false,
trial_without_telegram_enabled: true,
trial_requires_telegram: false,
trial_block_reason: "",
trial_duration_days: 5,
trial_traffic_limit_gb: 10,
trial_traffic_strategy: "NO_RESET",
@@ -484,6 +487,9 @@ export const DEMO_DATASET = {
invited_count: 106,
purchased_count: 106,
welcome_bonus_days: 3,
welcome_bonus_without_telegram_enabled: true,
welcome_bonus_requires_telegram: false,
welcome_bonus_block_reason: "",
one_bonus_per_referee: false,
bonus_details: [
{
+202
View File
@@ -156,6 +156,9 @@ function applyDemoEmailAuthUser() {
...(DEV_MOCK.data.settings || {}),
trial_enabled: true,
trial_available: true,
trial_without_telegram_enabled: true,
trial_requires_telegram: false,
trial_block_reason: "",
};
}
@@ -236,6 +239,9 @@ function applyDemoTelegramAuthUser(authData = {}) {
...(DEV_MOCK.data.settings || {}),
trial_enabled: true,
trial_available: true,
trial_without_telegram_enabled: true,
trial_requires_telegram: false,
trial_block_reason: "",
};
}
@@ -254,6 +260,16 @@ function applyDemoEmailLink(email) {
},
160
);
DEV_MOCK.data.settings = {
...(DEV_MOCK.data.settings || {}),
trial_requires_telegram: false,
trial_block_reason: "",
};
DEV_MOCK.data.referral = {
...(DEV_MOCK.data.referral || {}),
welcome_bonus_requires_telegram: false,
welcome_bonus_block_reason: "",
};
}
function applyDemoTelegramLink(authData = {}) {
@@ -616,6 +632,23 @@ function demoSettingsValuesByKey() {
return map;
}
function demoRuntimeSettingValue(key) {
const values = {
TRIAL_WITHOUT_TELEGRAM_ENABLED: DEV_MOCK.config.trialWithoutTelegramEnabled ?? true,
REFERRAL_WELCOME_BONUS_DAYS:
DEV_MOCK.config.referralWelcomeBonusDays ?? DEV_MOCK.data.referral?.welcome_bonus_days ?? 3,
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED:
DEV_MOCK.config.referralWelcomeWithoutTelegramEnabled ?? true,
REFERRAL_ONE_BONUS_PER_REFEREE:
DEV_MOCK.config.referralOneBonusPerReferee ??
DEV_MOCK.data.referral?.one_bonus_per_referee ??
false,
LEGACY_REFS: DEV_MOCK.config.legacyRefs ?? true,
DISPOSABLE_EMAIL_DOMAINS: DEV_MOCK.config.disposableEmailDomains || "",
};
return Object.prototype.hasOwnProperty.call(values, key) ? values[key] : undefined;
}
function demoSettingsSections(clone) {
// Section/field structure comes from the manifest snapshot generated off the
// Python source of truth (scripts/export_settings_manifest.py), so the demo
@@ -633,6 +666,9 @@ function demoSettingsSections(clone) {
if ("updated_at" in demoField) field.updated_at = demoField.updated_at;
if ("source" in demoField) field.source = demoField.source;
if (field.secret && "has_value" in demoField) field.has_value = demoField.has_value;
} else {
const runtimeValue = demoRuntimeSettingValue(field.key);
if (typeof runtimeValue !== "undefined") field.value = runtimeValue;
}
if (demoSettingsChanges.has(field.key)) {
const change = demoSettingsChanges.get(field.key);
@@ -656,6 +692,43 @@ function applyDemoSettingToMock(key, value) {
DEV_MOCK.config.faviconUrl = value || DEV_MOCK.config.faviconUrl || "";
}
if (key === "WEBAPP_FAVICON_USE_CUSTOM") DEV_MOCK.config.faviconUseCustom = Boolean(value);
if (key === "TRIAL_ENABLED") {
DEV_MOCK.config.trialEnabled = Boolean(value);
DEV_MOCK.data.settings.trial_enabled = Boolean(value);
}
if (key === "TRIAL_DURATION_DAYS") {
DEV_MOCK.config.trialDurationDays = value;
DEV_MOCK.data.settings.trial_duration_days = Number(value || 0);
}
if (key === "TRIAL_TRAFFIC_LIMIT_GB") {
DEV_MOCK.config.trialTrafficLimitGb = value;
DEV_MOCK.data.settings.trial_traffic_limit_gb = Number(value || 0);
}
if (key === "TRIAL_TRAFFIC_STRATEGY") {
DEV_MOCK.config.trialTrafficStrategy = value || "NO_RESET";
DEV_MOCK.data.settings.trial_traffic_strategy = value || "NO_RESET";
}
if (key === "TRIAL_WITHOUT_TELEGRAM_ENABLED") {
DEV_MOCK.config.trialWithoutTelegramEnabled = Boolean(value);
DEV_MOCK.data.settings.trial_without_telegram_enabled = Boolean(value);
}
if (key === "TRIAL_SQUAD_UUIDS") DEV_MOCK.config.trialSquadUuids = value || "";
if (key === "REFERRAL_WELCOME_BONUS_DAYS") {
DEV_MOCK.config.referralWelcomeBonusDays = Number(value || 0);
DEV_MOCK.data.referral.welcome_bonus_days = Number(value || 0);
}
if (key === "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED") {
DEV_MOCK.config.referralWelcomeWithoutTelegramEnabled = Boolean(value);
DEV_MOCK.data.referral.welcome_bonus_without_telegram_enabled = Boolean(value);
}
if (key === "REFERRAL_ONE_BONUS_PER_REFEREE") {
DEV_MOCK.config.referralOneBonusPerReferee = Boolean(value);
DEV_MOCK.data.referral.one_bonus_per_referee = Boolean(value);
}
if (key === "LEGACY_REFS") DEV_MOCK.config.legacyRefs = Boolean(value);
if (key === "DISPOSABLE_EMAIL_DOMAINS") {
DEV_MOCK.config.disposableEmailDomains = value || "";
}
}
function userSnapshotForTicket(ticket) {
@@ -1628,9 +1701,50 @@ export async function mockApi(path, options = {}, context = {}) {
if (Object.prototype.hasOwnProperty.call(updates, "TRIAL_TRAFFIC_STRATEGY")) {
DEV_MOCK.config.trialTrafficStrategy = updates.TRIAL_TRAFFIC_STRATEGY || "NO_RESET";
}
if (Object.prototype.hasOwnProperty.call(updates, "TRIAL_WITHOUT_TELEGRAM_ENABLED")) {
DEV_MOCK.config.trialWithoutTelegramEnabled = Boolean(
updates.TRIAL_WITHOUT_TELEGRAM_ENABLED
);
DEV_MOCK.data.settings.trial_without_telegram_enabled = Boolean(
updates.TRIAL_WITHOUT_TELEGRAM_ENABLED
);
}
if (Object.prototype.hasOwnProperty.call(updates, "TRIAL_SQUAD_UUIDS")) {
DEV_MOCK.config.trialSquadUuids = updates.TRIAL_SQUAD_UUIDS || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "REFERRAL_WELCOME_BONUS_DAYS")) {
DEV_MOCK.config.referralWelcomeBonusDays = Number(updates.REFERRAL_WELCOME_BONUS_DAYS || 0);
DEV_MOCK.data.referral.welcome_bonus_days = Number(
updates.REFERRAL_WELCOME_BONUS_DAYS || 0
);
}
if (
Object.prototype.hasOwnProperty.call(
updates,
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED"
)
) {
DEV_MOCK.config.referralWelcomeWithoutTelegramEnabled = Boolean(
updates.REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED
);
DEV_MOCK.data.referral.welcome_bonus_without_telegram_enabled = Boolean(
updates.REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED
);
}
if (Object.prototype.hasOwnProperty.call(updates, "REFERRAL_ONE_BONUS_PER_REFEREE")) {
DEV_MOCK.config.referralOneBonusPerReferee = Boolean(
updates.REFERRAL_ONE_BONUS_PER_REFEREE
);
DEV_MOCK.data.referral.one_bonus_per_referee = Boolean(
updates.REFERRAL_ONE_BONUS_PER_REFEREE
);
}
if (Object.prototype.hasOwnProperty.call(updates, "LEGACY_REFS")) {
DEV_MOCK.config.legacyRefs = Boolean(updates.LEGACY_REFS);
}
if (Object.prototype.hasOwnProperty.call(updates, "DISPOSABLE_EMAIL_DOMAINS")) {
DEV_MOCK.config.disposableEmailDomains = updates.DISPOSABLE_EMAIL_DOMAINS || "";
}
} catch (_e) {
void _e;
}
@@ -1798,6 +1912,14 @@ export async function mockApi(path, options = {}, context = {}) {
label: "Стратегия сброса трафика триала",
value: DEV_MOCK.config.trialTrafficStrategy || "NO_RESET",
},
{
key: "TRIAL_WITHOUT_TELEGRAM_ENABLED",
type: "bool",
section: "pricing",
subsection: "trial",
label: "Триал без Telegram",
value: DEV_MOCK.config.trialWithoutTelegramEnabled ?? true,
},
{
key: "TRIAL_SQUAD_UUIDS",
type: "string",
@@ -1806,6 +1928,46 @@ export async function mockApi(path, options = {}, context = {}) {
label: "Internal Squads для триала",
value: DEV_MOCK.config.trialSquadUuids || "",
},
{
key: "REFERRAL_WELCOME_BONUS_DAYS",
type: "int",
section: "pricing",
subsection: "referral",
label: "Приветственный бонус (дней)",
value: DEV_MOCK.config.referralWelcomeBonusDays ?? 3,
},
{
key: "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
type: "bool",
section: "pricing",
subsection: "referral",
label: "Приветственный бонус без Telegram",
value: DEV_MOCK.config.referralWelcomeWithoutTelegramEnabled ?? true,
},
{
key: "REFERRAL_ONE_BONUS_PER_REFEREE",
type: "bool",
section: "pricing",
subsection: "referral",
label: "Один бонус на приглашённого",
value: Boolean(DEV_MOCK.config.referralOneBonusPerReferee),
},
{
key: "LEGACY_REFS",
type: "bool",
section: "pricing",
subsection: "referral",
label: "Поддержка старых ref-ссылок",
value: DEV_MOCK.config.legacyRefs ?? true,
},
{
key: "DISPOSABLE_EMAIL_DOMAINS",
type: "text",
section: "pricing",
subsection: "referral",
label: "Disposable email домены",
value: DEV_MOCK.config.disposableEmailDomains || "",
},
...[
["MONTH_1_ENABLED", "bool", true],
["RUB_PRICE_1_MONTH", "float", 200],
@@ -2011,6 +2173,39 @@ export async function mockApi(path, options = {}, context = {}) {
return { ok: true, csrf_token: "local-preview-csrf" };
}
if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" };
if (
path === "/referral/welcome-bonus/claim" &&
String(options.method || "").toUpperCase() === "POST"
) {
const days = Math.max(1, Number(DEV_MOCK.data.referral?.welcome_bonus_days || 3));
DEV_MOCK.data.subscription = {
...DEV_MOCK.data.subscription,
active: true,
status: "ACTIVE",
remaining_text: `${days} д.`,
end_date_text: "05.05.2026 12:00",
days_left: days,
config_link: "https://sub.example.com/sub/referral-preview-token",
connect_url: "https://sub.example.com/connect/referral-preview-token",
panel_short_uuid: "referral-preview-token",
install_share_token: "referral-preview-share",
install_share_url: "https://app.example.com/s/referral-preview-share",
traffic_limit: "10 GB",
traffic_limit_bytes: 10737418240,
traffic_used: "0 B",
traffic_used_bytes: 0,
};
DEV_MOCK.data.referral = {
...(DEV_MOCK.data.referral || {}),
welcome_bonus_requires_telegram: false,
welcome_bonus_block_reason: "",
};
return {
ok: true,
claimed: true,
end_date_text: "05.05.2026 12:00",
};
}
if (path === "/devices") return clone(DEV_MOCK.data.devices);
if (path === "/devices/topup-options")
return clone(DEV_MOCK.data.device_topup_options || { ok: true, plans: [] });
@@ -2040,6 +2235,13 @@ export async function mockApi(path, options = {}, context = {}) {
return { ok: true };
}
if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") {
if (DEV_MOCK.data.settings?.trial_requires_telegram && !DEV_MOCK.data.user?.telegram_linked) {
return {
ok: false,
error: "trial_telegram_required",
message: "telegram_required",
};
}
DEV_MOCK.data.subscription = {
...DEV_MOCK.data.subscription,
active: true,
+140
View File
@@ -2,6 +2,112 @@ import { DEMO_DATASET } from "./demoDataset.js";
import { withDemoAvatar } from "./demoAvatars.js";
const DEMO_LANGUAGE_STORAGE_KEY = "rw_minishop_demo_language";
const DEFAULT_DISPOSABLE_EMAIL_DOMAINS = [
"10minutemail.com",
"10minutemail.net",
"10minutemail.org",
"20minutemail.com",
"33mail.com",
"anonbox.net",
"anonymbox.com",
"armyspy.com",
"byom.de",
"crazymailing.com",
"cuvox.de",
"dayrep.com",
"deadaddress.com",
"dispostable.com",
"dodgeit.com",
"dodgit.com",
"dropmail.me",
"easytrashmail.com",
"emailfake.com",
"emailondeck.com",
"emailtemporanea.com",
"emailtemporanea.net",
"einrot.com",
"fakeinbox.com",
"filzmail.com",
"fleckens.hu",
"generator.email",
"getairmail.com",
"getnada.com",
"grr.la",
"guerrillamail.biz",
"guerrillamail.com",
"guerrillamail.de",
"guerrillamail.info",
"guerrillamail.net",
"guerrillamail.org",
"guerrillamailblock.com",
"gustr.com",
"hmamail.com",
"incognitomail.org",
"inboxbear.com",
"jetable.org",
"jourrapide.com",
"kasmail.com",
"mail-temp.com",
"mailcatch.com",
"maildrop.cc",
"mailexpire.com",
"mailinator.com",
"mailinator.net",
"mailinator.org",
"mailmetrash.com",
"mailnesia.com",
"mailnull.com",
"mailpoof.com",
"mailtothis.com",
"mail.tm",
"mintemail.com",
"mohmal.com",
"moakt.com",
"mytemp.email",
"mytrashmail.com",
"nada.email",
"no-spam.ws",
"pookmail.com",
"rhyta.com",
"sharklasers.com",
"sofort-mail.de",
"spam4.me",
"spambog.com",
"spamdecoy.net",
"spamfree24.org",
"spamgourmet.com",
"spamhole.com",
"spam.la",
"spammotel.com",
"superrito.com",
"teleworm.us",
"tempail.com",
"temp-mail.io",
"temp-mail.org",
"tempmail.com",
"tempmail.dev",
"tempmail.net",
"tempmailo.com",
"temporaryemail.net",
"temporary-mail.net",
"tempr.email",
"throwawaymail.com",
"trash-mail.com",
"trash-mail.de",
"trashmail.com",
"trashmail.me",
"trashmail.net",
"trashmailer.com",
"trashymail.com",
"weg-werf-email.de",
"wegwerfmail.de",
"wegwerfmail.net",
"wegwerfmail.org",
"yomail.info",
"yopmail.com",
"yopmail.fr",
"yopmail.net",
].join("\n");
function readStoredDemoLanguage() {
if (typeof window === "undefined") return "";
@@ -223,7 +329,13 @@ export const DEV_MOCK = {
trialDurationDays: 3,
trialTrafficLimitGb: 5,
trialTrafficStrategy: "NO_RESET",
trialWithoutTelegramEnabled: true,
trialSquadUuids: "2f2f6e0a-1f2d-4e80-a33b-0ebf3a409012",
referralWelcomeBonusDays: 3,
referralWelcomeWithoutTelegramEnabled: true,
referralOneBonusPerReferee: false,
legacyRefs: true,
disposableEmailDomains: DEFAULT_DISPOSABLE_EMAIL_DOMAINS,
apiBase: "/api",
adminJsAsset: "subscription_webapp_admin.js",
adminCssAsset: "subscription_webapp_admin.css",
@@ -396,6 +508,9 @@ export const DEV_MOCK = {
invited_count: 4,
purchased_count: 2,
welcome_bonus_days: 3,
welcome_bonus_without_telegram_enabled: true,
welcome_bonus_requires_telegram: false,
welcome_bonus_block_reason: "",
one_bonus_per_referee: false,
bonus_details: [
{ months: 1, title: "1 месяц", inviter_days: 14, friend_days: 7 },
@@ -440,6 +555,9 @@ export const DEV_MOCK = {
user_hwid_device_limit: 5,
trial_enabled: true,
trial_available: true,
trial_without_telegram_enabled: true,
trial_requires_telegram: false,
trial_block_reason: "",
trial_duration_days: 5,
trial_traffic_limit_gb: 10,
trial_traffic_strategy: "NO_RESET",
@@ -516,6 +634,8 @@ function applyInactiveSubscriptionScenario({ trialAvailable = false } = {}) {
DEV_MOCK.data.settings.traffic_mode = false;
DEV_MOCK.data.settings.trial_enabled = true;
DEV_MOCK.data.settings.trial_available = Boolean(trialAvailable);
DEV_MOCK.data.settings.trial_requires_telegram = false;
DEV_MOCK.data.settings.trial_block_reason = "";
DEV_MOCK.data.settings.trial_duration_days = 5;
DEV_MOCK.data.settings.trial_traffic_limit_gb = 10;
DEV_MOCK.data.subscription = {
@@ -598,6 +718,26 @@ export function applyPreviewMock(kind) {
return;
}
if (mode === "trial-telegram" || mode === "trial_requires_telegram") {
applyInactiveSubscriptionScenario();
DEV_MOCK.data.user = {
...(DEV_MOCK.data.user || {}),
telegram_id: null,
telegram_linked: false,
};
DEV_MOCK.data.settings.trial_enabled = true;
DEV_MOCK.data.settings.trial_available = false;
DEV_MOCK.data.settings.trial_requires_telegram = true;
DEV_MOCK.data.settings.trial_block_reason = "telegram_required";
DEV_MOCK.data.referral = {
...(DEV_MOCK.data.referral || {}),
welcome_bonus_days: 3,
welcome_bonus_requires_telegram: true,
welcome_bonus_block_reason: "telegram_required",
};
return;
}
if (mode === "notifications" || mode === "telegram-notifications" || mode === "needs-bot") {
DEV_MOCK.data.user = {
...(DEV_MOCK.data.user || {}),
@@ -3842,70 +3842,6 @@
}
]
},
{
"id": "referral",
"order": 6,
"fields": [
{
"key": "REFERRAL_ONE_BONUS_PER_REFEREE",
"type": "bool",
"section": "referral",
"section_order": 6,
"subsection": null,
"label": "Один бонус на приглашённого",
"description": "",
"i18n_label_key": "admin_settings_field_referral_one_bonus_per_referee_label",
"i18n_description_key": null,
"i18n_subsection_key": null,
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"value": "",
"overridden": false,
"updated_at": null
},
{
"key": "REFERRAL_WELCOME_BONUS_DAYS",
"type": "int",
"section": "referral",
"section_order": 6,
"subsection": null,
"label": "Приветственный бонус (дней)",
"description": "",
"i18n_label_key": "admin_settings_field_referral_welcome_bonus_days_label",
"i18n_description_key": null,
"i18n_subsection_key": null,
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"min": 0,
"value": "",
"overridden": false,
"updated_at": null
},
{
"key": "LEGACY_REFS",
"type": "bool",
"section": "referral",
"section_order": 6,
"subsection": null,
"label": "Поддержка старых ref-ссылок",
"description": "",
"i18n_label_key": "admin_settings_field_legacy_refs_label",
"i18n_description_key": null,
"i18n_subsection_key": null,
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"value": "",
"overridden": false,
"updated_at": null
}
]
},
{
"id": "notifications",
"order": 7,
@@ -5215,6 +5151,25 @@
"overridden": false,
"updated_at": null
},
{
"key": "TRIAL_WITHOUT_TELEGRAM_ENABLED",
"type": "bool",
"section": "pricing",
"section_order": 11,
"subsection": "trial",
"label": "Триал без Telegram",
"description": "Если выключено, email-only пользователю нужно привязать Telegram для активации триала. Disposable email домены всегда требуют Telegram.",
"i18n_label_key": "admin_settings_field_trial_without_telegram_enabled_label",
"i18n_description_key": "admin_settings_field_trial_without_telegram_enabled_description",
"i18n_subsection_key": "admin_settings_subsection_trial",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": false,
"secret": false,
"value": "",
"overridden": false,
"updated_at": null
},
{
"key": "TRIAL_SQUAD_UUIDS",
"type": "string",
@@ -5233,6 +5188,102 @@
"value": "",
"overridden": false,
"updated_at": null
},
{
"key": "REFERRAL_ONE_BONUS_PER_REFEREE",
"type": "bool",
"section": "pricing",
"section_order": 11,
"subsection": "referral",
"label": "Один бонус на приглашённого",
"description": "",
"i18n_label_key": "admin_settings_field_referral_one_bonus_per_referee_label",
"i18n_description_key": null,
"i18n_subsection_key": "admin_settings_subsection_referral",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"value": "",
"overridden": false,
"updated_at": null
},
{
"key": "REFERRAL_WELCOME_BONUS_DAYS",
"type": "int",
"section": "pricing",
"section_order": 11,
"subsection": "referral",
"label": "Приветственный бонус (дней)",
"description": "",
"i18n_label_key": "admin_settings_field_referral_welcome_bonus_days_label",
"i18n_description_key": null,
"i18n_subsection_key": "admin_settings_subsection_referral",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"min": 0,
"value": "",
"overridden": false,
"updated_at": null
},
{
"key": "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
"type": "bool",
"section": "pricing",
"section_order": 11,
"subsection": "referral",
"label": "Приветственный бонус без Telegram",
"description": "Если выключено, email-only пользователю нужно привязать Telegram для получения реферального приветственного бонуса. Disposable email домены всегда требуют Telegram.",
"i18n_label_key": "admin_settings_field_referral_welcome_bonus_without_telegram_enabled_label",
"i18n_description_key": "admin_settings_field_referral_welcome_bonus_without_telegram_enabled_description",
"i18n_subsection_key": "admin_settings_subsection_referral",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"value": "",
"overridden": false,
"updated_at": null
},
{
"key": "LEGACY_REFS",
"type": "bool",
"section": "pricing",
"section_order": 11,
"subsection": "referral",
"label": "Поддержка старых ref-ссылок",
"description": "",
"i18n_label_key": "admin_settings_field_legacy_refs_label",
"i18n_description_key": null,
"i18n_subsection_key": "admin_settings_subsection_referral",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"value": "",
"overridden": false,
"updated_at": null
},
{
"key": "DISPOSABLE_EMAIL_DOMAINS",
"type": "text",
"section": "pricing",
"section_order": 11,
"subsection": "referral",
"label": "Disposable email домены",
"description": "Домены по одному на строку или через запятую. Пользователи без Telegram с такими email не смогут получить trial или реферальный приветственный бонус.",
"i18n_label_key": "admin_settings_field_disposable_email_domains_label",
"i18n_description_key": "admin_settings_field_disposable_email_domains_description",
"i18n_subsection_key": "admin_settings_subsection_referral",
"i18n_placeholder_key": "admin_settings_field_disposable_email_domains_placeholder",
"placeholder": "mailinator.com\ntemp-mail.org\nyopmail.com",
"optional": true,
"secret": false,
"value": "",
"overridden": false,
"updated_at": null
}
]
},
@@ -352,8 +352,7 @@ export function createBillingStore({
try {
const response = await billing.postPayment(
billing.planPaymentBody(s.selectedPlan, s.selectedMethod, {
renewHwidDevices:
s.renewHwidDevices && Boolean(s.selectedPlan?.hwid_renewal?.available),
renewHwidDevices: s.renewHwidDevices && Boolean(s.selectedPlan?.hwid_renewal?.available),
})
);
const successContext = paymentSuccessContext(s, response);
+12
View File
@@ -2028,7 +2028,19 @@ a {
}
.settings-telegram-link-btn {
min-width: 0;
height: auto;
min-height: 46px;
flex-wrap: wrap;
justify-content: center;
padding-block: 10px;
line-height: 1.16;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-telegram-link-btn svg {
flex: 0 0 auto;
}
.telegram-notifications-card {
+4 -4
View File
@@ -125,9 +125,9 @@
function showHwidRenewalUnavailableNote() {
return Boolean(
subscription?.active &&
Number(subscription?.extra_hwid_devices || 0) > 0 &&
isSubscriptionPlan(selectedPlan) &&
!showHwidRenewalBlock()
Number(subscription?.extra_hwid_devices || 0) > 0 &&
isSubscriptionPlan(selectedPlan) &&
!showHwidRenewalBlock()
);
}
function hwidRenewalCount(plan = selectedPlan) {
@@ -146,7 +146,7 @@
function showHwidDesyncNotice() {
return Boolean(
subscription?.device_topup_renewal_available &&
subscription?.extra_hwid_devices_valid_until_text
subscription?.extra_hwid_devices_valid_until_text
);
}
function planKey(plan) {
+121 -31
View File
@@ -9,9 +9,11 @@
Download,
Gift,
Repeat2,
Send,
} from "$components/ui/icons.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import { AttentionDot } from "$components/ui/index.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import TelegramNotificationsBanner from "../TelegramNotificationsBanner.svelte";
@@ -39,10 +41,12 @@
export let premiumTrafficTopupUnlocked = false;
export let regularTrafficTopupBarClickable = false;
export let regularTrafficTopupUnlocked = false;
export let referral = {};
export let currentTariffName = "";
export let hasActiveTariffSubscription = false;
export let hasMultipleTariffs = false;
export let subscription = {};
export let linkTelegramBusy = false;
export let telegramNotificationsNeedPrompt = false;
export let telegramNotificationsStartLink = "";
export let telegramNotificationsStatus = "unknown";
@@ -142,6 +146,14 @@
$: trialOfferAvailable = Boolean(
!subscription?.active && appSettings?.trial_enabled && appSettings?.trial_available
);
$: trialRequiresTelegram = Boolean(
!subscription?.active && appSettings?.trial_enabled && appSettings?.trial_requires_telegram
);
$: referralWelcomeRequiresTelegram = Boolean(
!subscription?.active &&
referral?.welcome_bonus_requires_telegram &&
Number(referral?.welcome_bonus_days || 0) > 0
);
$: subscriptionEndMs = subscription?.active ? parseSubscriptionEndMs(subscription) : null;
$: subscriptionRemainingMs = Math.max(0, Number(subscriptionEndMs || 0) - nowMs);
$: subscriptionExpiryWarning = Boolean(
@@ -186,6 +198,8 @@
});
export let activateTrial = () => {};
export let linkTelegramAndActivateTrial = () => {};
export let linkTelegramAndClaimReferralWelcome = () => {};
export let openConnectLink = () => {};
export let openPaymentModal = () => {};
export let openTelegramNotificationsBot = () => {};
@@ -374,37 +388,113 @@
/>
</Card>
{/if}
{:else if trialOfferAvailable}
<Card class="trial-card trial-offer-card">
<div class="trial-card-head">
<Gift size={22} />
<span>
<strong>{t("wa_trial_offer_title", {}, "Можно начать с льготного периода")}</strong>
<small>{t("wa_trial_title")}</small>
</span>
</div>
<p class="trial-card-description">
{t(
"wa_trial_offer_description",
{ duration: trialDurationLabel(), traffic: trialTrafficLabel() },
"Активируйте триал: {duration} доступа и {traffic} для скачивания без оплаты."
)}
</p>
<div class="trial-card-facts">
<span>
<small>{t("wa_trial_duration_label", {}, "Срок")}</small>
<strong>{trialDurationLabel()}</strong>
</span>
<span>
<small>{t("wa_trial_download_traffic_label", {}, "Доступно для скачивания")}</small>
<strong>{trialTrafficLabel()}</strong>
</span>
</div>
<Button class="wide trial-card-action" onclick={activateTrial} disabled={trialBusy}>
<Gift size={18} />
{t("wa_trial_try_free", {}, "Попробовать бесплатно")}
</Button>
</Card>
{:else}
{#if referralWelcomeRequiresTelegram}
<Card class="trial-card trial-offer-card">
<div class="trial-card-head">
<Gift size={22} />
<span>
<strong>
{t(
"wa_referral_welcome_telegram_required_title",
{},
"Бонус ждёт привязки Telegram"
)}
</strong>
<small>{t("wa_referral_program_title", {}, "Реферальная программа")}</small>
</span>
</div>
<p class="trial-card-description">
{t(
"wa_referral_welcome_telegram_required_description",
{ days: Number(referral?.welcome_bonus_days || 0) },
"Привяжите Telegram, чтобы получить {days} бонусных дней за регистрацию по приглашению."
)}
</p>
<Button
class="wide trial-card-action settings-telegram-link-btn attention-wrap"
variant="telegram"
onclick={linkTelegramAndClaimReferralWelcome}
disabled={linkTelegramBusy}
>
<AttentionDot />
<Send size={18} />
{t("wa_referral_link_telegram_and_claim", {}, "Привязать и получить бонус")}
</Button>
</Card>
{/if}
{#if trialOfferAvailable}
<Card class="trial-card trial-offer-card">
<div class="trial-card-head">
<Gift size={22} />
<span>
<strong>{t("wa_trial_offer_title", {}, "Можно начать с льготного периода")}</strong>
<small>{t("wa_trial_title")}</small>
</span>
</div>
<p class="trial-card-description">
{t(
"wa_trial_offer_description",
{ duration: trialDurationLabel(), traffic: trialTrafficLabel() },
"Активируйте триал: {duration} доступа и {traffic} для скачивания без оплаты."
)}
</p>
<div class="trial-card-facts">
<span>
<small>{t("wa_trial_duration_label", {}, "Срок")}</small>
<strong>{trialDurationLabel()}</strong>
</span>
<span>
<small>{t("wa_trial_download_traffic_label", {}, "Доступно для скачивания")}</small>
<strong>{trialTrafficLabel()}</strong>
</span>
</div>
<Button class="wide trial-card-action" onclick={activateTrial} disabled={trialBusy}>
<Gift size={18} />
{t("wa_trial_try_free", {}, "Попробовать бесплатно")}
</Button>
</Card>
{:else if trialRequiresTelegram}
<Card class="trial-card trial-offer-card">
<div class="trial-card-head">
<Gift size={22} />
<span>
<strong>
{t("wa_trial_telegram_required_title", {}, "Привяжите Telegram для триала")}
</strong>
<small>{t("wa_trial_title")}</small>
</span>
</div>
<p class="trial-card-description">
{t(
"wa_trial_telegram_required_description",
{ duration: trialDurationLabel(), traffic: trialTrafficLabel() },
"Чтобы активировать триал на {duration} с лимитом {traffic}, сначала привяжите Telegram."
)}
</p>
<div class="trial-card-facts">
<span>
<small>{t("wa_trial_duration_label", {}, "Срок")}</small>
<strong>{trialDurationLabel()}</strong>
</span>
<span>
<small>{t("wa_trial_download_traffic_label", {}, "Доступно для скачивания")}</small>
<strong>{trialTrafficLabel()}</strong>
</span>
</div>
<Button
class="wide trial-card-action settings-telegram-link-btn attention-wrap"
variant="telegram"
onclick={linkTelegramAndActivateTrial}
disabled={linkTelegramBusy || trialBusy}
>
<AttentionDot />
<Send size={18} />
{t("wa_trial_link_telegram_and_activate", {}, "Привязать и активировать")}
</Button>
</Card>
{/if}
{/if}
<div class="action-stack">
@@ -7,9 +7,11 @@
Download,
Gift,
RefreshCw,
Send,
} from "$components/ui/icons.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import { AttentionDot } from "$components/ui/index.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { formatTrafficGb } from "../../lib/webapp/formatters.js";
@@ -19,9 +21,11 @@
export let brandTitle = "";
export let subscription = {};
export let trialBusy = false;
export let linkTelegramBusy = false;
export let trialResult = null;
export let trialError = "";
export let activateTrial = () => {};
export let linkTelegramAndActivateTrial = () => {};
export let openInstallOrConnect = () => {};
export let goHome = () => {};
export let t = (key, _params = {}, fallback = "") => fallback || key;
@@ -30,6 +34,9 @@
$: trialEnabled = Boolean(appSettings?.trial_enabled);
$: trialAvailable = Boolean(appSettings?.trial_available);
$: trialRequiresTelegram = Boolean(
trialEnabled && appSettings?.trial_requires_telegram && !subscription?.active
);
$: canRequestTrial = Boolean(trialEnabled && trialAvailable && !subscription?.active);
$: isTrialStatus =
Boolean(trialResult?.activated) ||
@@ -78,6 +85,8 @@
<RefreshCw size={27} />
{:else if hasActiveAccess}
<CheckCircle2 size={30} />
{:else if trialRequiresTelegram}
<Gift size={30} />
{:else if trialError || !canRequestTrial}
<CircleX size={30} />
{:else}
@@ -116,6 +125,31 @@
<dd>{trafficLabel}</dd>
</div>
</dl>
{:else if trialRequiresTelegram}
<h2>{t("wa_trial_telegram_required_title", {}, "Привяжите Telegram для триала")}</h2>
<p>
{t(
"wa_trial_telegram_required_description",
{
duration:
daysLeft > 0 ? t("wa_trial_days_left", { days: daysLeft }, "{days} days") : "",
traffic: trafficLabel,
},
"Чтобы активировать пробный период, сначала привяжите Telegram."
)}
</p>
<dl class="trial-activation-facts">
{#if daysLeft > 0}
<div>
<dt>{t("wa_trial_duration_label", {}, "Срок")}</dt>
<dd>{t("wa_trial_days_left", { days: daysLeft }, "{days} days")}</dd>
</div>
{/if}
<div>
<dt>{t("wa_trial_traffic_label", {}, "Traffic")}</dt>
<dd>{trafficLabel}</dd>
</div>
</dl>
{:else if trialError}
<h2>{t("wa_trial_activation_failed")}</h2>
<p>{trialError}</p>
@@ -138,6 +172,17 @@
<Download size={18} />
{t("wa_install_and_configure")}
</Button>
{:else if trialRequiresTelegram}
<Button
class="wide settings-telegram-link-btn attention-wrap"
variant="telegram"
onclick={linkTelegramAndActivateTrial}
disabled={linkTelegramBusy || trialBusy}
>
<AttentionDot />
<Send size={18} />
{t("wa_trial_link_telegram_and_activate", {}, "Привязать и активировать")}
</Button>
{:else if trialError && canRequestTrial}
<Button class="wide" onclick={activateTrial} disabled={trialBusy}>
<RefreshCw size={18} />
+36 -1
View File
@@ -835,10 +835,14 @@
"wa_trial_download_traffic_label": "Available to download",
"wa_trial_try_free": "Try for free",
"wa_trial_activated": "Trial activated",
"wa_trial_telegram_required_title": "Link Telegram to start trial",
"wa_trial_telegram_required_description": "To activate a {duration} trial with {traffic}, link Telegram first.",
"wa_trial_link_telegram_and_activate": "Link and activate",
"wa_activation_success_title": "Everything is successfully activated",
"wa_activation_success_install_hint": "Press OK and follow the setup instructions for your device.",
"wa_activation_success_connect_hint": "Press OK and we will open the Remnawave subscription page for setup.",
"wa_trial_activation_failed": "Failed to activate trial",
"wa_trial_telegram_required_error": "Link Telegram to activate the trial.",
"wa_trial_activation_loading": "Activating trial...",
"wa_trial_activation_wait": "Preparing access and connection details.",
"wa_trial_active_hint": "Access is ready. Install the app and import the profile.",
@@ -852,7 +856,15 @@
"wa_install_and_configure": "Install and configure",
"wa_payment_methods_not_configured": "Payment methods are not configured yet",
"wa_pay": "Pay",
"wa_referral_program_title": "Referral program",
"wa_referral_link_title": "Your referral link",
"wa_referral_welcome_telegram_required_title": "Your bonus is waiting for Telegram",
"wa_referral_welcome_telegram_required_description": "Link Telegram to claim {days} bonus days for registering via an invite.",
"wa_referral_link_telegram_and_claim": "Link and claim bonus",
"wa_referral_welcome_claimed": "Referral bonus granted",
"wa_referral_welcome_claimed_until": "Referral bonus granted until {date}",
"wa_referral_welcome_claim_failed": "Failed to grant referral bonus",
"wa_referral_welcome_telegram_required_error": "Link Telegram to claim the referral bonus.",
"wa_link_unavailable": "Link is not available yet",
"wa_link_copied": "Link copied",
"wa_copy": "Copy",
@@ -1131,7 +1143,7 @@
"admin_settings_section_general": "General",
"admin_settings_section_remnawave": "Remnawave Panel",
"admin_settings_section_appearance": "Appearance",
"admin_settings_section_pricing": "Legacy tariff compatibility",
"admin_settings_section_pricing": "Tariffs",
"admin_settings_section_payments": "Payment systems",
"admin_settings_section_trial": "Trial",
"admin_settings_section_referral": "Referral program",
@@ -1145,6 +1157,9 @@
"admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge how many installs are active and which versions are in use. Toggling this off takes effect without a restart.",
"admin_settings_subsection_common": "Common",
"admin_settings_subsection_checkout": "Checkout",
"admin_settings_subsection_trial": "Trial",
"admin_settings_subsection_referral": "Referral program",
"admin_settings_subsection_legacy_tariffs": "Legacy tariffs",
"admin_settings_subsection_remnawave": "Remnawave",
"admin_settings_subsection_remnashop": "Remnashop",
"admin_settings_subsection_telegram_stars": "Telegram Stars",
@@ -1506,6 +1521,7 @@
"admin_tariffs_trial_title": "Trial access",
"admin_tariffs_trial_subtitle": "Configure trial duration, traffic limit, and Remnawave squads from the tariff page.",
"admin_tariffs_trial_enabled": "Trial enabled",
"admin_tariffs_trial_without_telegram_enabled": "Trial without Telegram",
"admin_tariffs_trial_days": "Duration, days",
"admin_tariffs_trial_traffic": "Traffic limit, GB",
"admin_tariffs_trial_strategy": "Traffic reset strategy",
@@ -1520,6 +1536,18 @@
"admin_tariffs_trial_group_reset_hint": "Strategy Remnawave uses to refresh the trial traffic limit.",
"admin_tariffs_trial_group_squads": "Squads",
"admin_tariffs_trial_group_squads_hint": "Squads assigned to the user when trial access is activated.",
"admin_tariffs_referral_title": "Referral program",
"admin_tariffs_referral_subtitle": "Configure welcome bonus, grant rules, and disposable email protection.",
"admin_tariffs_referral_group_welcome": "Welcome bonus",
"admin_tariffs_referral_group_welcome_hint": "Days granted to an invited user after registration via referral link.",
"admin_tariffs_referral_welcome_bonus_days": "Welcome bonus, days",
"admin_tariffs_referral_without_telegram": "Grant welcome bonus without Telegram",
"admin_tariffs_referral_group_rules": "Rules and anti-abuse",
"admin_tariffs_referral_group_rules_hint": "Repeat-bonus limits and disposable email domains for no-Telegram accounts.",
"admin_tariffs_referral_one_bonus_per_referee": "One bonus per invited user",
"admin_tariffs_referral_legacy_refs": "Legacy ref links",
"admin_tariffs_referral_disposable_domains": "Disposable email domains",
"admin_tariffs_referral_disposable_domains_hint": "One domain per line or comma-separated. Subdomains are treated as matches too.",
"admin_tariffs_legacy_title": "Legacy tariff compatibility",
"admin_tariffs_legacy_subtitle": "Old remnawave-tg-shop periods and traffic packages used only when the JSON tariff catalog is not configured.",
"admin_tariffs_legacy_period": "Period",
@@ -1728,9 +1756,16 @@
"admin_settings_field_trial_duration_days_label": "Trial Duration Days",
"admin_settings_field_trial_traffic_limit_gb_label": "Trial Traffic Limit Gb",
"admin_settings_field_trial_traffic_strategy_label": "Trial Traffic Strategy",
"admin_settings_field_trial_without_telegram_enabled_label": "Trial Without Telegram",
"admin_settings_field_trial_without_telegram_enabled_description": "If disabled, email-only users must link Telegram before activating a trial. Disposable email domains always require Telegram.",
"admin_settings_field_referral_one_bonus_per_referee_label": "Referral One Bonus Per Referee",
"admin_settings_field_referral_welcome_bonus_days_label": "Referral Welcome Bonus Days",
"admin_settings_field_referral_welcome_bonus_without_telegram_enabled_label": "Referral Welcome Bonus Without Telegram",
"admin_settings_field_referral_welcome_bonus_without_telegram_enabled_description": "If disabled, email-only users must link Telegram before receiving the referral welcome bonus. Disposable email domains always require Telegram.",
"admin_settings_field_legacy_refs_label": "Legacy Refs",
"admin_settings_field_disposable_email_domains_label": "Disposable Email Domains",
"admin_settings_field_disposable_email_domains_description": "Comma-separated domains. Users without Telegram using these emails cannot claim trial or referral welcome bonus.",
"admin_settings_field_disposable_email_domains_placeholder": "mailinator.com,temp-mail.org,yopmail.com",
"admin_settings_field_referral_bonus_days_inviter_1_month_label": "Referral Bonus Days Inviter 1 Month",
"admin_settings_field_referral_bonus_days_inviter_3_months_label": "Referral Bonus Days Inviter 3 Months",
"admin_settings_field_referral_bonus_days_inviter_6_months_label": "Referral Bonus Days Inviter 6 Months",
+36 -1
View File
@@ -835,10 +835,14 @@
"wa_trial_download_traffic_label": "Доступно для скачивания",
"wa_trial_try_free": "Попробовать бесплатно",
"wa_trial_activated": "Пробный период активирован",
"wa_trial_telegram_required_title": "Привяжите Telegram для триала",
"wa_trial_telegram_required_description": "Чтобы активировать триал на {duration} с лимитом {traffic}, сначала привяжите Telegram.",
"wa_trial_link_telegram_and_activate": "Привязать и активировать",
"wa_activation_success_title": "Всё успешно активировано",
"wa_activation_success_install_hint": "Нажмите ОК и следуйте инструкциям по установке на вашем устройстве.",
"wa_activation_success_connect_hint": "Нажмите ОК, и мы откроем страницу подписки Remnawave для настройки устройства.",
"wa_trial_activation_failed": "Не удалось активировать пробный период",
"wa_trial_telegram_required_error": "Для активации пробного периода привяжите Telegram.",
"wa_trial_activation_loading": "Активируем пробный период...",
"wa_trial_activation_wait": "Готовим доступ и данные для подключения.",
"wa_trial_active_hint": "Доступ готов. Установите приложение и импортируйте профиль.",
@@ -852,7 +856,15 @@
"wa_install_and_configure": "Установить и настроить",
"wa_payment_methods_not_configured": "Способы оплаты пока не настроены",
"wa_pay": "Оплатить",
"wa_referral_program_title": "Реферальная программа",
"wa_referral_link_title": "Ваша реферальная ссылка",
"wa_referral_welcome_telegram_required_title": "Бонус ждёт привязки Telegram",
"wa_referral_welcome_telegram_required_description": "Привяжите Telegram, чтобы получить {days} бонусных дней за регистрацию по приглашению.",
"wa_referral_link_telegram_and_claim": "Привязать и получить бонус",
"wa_referral_welcome_claimed": "Реферальный бонус начислен",
"wa_referral_welcome_claimed_until": "Реферальный бонус начислен до {date}",
"wa_referral_welcome_claim_failed": "Не удалось начислить реферальный бонус",
"wa_referral_welcome_telegram_required_error": "Для получения реферального бонуса привяжите Telegram.",
"wa_link_unavailable": "Ссылка пока недоступна",
"wa_link_copied": "Ссылка скопирована",
"wa_copy": "Копировать",
@@ -1131,7 +1143,7 @@
"admin_settings_section_general": "Общие",
"admin_settings_section_remnawave": "Remnawave Panel",
"admin_settings_section_appearance": "Внешний вид",
"admin_settings_section_pricing": "Совместимость с legacy-тарифами",
"admin_settings_section_pricing": "Тарифы",
"admin_settings_section_payments": "Платёжные системы",
"admin_settings_section_trial": "Триал",
"admin_settings_section_referral": "Реферальная программа",
@@ -1145,6 +1157,9 @@
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
"admin_settings_subsection_common": "Общие",
"admin_settings_subsection_checkout": "Оформление оплаты",
"admin_settings_subsection_trial": "Пробный период",
"admin_settings_subsection_referral": "Реферальная программа",
"admin_settings_subsection_legacy_tariffs": "Legacy-тарифы",
"admin_settings_subsection_remnawave": "Remnawave",
"admin_settings_subsection_remnashop": "Remnashop",
"admin_settings_subsection_telegram_stars": "Telegram Stars",
@@ -1506,6 +1521,7 @@
"admin_tariffs_trial_title": "Пробный период",
"admin_tariffs_trial_subtitle": "Настройки длительности, лимита трафика и Remnawave-сквадов для триала.",
"admin_tariffs_trial_enabled": "Триал включён",
"admin_tariffs_trial_without_telegram_enabled": "Триал без Telegram",
"admin_tariffs_trial_days": "Длительность, дней",
"admin_tariffs_trial_traffic": "Лимит трафика, GB",
"admin_tariffs_trial_strategy": "Стратегия сброса трафика",
@@ -1520,6 +1536,18 @@
"admin_tariffs_trial_group_reset_hint": "Стратегия, по которой Remnawave обновляет лимит трафика для пробного периода.",
"admin_tariffs_trial_group_squads": "Сквады",
"admin_tariffs_trial_group_squads_hint": "Сквады, которые будут назначены пользователю при активации триала.",
"admin_tariffs_referral_title": "Реферальная программа",
"admin_tariffs_referral_subtitle": "Настройки приветственного бонуса, правил начисления и защиты от одноразовых email.",
"admin_tariffs_referral_group_welcome": "Приветственный бонус",
"admin_tariffs_referral_group_welcome_hint": "Дни, которые получает приглашённый пользователь после регистрации по ссылке.",
"admin_tariffs_referral_welcome_bonus_days": "Приветственный бонус, дней",
"admin_tariffs_referral_without_telegram": "Начислять welcome bonus без Telegram",
"admin_tariffs_referral_group_rules": "Правила и антиабьюз",
"admin_tariffs_referral_group_rules_hint": "Ограничения повторных бонусов и домены одноразовой почты для no-Telegram аккаунтов.",
"admin_tariffs_referral_one_bonus_per_referee": "Один бонус на приглашённого",
"admin_tariffs_referral_legacy_refs": "Старые ref-ссылки",
"admin_tariffs_referral_disposable_domains": "Disposable email домены",
"admin_tariffs_referral_disposable_domains_hint": "По одному домену на строку или через запятую. Поддомены тоже считаются совпадением.",
"admin_tariffs_legacy_title": "Совместимость с legacy-тарифами",
"admin_tariffs_legacy_subtitle": "Старые периоды и пакеты трафика remnawave-tg-shop, которые используются только без JSON-каталога.",
"admin_tariffs_legacy_period": "Период",
@@ -1728,9 +1756,16 @@
"admin_settings_field_trial_duration_days_label": "Длительность триала (дней)",
"admin_settings_field_trial_traffic_limit_gb_label": "Лимит трафика триала (ГБ)",
"admin_settings_field_trial_traffic_strategy_label": "Стратегия сброса трафика триала",
"admin_settings_field_trial_without_telegram_enabled_label": "Триал без Telegram",
"admin_settings_field_trial_without_telegram_enabled_description": "Если выключено, email-only пользователю нужно привязать Telegram для активации триала. Disposable email домены всегда требуют Telegram.",
"admin_settings_field_referral_one_bonus_per_referee_label": "Один бонус на приглашённого",
"admin_settings_field_referral_welcome_bonus_days_label": "Приветственный бонус (дней)",
"admin_settings_field_referral_welcome_bonus_without_telegram_enabled_label": "Приветственный бонус без Telegram",
"admin_settings_field_referral_welcome_bonus_without_telegram_enabled_description": "Если выключено, email-only пользователю нужно привязать Telegram для получения реферального приветственного бонуса. Disposable email домены всегда требуют Telegram.",
"admin_settings_field_legacy_refs_label": "Поддержка старых ref-ссылок",
"admin_settings_field_disposable_email_domains_label": "Disposable email домены",
"admin_settings_field_disposable_email_domains_description": "Домены через запятую. Пользователи без Telegram с такими email не смогут получить trial или реферальный приветственный бонус.",
"admin_settings_field_disposable_email_domains_placeholder": "mailinator.com,temp-mail.org,yopmail.com",
"admin_settings_field_referral_bonus_days_inviter_1_month_label": "Бонус приглашающему: 1 мес.",
"admin_settings_field_referral_bonus_days_inviter_3_months_label": "Бонус приглашающему: 3 мес.",
"admin_settings_field_referral_bonus_days_inviter_6_months_label": "Бонус приглашающему: 6 мес.",
@@ -55,6 +55,7 @@ ADMIN_TARIFF_SETTINGS_PAGE_KEYS = {
"admin_tariffs_trial_title",
"admin_tariffs_trial_subtitle",
"admin_tariffs_trial_enabled",
"admin_tariffs_trial_without_telegram_enabled",
"admin_tariffs_trial_days",
"admin_tariffs_trial_traffic",
"admin_tariffs_trial_strategy",
@@ -69,6 +70,18 @@ ADMIN_TARIFF_SETTINGS_PAGE_KEYS = {
"admin_tariffs_trial_group_reset_hint",
"admin_tariffs_trial_group_squads",
"admin_tariffs_trial_group_squads_hint",
"admin_tariffs_referral_title",
"admin_tariffs_referral_subtitle",
"admin_tariffs_referral_group_welcome",
"admin_tariffs_referral_group_welcome_hint",
"admin_tariffs_referral_welcome_bonus_days",
"admin_tariffs_referral_without_telegram",
"admin_tariffs_referral_group_rules",
"admin_tariffs_referral_group_rules_hint",
"admin_tariffs_referral_one_bonus_per_referee",
"admin_tariffs_referral_legacy_refs",
"admin_tariffs_referral_disposable_domains",
"admin_tariffs_referral_disposable_domains_hint",
"admin_tariffs_legacy_title",
"admin_tariffs_legacy_subtitle",
"admin_tariffs_legacy_period",
@@ -243,6 +256,7 @@ def test_trial_required_settings_reject_empty_values():
"TRIAL_DURATION_DAYS",
"TRIAL_TRAFFIC_LIMIT_GB",
"TRIAL_TRAFFIC_STRATEGY",
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
):
with pytest.raises(ValueError):
coerce_value(get_field_by_key(key), "")
@@ -321,8 +335,20 @@ def test_legacy_tariff_settings_are_separated_from_payment_settings():
assert manifest["MONTH_1_ENABLED"]["section_order"] == 11
assert manifest["TRIAL_ENABLED"]["section"] == "pricing"
assert manifest["TRIAL_ENABLED"]["subsection"] == "trial"
assert manifest["TRIAL_WITHOUT_TELEGRAM_ENABLED"]["section"] == "pricing"
assert manifest["TRIAL_WITHOUT_TELEGRAM_ENABLED"]["subsection"] == "trial"
assert manifest["TRIAL_SQUAD_UUIDS"]["section"] == "pricing"
assert manifest["TRIAL_SQUAD_UUIDS"]["subsection"] == "trial"
assert manifest["REFERRAL_WELCOME_BONUS_DAYS"]["section"] == "pricing"
assert manifest["REFERRAL_WELCOME_BONUS_DAYS"]["subsection"] == "referral"
assert manifest["REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED"]["section"] == "pricing"
assert manifest["REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED"]["subsection"] == "referral"
assert manifest["REFERRAL_ONE_BONUS_PER_REFEREE"]["section"] == "pricing"
assert manifest["REFERRAL_ONE_BONUS_PER_REFEREE"]["subsection"] == "referral"
assert manifest["LEGACY_REFS"]["section"] == "pricing"
assert manifest["LEGACY_REFS"]["subsection"] == "referral"
assert manifest["DISPOSABLE_EMAIL_DOMAINS"]["section"] == "pricing"
assert manifest["DISPOSABLE_EMAIL_DOMAINS"]["subsection"] == "referral"
def test_platega_settings_share_one_admin_subsection():
@@ -0,0 +1,78 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock
import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import auth as auth_module
class WebAppReferralWelcomeBonusTests(IsolatedAsyncioTestCase):
async def test_disposable_email_referral_welcome_bonus_requires_telegram(self):
settings = SimpleNamespace(
REFERRAL_WELCOME_BONUS_DAYS=3,
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED=True,
DISPOSABLE_EMAIL_DOMAINS="mailinator.com",
)
user = SimpleNamespace(
user_id=42,
referred_by_id=7,
telegram_id=None,
email="person@mailinator.com",
)
subscription_service = SimpleNamespace(
has_active_subscription=AsyncMock(return_value=False),
extend_active_subscription_days=AsyncMock(),
)
request = SimpleNamespace(
app={"settings": settings, "subscription_service": subscription_service}
)
result = await auth_module._apply_referral_welcome_bonus_if_needed(
request,
SimpleNamespace(),
user,
"ABC123",
)
self.assertIsNone(result)
subscription_service.has_active_subscription.assert_not_awaited()
subscription_service.extend_active_subscription_days.assert_not_awaited()
async def test_linked_telegram_allows_disposable_email_referral_welcome_bonus(self):
end_date = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc)
settings = SimpleNamespace(
REFERRAL_WELCOME_BONUS_DAYS=3,
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED=True,
DISPOSABLE_EMAIL_DOMAINS="mailinator.com",
)
user = SimpleNamespace(
user_id=42,
referred_by_id=7,
telegram_id=123456,
email="person@mailinator.com",
)
session = SimpleNamespace()
subscription_service = SimpleNamespace(
has_active_subscription=AsyncMock(return_value=False),
extend_active_subscription_days=AsyncMock(return_value=end_date),
)
request = SimpleNamespace(
app={"settings": settings, "subscription_service": subscription_service}
)
result = await auth_module._apply_referral_welcome_bonus_if_needed(
request,
session,
user,
"ABC123",
)
self.assertEqual(result, end_date)
subscription_service.has_active_subscription.assert_awaited_once_with(session, 42)
subscription_service.extend_active_subscription_days.assert_awaited_once_with(
session,
42,
3,
reason="referral_welcome_bonus",
)
+92
View File
@@ -115,3 +115,95 @@ class WebAppTrialActivationTests(IsolatedAsyncioTestCase):
mark_trial_activated.assert_awaited_once_with(session, 42)
self.assertEqual(session.commit_count, 2)
self.assertEqual(session.rollback_count, 0)
async def test_email_only_trial_activation_requires_telegram_when_disabled(self):
session = _Session()
settings = SimpleNamespace(
TRIAL_ENABLED=True,
TRIAL_DURATION_DAYS=7,
TRIAL_TRAFFIC_LIMIT_GB=10,
TRIAL_WITHOUT_TELEGRAM_ENABLED=False,
DISPOSABLE_EMAIL_DOMAINS="",
LOG_TRIAL_ACTIVATIONS=False,
)
db_user = SimpleNamespace(
user_id=42,
telegram_id=None,
is_banned=False,
email="email-only@example.com",
)
subscription_service = SimpleNamespace(activate_trial_subscription=AsyncMock())
request = SimpleNamespace(
app={
"settings": settings,
"async_session_factory": _SessionFactory(session),
"subscription_service": subscription_service,
}
)
with (
patch.object(billing_module, "_require_user_id", return_value=42),
patch.object(
billing_module,
"_enforce_webapp_rate_limit",
AsyncMock(return_value=None),
),
patch.object(
billing_module.user_dal,
"get_user_by_id",
AsyncMock(return_value=db_user),
),
):
response = await billing_module.activate_trial_route(request)
payload = json.loads(response.text)
self.assertEqual(response.status, 400)
self.assertEqual(payload["error"], "trial_telegram_required")
self.assertEqual(payload["message"], "telegram_required")
subscription_service.activate_trial_subscription.assert_not_awaited()
async def test_disposable_email_trial_activation_requires_telegram(self):
session = _Session()
settings = SimpleNamespace(
TRIAL_ENABLED=True,
TRIAL_DURATION_DAYS=7,
TRIAL_TRAFFIC_LIMIT_GB=10,
TRIAL_WITHOUT_TELEGRAM_ENABLED=True,
DISPOSABLE_EMAIL_DOMAINS="mailinator.com,temp-mail.org",
LOG_TRIAL_ACTIVATIONS=False,
)
db_user = SimpleNamespace(
user_id=42,
telegram_id=None,
is_banned=False,
email="person@mailinator.com",
)
subscription_service = SimpleNamespace(activate_trial_subscription=AsyncMock())
request = SimpleNamespace(
app={
"settings": settings,
"async_session_factory": _SessionFactory(session),
"subscription_service": subscription_service,
}
)
with (
patch.object(billing_module, "_require_user_id", return_value=42),
patch.object(
billing_module,
"_enforce_webapp_rate_limit",
AsyncMock(return_value=None),
),
patch.object(
billing_module.user_dal,
"get_user_by_id",
AsyncMock(return_value=db_user),
),
):
response = await billing_module.activate_trial_route(request)
payload = json.loads(response.text)
self.assertEqual(response.status, 400)
self.assertEqual(payload["error"], "trial_telegram_required")
self.assertEqual(payload["message"], "disposable_email")
subscription_service.activate_trial_subscription.assert_not_awaited()