diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 55dbc7e..8d2cc81 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -163,32 +163,66 @@ def _legacy_referral_bonus_periods(settings: Settings) -> List[int]: return sorted(int(months) for months in settings.subscription_options) +def _serialize_tariff_period_referral_bonus_details( + tariff: Any, lang: str +) -> List[Dict[str, Any]]: + details: List[Dict[str, Any]] = [] + for months in sorted(int(month) for month in tariff.enabled_periods): + inviter_days = tariff.referral_inviter_bonus_days(months) + friend_days = tariff.referral_referee_bonus_days(months) + if inviter_days is None and friend_days is None: + continue + details.append( + { + "id": f"{tariff.key}:{months}", + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "months": int(months), + "title": _format_months_title(int(months), lang), + "inviter_days": int(inviter_days or 0), + "friend_days": int(friend_days or 0), + } + ) + return details + + def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]: tariffs_config = settings.tariffs_config if not tariffs_config: return [] - details: List[Dict[str, Any]] = [] - for tariff in tariffs_config.enabled_tariffs: - if tariff.billing_model != "period": + period_tariffs = [ + tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period" + ] + if len(period_tariffs) <= 1: + return ( + _serialize_tariff_period_referral_bonus_details(period_tariffs[0], lang) + if period_tariffs + else [] + ) + + summaries: List[Dict[str, Any]] = [] + for tariff in period_tariffs: + details = _serialize_tariff_period_referral_bonus_details(tariff, lang) + if not details: continue - for months in sorted(int(month) for month in tariff.enabled_periods): - inviter_days = tariff.referral_inviter_bonus_days(months) - friend_days = tariff.referral_referee_bonus_days(months) - if inviter_days is None and friend_days is None: - continue - details.append( - { - "id": f"{tariff.key}:{months}", - "tariff_key": tariff.key, - "tariff_name": tariff.name(lang), - "months": int(months), - "title": f"{tariff.name(lang)} - {_format_months_title(int(months), lang)}", - "inviter_days": int(inviter_days or 0), - "friend_days": int(friend_days or 0), - } - ) - return details + inviter_values = [int(item["inviter_days"]) for item in details] + friend_values = [int(item["friend_days"]) for item in details] + summaries.append( + { + "id": f"tariff:{tariff.key}", + "type": "tariff_summary", + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "title": tariff.name(lang), + "inviter_min_days": min(inviter_values), + "inviter_max_days": max(inviter_values), + "friend_min_days": min(friend_values), + "friend_max_days": max(friend_values), + "details": details, + } + ) + return summaries def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]: diff --git a/backend/bot/handlers/user/referral.py b/backend/bot/handlers/user/referral.py index 3059e4e..f58289d 100644 --- a/backend/bot/handlers/user/referral.py +++ b/backend/bot/handlers/user/referral.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Union +from typing import Any, Callable, Optional, Union from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from aiogram import Bot, F, Router, types @@ -76,31 +76,10 @@ async def referral_command_handler( await event.answer() return - bonus_info_parts = [] if getattr(settings, "traffic_sale_mode", False): bonus_details_str = _("referral_not_available_for_traffic") else: - if settings.subscription_options: - for months_period_key, _price in sorted(settings.subscription_options.items()): - inv_bonus = settings.referral_bonus_inviter.get(months_period_key) - ref_bonus = settings.referral_bonus_referee.get(months_period_key) - if inv_bonus is not None or ref_bonus is not None: - bonus_info_parts.append( - _( - "referral_bonus_per_period", - months=months_period_key, - inviter_bonus_days=inv_bonus - if inv_bonus is not None - else _("no_bonus_placeholder"), - referee_bonus_days=ref_bonus - if ref_bonus is not None - else _("no_bonus_placeholder"), - ) - ) - - bonus_details_str = ( - "\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured") - ) + bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang) referral_stats = await referral_service.get_referral_stats(session, inviter_user_id) @@ -208,6 +187,132 @@ async def referral_action_handler( await callback.answer() +Translator = Callable[..., str] + + +def _period_bonus_text( + translator: Translator, + *, + months: int, + inviter_days: Optional[int], + referee_days: Optional[int], +) -> str: + return translator( + "referral_bonus_per_period", + months=months, + inviter_bonus_days=( + inviter_days if inviter_days is not None else translator("no_bonus_placeholder") + ), + referee_bonus_days=( + referee_days if referee_days is not None else translator("no_bonus_placeholder") + ), + ) + + +def _tariff_period_bonus_entries(tariff: Any) -> list[dict[str, Optional[int]]]: + entries: list[dict[str, Optional[int]]] = [] + for months in sorted(int(month) for month in getattr(tariff, "enabled_periods", [])): + inviter_days = tariff.referral_inviter_bonus_days(months) + referee_days = tariff.referral_referee_bonus_days(months) + if inviter_days is None and referee_days is None: + continue + entries.append( + { + "months": months, + "inviter_days": inviter_days, + "referee_days": referee_days, + } + ) + return entries + + +def _legacy_period_bonus_entries(settings: Settings) -> list[dict[str, Optional[int]]]: + entries: list[dict[str, Optional[int]]] = [] + for months, _price in sorted(settings.subscription_options.items()): + inviter_days = settings.referral_bonus_inviter.get(months) + referee_days = settings.referral_bonus_referee.get(months) + if inviter_days is None and referee_days is None: + continue + entries.append( + { + "months": int(months), + "inviter_days": inviter_days, + "referee_days": referee_days, + } + ) + return entries + + +def _bonus_days_range(translator: Translator, values: list[int]) -> str: + return translator( + "referral_bonus_days_range", + min_days=min(values), + max_days=max(values), + ) + + +def _build_referral_bonus_details_text( + settings: Settings, translator: Translator, current_lang: str +) -> str: + tariffs_config = settings.tariffs_config + if not tariffs_config: + bonus_info_parts = [ + _period_bonus_text( + translator, + months=int(entry["months"] or 0), + inviter_days=entry["inviter_days"], + referee_days=entry["referee_days"], + ) + for entry in _legacy_period_bonus_entries(settings) + ] + return ( + "\n".join(bonus_info_parts) + if bonus_info_parts + else translator("referral_no_bonuses_configured") + ) + + period_tariffs = [ + tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period" + ] + if len(period_tariffs) <= 1: + entries = _tariff_period_bonus_entries(period_tariffs[0]) if period_tariffs else [] + bonus_info_parts = [ + _period_bonus_text( + translator, + months=int(entry["months"] or 0), + inviter_days=entry["inviter_days"], + referee_days=entry["referee_days"], + ) + for entry in entries + ] + return ( + "\n".join(bonus_info_parts) + if bonus_info_parts + else translator("referral_no_bonuses_configured") + ) + + bonus_info_parts = [] + for tariff in period_tariffs: + entries = _tariff_period_bonus_entries(tariff) + if not entries: + continue + inviter_values = [int(entry["inviter_days"] or 0) for entry in entries] + referee_values = [int(entry["referee_days"] or 0) for entry in entries] + bonus_info_parts.append( + translator( + "referral_bonus_tariff_range", + tariff_name=tariff.name(current_lang), + inviter_bonus_range=_bonus_days_range(translator, inviter_values), + referee_bonus_range=_bonus_days_range(translator, referee_values), + ) + ) + return ( + "\n".join(bonus_info_parts) + if bonus_info_parts + else translator("referral_no_bonuses_configured") + ) + + def _build_webapp_referral_link( base_url: Optional[str], referral_code: Optional[str] ) -> Optional[str]: diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index f01007b..0a99321 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -1537,6 +1537,89 @@ a { font-size: 12px; } +.referral-tariff-dropdown { + display: grid; + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-sheen-soft); +} + +.referral-tariff-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-width: 0; + padding: 10px 11px; + cursor: pointer; + list-style: none; + -webkit-tap-highlight-color: transparent; +} + +.referral-tariff-summary::-webkit-details-marker { + display: none; +} + +.referral-tariff-copy { + display: grid; + min-width: 0; + gap: 3px; +} + +.referral-tariff-copy strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.referral-tariff-copy small { + color: var(--muted); + font-size: 12px; +} + +.referral-tariff-dropdown summary:hover .premium-server-help-icon, +.referral-tariff-dropdown[open] .premium-server-help-icon { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.referral-tariff-details { + display: grid; + grid-template-rows: 0fr; + padding: 0 8px; + opacity: 0; + transform: translateY(-2px); + transform-origin: top; + transition: + grid-template-rows 0.22s ease, + padding 0.22s ease, + opacity 0.18s ease, + transform 0.22s ease; +} + +.referral-tariff-details > div { + min-height: 0; + overflow: hidden; +} + +.referral-tariff-detail-list { + display: grid; + gap: 8px; +} + +.referral-tariff-dropdown[open] .referral-tariff-details { + grid-template-rows: 1fr; + padding: 0 8px 8px; + opacity: 1; + transform: translateY(0); +} + +.referral-bonus-row-nested { + background: var(--surface); +} + .settings-profile { display: flex; align-items: center; diff --git a/frontend/src/webapp/screens/InviteScreen.svelte b/frontend/src/webapp/screens/InviteScreen.svelte index 1d1ed13..3668e46 100644 --- a/frontend/src/webapp/screens/InviteScreen.svelte +++ b/frontend/src/webapp/screens/InviteScreen.svelte @@ -1,5 +1,5 @@
@@ -55,20 +66,68 @@ {t("wa_referral_bonus_friend_days", { days: referralWelcomeBonusDays })} {/if} - {#if referralBonusDetails.length} + {#if usesTariffBonusSummaries} +

{t("wa_referral_bonus_depends_on_tariff")}

+ {:else if periodBonusDetails.length}

{t("wa_referral_bonus_paid_intro")}

{/if} - {#each referralBonusDetails as bonus, index (bonus.id || `${bonus.tariff_key || "legacy"}:${bonus.months || index}`)} -
- {bonus.title || `${bonus.months || "?"}`} - {t("wa_referral_bonus_you_days", { days: Number(bonus.inviter_days || 0) })} - {t("wa_referral_bonus_friend_days", { days: Number(bonus.friend_days || 0) })} -
- {/each} + {#if usesTariffBonusSummaries} + {#each tariffBonusSummaries as tariffBonus, index (tariffBonus.id || `tariff:${tariffBonus.tariff_key || index}`)} +
+ + + {tariffBonus.title || tariffBonus.tariff_name} + + {t("wa_referral_bonus_you_range", { + range: daysRange(tariffBonus.inviter_min_days, tariffBonus.inviter_max_days), + })} + + + {t("wa_referral_bonus_friend_range", { + range: daysRange(tariffBonus.friend_min_days, tariffBonus.friend_max_days), + })} + + + + +
+
+ {#each tariffBonus.details || [] as bonus, detailIndex (bonus.id || `${tariffBonus.tariff_key || index}:${bonus.months || detailIndex}`)} +
+ {bonus.title || `${bonus.months || "?"}`} + {t("wa_referral_bonus_you_days", { + days: Number(bonus.inviter_days || 0), + })} + {t("wa_referral_bonus_friend_days", { + days: Number(bonus.friend_days || 0), + })} +
+ {/each} +
+
+
+ {/each} + {:else} + {#each periodBonusDetails as bonus, index (bonus.id || `${bonus.tariff_key || "legacy"}:${bonus.months || index}`)} +
+ {bonus.title || `${bonus.months || "?"}`} + {t("wa_referral_bonus_you_days", { + days: Number(bonus.inviter_days || 0), + })} + {t("wa_referral_bonus_friend_days", { + days: Number(bonus.friend_days || 0), + })} +
+ {/each} + {/if} {:else} {t("wa_referral_bonus_not_configured")} diff --git a/locales/en.json b/locales/en.json index bc40b13..427be32 100644 --- a/locales/en.json +++ b/locales/en.json @@ -97,6 +97,8 @@ "no_button": "No", "referral_program_info_new": "🎁 Referral Program\n\n📊 Your stats:\n👥 Friends invited: {invited_count}\n💳 Purchased subscription: {purchased_count}\n\n🔗 Telegram link:\n{referral_link}{webapp_link_section}\n\n💰 Invitation bonuses:\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: {inviter_bonus_days} days\n ➢ Friend: {referee_bonus_days} days", + "referral_bonus_days_range": "from {min_days} to {max_days} days", + "referral_bonus_tariff_range": "\n\n🎁 {tariff_name}:\n ➢ You: {inviter_bonus_range}\n ➢ Friend: {referee_bonus_range}", "referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.", "referral_share_message_button": "📩 Message for friend", "referral_friend_message": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", @@ -778,8 +780,12 @@ "wa_referral_bonus_once_note": "Bonus is granted only once per invited user after their first payment.", "wa_referral_bonus_registration_title": "For registration via referral link", "wa_referral_bonus_paid_intro": "If a friend pays for a subscription:", + "wa_referral_bonus_depends_on_tariff": "Bonus depends on the tariff and payment period your friend chooses", + "wa_referral_bonus_range_days": "from {min} to {max} days", "wa_referral_bonus_you_days": "You: +{days} days", "wa_referral_bonus_friend_days": "Friend: +{days} days", + "wa_referral_bonus_you_range": "You: {range}", + "wa_referral_bonus_friend_range": "Friend: {range}", "wa_referral_bonus_not_configured": "Referral bonuses are not configured", "wa_promo_enter": "Enter promo code", "wa_promo_activated_until": "Promo code activated. Subscription until {date}", diff --git a/locales/ru.json b/locales/ru.json index 72397cd..aaf7564 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -97,6 +97,8 @@ "no_button": "Нет", "referral_program_info_new": "🎁 Реферальная программа\n\n📊 Твоя статистика:\n👥 Приглашено друзей: {invited_count}\n💳 Купили подписку: {purchased_count}\n\n🔗 Telegram ссылка:\n{referral_link}{webapp_link_section}\n\n💰 Бонусы за приглашения:\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: {inviter_bonus_days} дн.\n ➢ Друг: {referee_bonus_days} дн.", + "referral_bonus_days_range": "от {min_days} до {max_days} дн.", + "referral_bonus_tariff_range": "\n\n🎁 {tariff_name}:\n ➢ Вы: {inviter_bonus_range}\n ➢ Друг: {referee_bonus_range}", "referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.", "referral_share_message_button": "📩 Сообщение для друга", "referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", @@ -778,8 +780,12 @@ "wa_referral_bonus_once_note": "Бонус за приглашённого начисляется только один раз, после его первой оплаты.", "wa_referral_bonus_registration_title": "За регистрацию по реферальной ссылке", "wa_referral_bonus_paid_intro": "Если друг оплатит подписку:", + "wa_referral_bonus_depends_on_tariff": "Бонус зависит от тарифа и периода оплаты друга", + "wa_referral_bonus_range_days": "от {min} до {max} дней", "wa_referral_bonus_you_days": "Вам: +{days} дней", "wa_referral_bonus_friend_days": "Другу: +{days} дней", + "wa_referral_bonus_you_range": "Вам: {range}", + "wa_referral_bonus_friend_range": "Другу: {range}", "wa_referral_bonus_not_configured": "Реферальные бонусы не настроены", "wa_promo_enter": "Введите промокод", "wa_promo_activated_until": "Промокод активирован. Подписка до {date}", diff --git a/tests/test_user_bot_menu.py b/tests/test_user_bot_menu.py index 0f50f84..17789f0 100644 --- a/tests/test_user_bot_menu.py +++ b/tests/test_user_bot_menu.py @@ -20,6 +20,7 @@ from bot.keyboards.inline.user_keyboards import ( payment_methods_back_callback, payment_options_back_callback, ) +from config.tariffs_config import TariffsConfig class JsonI18nStub: @@ -304,3 +305,95 @@ class UserBotMenuTests(unittest.TestCase): self.assertLess(text.index("Telegram link:"), text.index("Web link:")) self.assertLess(text.index("Web link:"), text.index("Invitation bonuses:")) + + def test_single_tariff_referral_text_uses_period_rows_without_tariff_name(self): + settings = SimpleNamespace( + tariffs_config=TariffsConfig.model_validate( + { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {}, + "squad_uuids": ["standard"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"2": 400, "4": 800}, + "prices_stars": {}, + "referral_bonus_days_inviter": {"2": 5, "4": 10}, + "referral_bonus_days_referee": {"2": 1, "4": 2}, + "enabled_periods": [2, 4], + "enabled": True, + } + ], + } + ), + subscription_options={}, + referral_bonus_inviter={}, + referral_bonus_referee={}, + ) + + text = referral._build_referral_bonus_details_text( + settings, + lambda key, **kwargs: self.i18n.gettext("en", key, **kwargs), + "en", + ) + + self.assertIn("For a friend's 2-month subscription", text) + self.assertIn("For a friend's 4-month subscription", text) + self.assertNotIn("Standard", text) + + def test_multiple_tariff_referral_text_uses_tariff_ranges(self): + settings = SimpleNamespace( + tariffs_config=TariffsConfig.model_validate( + { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {}, + "squad_uuids": ["standard"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"2": 400, "4": 800}, + "prices_stars": {}, + "referral_bonus_days_inviter": {"2": 5, "4": 10}, + "referral_bonus_days_referee": {"2": 1, "4": 2}, + "enabled_periods": [2, 4], + "enabled": True, + }, + { + "key": "premium", + "names": {"en": "Premium"}, + "descriptions": {}, + "squad_uuids": ["premium"], + "billing_model": "period", + "monthly_gb": 500, + "prices_rub": {"1": 700, "3": 1800}, + "prices_stars": {}, + "referral_bonus_days_inviter": {"1": 8, "3": 24}, + "referral_bonus_days_referee": {"1": 3, "3": 9}, + "enabled_periods": [1, 3], + "enabled": True, + }, + ], + } + ), + subscription_options={}, + referral_bonus_inviter={}, + referral_bonus_referee={}, + ) + + text = referral._build_referral_bonus_details_text( + settings, + lambda key, **kwargs: self.i18n.gettext("en", key, **kwargs), + "en", + ) + + self.assertIn("Standard", text) + self.assertIn("Premium", text) + self.assertIn("from 5 to 10 days", text) + self.assertIn("from 8 to 24 days", text) + self.assertNotIn("2-month subscription", text) diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index ab44f22..3f6548a 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -141,7 +141,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "tariff_key": "standard", "tariff_name": "Standard", "months": 2, - "title": "Standard - 2 months", + "title": "2 months", "inviter_days": 5, "friend_days": 1, }, @@ -150,7 +150,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "tariff_key": "standard", "tariff_name": "Standard", "months": 4, - "title": "Standard - 4 months", + "title": "4 months", "inviter_days": 10, "friend_days": 2, }, @@ -159,7 +159,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "tariff_key": "standard", "tariff_name": "Standard", "months": 8, - "title": "Standard - 8 months", + "title": "8 months", "inviter_days": 0, "friend_days": 4, }, @@ -168,13 +168,78 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "tariff_key": "standard", "tariff_name": "Standard", "months": 16, - "title": "Standard - 16 months", + "title": "16 months", "inviter_days": 40, "friend_days": 0, }, ], ) + def test_referral_bonus_details_group_multiple_tariffs(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "tariffs.json" + path.write_text( + json.dumps( + { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {}, + "squad_uuids": ["standard"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"2": 400, "4": 800}, + "prices_stars": {}, + "referral_bonus_days_inviter": {"2": 5, "4": 10}, + "referral_bonus_days_referee": {"2": 1, "4": 2}, + "enabled_periods": [2, 4], + "enabled": True, + }, + { + "key": "premium", + "names": {"en": "Premium"}, + "descriptions": {}, + "squad_uuids": ["premium"], + "billing_model": "period", + "monthly_gb": 500, + "prices_rub": {"1": 700, "3": 1800}, + "prices_stars": {}, + "referral_bonus_days_inviter": {"1": 8, "3": 24}, + "referral_bonus_days_referee": {"1": 3, "3": 9}, + "enabled_periods": [1, 3], + "enabled": True, + }, + ], + } + ), + encoding="utf-8", + ) + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(path), + ) + + details = subscription_webapp._serialize_referral_bonus_details(settings, "en") + + self.assertEqual([item["type"] for item in details], ["tariff_summary", "tariff_summary"]) + self.assertEqual(details[0]["title"], "Standard") + self.assertEqual(details[0]["inviter_min_days"], 5) + self.assertEqual(details[0]["inviter_max_days"], 10) + self.assertEqual(details[0]["friend_min_days"], 1) + self.assertEqual(details[0]["friend_max_days"], 2) + self.assertEqual( + [item["title"] for item in details[0]["details"]], + ["2 months", "4 months"], + ) + self.assertEqual(details[1]["title"], "Premium") + self.assertEqual(details[1]["inviter_min_days"], 8) + self.assertEqual(details[1]["inviter_max_days"], 24) + def test_subscription_template_does_not_block_on_telegram_sdk(self): html = subscription_webapp.TEMPLATE_PATH.read_text(encoding="utf-8")