feat: add telegram guardrails for trials and referrals
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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` | Включает напоминания о подписке. |
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user