From 6f4074e8d6b88507f6bb4c6d9bababf6793ecd26 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 24 May 2026 18:48:01 +0300 Subject: [PATCH] feat: surface trial activation in mini app --- backend/bot/app/web/webapp/routes.py | 1 + .../bot/keyboards/inline/user_keyboards.py | 24 +- backend/bot/utils/mini_app_url.py | 5 + frontend/src/App.svelte | 54 +++- frontend/src/lib/webapp/constants.js | 1 + frontend/src/lib/webapp/mockApi.js | 93 +++++- frontend/src/lib/webapp/routes.js | 1 + .../screens/TrialActivationScreen.svelte | 278 ++++++++++++++++++ locales/en.json | 10 + locales/ru.json | 10 + tests/test_mini_app_url.py | 5 + tests/test_user_bot_menu.py | 40 +++ tests/test_webapp_route_contract.py | 1 + 13 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 frontend/src/webapp/screens/TrialActivationScreen.svelte diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index f6e171d..a5f77b3 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -7,6 +7,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/login/password", index_route) app.router.add_get("/home", index_route) app.router.add_get("/install", index_route) + app.router.add_get("/trial", index_route) app.router.add_get("/open-app", app_deeplink_route) app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route) app.router.add_get("/invite", index_route) diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 035eb20..8823072 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from bot.utils.install_links import bot_install_guide_url +from bot.utils.mini_app_url import subscription_mini_app_trial_url from config.settings import Settings BOT_MENU_CONTEXT = "bot" @@ -82,12 +83,29 @@ def payment_options_back_callback(sale_mode: str = "subscription") -> str: return subscription_options_callback(context) +def _trial_activation_button(lang: str, i18n_instance, settings: Settings) -> InlineKeyboardButton: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + if settings.SUBSCRIPTION_MINI_APP_URL: + trial_url = subscription_mini_app_trial_url(settings) or settings.SUBSCRIPTION_MINI_APP_URL + return InlineKeyboardButton( + text=_(key="menu_activate_trial_button"), + web_app=WebAppInfo(url=trial_url), + ) + return InlineKeyboardButton( + text=_(key="menu_activate_trial_button"), + callback_data="main_action:request_trial", + ) + + def get_main_menu_inline_keyboard( lang: str, i18n_instance, settings: Settings, show_trial_button: bool = False ) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + if show_trial_button and settings.TRIAL_ENABLED: + builder.row(_trial_activation_button(lang, i18n_instance, settings)) + if settings.SUBSCRIPTION_MINI_APP_URL: builder.row( InlineKeyboardButton( @@ -130,11 +148,7 @@ def get_bot_interface_inline_keyboard( builder = InlineKeyboardBuilder() if show_trial_button and settings.TRIAL_ENABLED: - builder.row( - InlineKeyboardButton( - text=_(key="menu_activate_trial_button"), callback_data="main_action:request_trial" - ) - ) + builder.row(_trial_activation_button(lang, i18n_instance, settings)) if settings.SUBSCRIPTION_MINI_APP_URL: builder.row( diff --git a/backend/bot/utils/mini_app_url.py b/backend/bot/utils/mini_app_url.py index 904001e..e6358a0 100644 --- a/backend/bot/utils/mini_app_url.py +++ b/backend/bot/utils/mini_app_url.py @@ -48,6 +48,11 @@ def subscription_mini_app_install_url(settings: Settings) -> Optional[str]: return subscription_mini_app_path_url(settings, "/install") +def subscription_mini_app_trial_url(settings: Settings) -> Optional[str]: + """Return the trial activation URL inside the Mini App.""" + return subscription_mini_app_path_url(settings, "/trial") + + def subscription_public_install_url(settings: Settings, share_token: str) -> Optional[str]: """Return the public install guide URL for a normalized share token.""" token = normalize_install_share_token(share_token) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index ec5f158..fb4cefe 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -22,6 +22,7 @@ import SettingsScreen from "./webapp/screens/SettingsScreen.svelte"; import SupportScreen from "./webapp/screens/SupportScreen.svelte"; import SupportTicketScreen from "./webapp/screens/SupportTicketScreen.svelte"; + import TrialActivationScreen from "./webapp/screens/TrialActivationScreen.svelte"; import { LANGUAGE_FLAGS, @@ -118,6 +119,8 @@ let publicInstallSubscription = null; let publicInstallToken = ""; let trialBusy = false; + let trialActivationResult = null; + let trialActivationError = ""; let promoCode = ""; let promoBusy = false; let promoStatus = ""; @@ -564,7 +567,7 @@ : section === "install" && !canUseInstallGuides() ? "home" : section; - activeTab = nextSection === "install" ? "home" : nextSection; + activeTab = nextSection === "install" || nextSection === "trial" ? "home" : nextSection; screen = nextSection; if (nextSection === "devices") devicesStore.loadDevices(devicesEnabled); if (nextSection === "support") { @@ -949,7 +952,12 @@ } const initialSupportTicketId = section === "support" ? supportTicketIdFromPath(window.location.pathname) : null; - activeTab = section === "admin" ? "settings" : section === "install" ? "home" : section; + activeTab = + section === "admin" + ? "settings" + : section === "install" || section === "trial" + ? "home" + : section; screen = section; mode = "app"; if (payload.settings?.support_tickets_enabled !== false) { @@ -1151,6 +1159,19 @@ openConnectLink(); } + function openTrialInstallOrConnect() { + if (canUseInstallGuides()) { + goInstall(); + return; + } + const url = trialActivationResult?.connect_url || trialActivationResult?.config_link; + if (url) { + openExternalLink(url); + return; + } + openConnectLink(); + } + async function copyText(value, success = t("wa_copied")) { if (!value) { showToast(t("wa_unavailable")); @@ -1199,19 +1220,30 @@ } } - async function activateTrial() { + async function activateTrial(options = {}) { if (trialBusy) return; + const stayOnTrial = Boolean(options?.stayOnTrial); trialBusy = true; + trialActivationResult = null; + trialActivationError = ""; try { const response = await api("/trial/activate", { method: "POST", body: JSON.stringify({}), }); if (!response.ok) throw response; + trialActivationResult = response; showToast(t("wa_trial_activated")); await loadData(); + if (stayOnTrial) { + activeTab = "home"; + screen = "trial"; + syncSectionPath("trial", true); + } } catch (error) { - showToast(error?.message || t("wa_trial_activation_failed")); + const message = error?.message || t("wa_trial_activation_failed"); + trialActivationError = message; + showToast(message); } finally { trialBusy = false; } @@ -1569,6 +1601,20 @@ {copyText} {t} /> + {:else if screen === "trial"} + {:else if screen === "invite"} ({ + key, + type, + section: "pricing", + subsection: "legacy_tariffs", + label: key, + value, + })), + ], + }, ], }; if (cleanPath === "/admin/support/stats") { @@ -713,13 +791,26 @@ export async function mockApi(path, options = {}, context = {}) { remaining_text: "5 д. 0 ч.", end_date_text: "05.05.2026 12:00", days_left: 5, + config_link: "https://sub.example.com/sub/trial-preview-token", + connect_url: "https://sub.example.com/connect/trial-preview-token", + panel_short_uuid: "trial-preview-token", + install_share_token: "8f559061460e8fede78ef18dce887236", + install_share_url: "https://app.example.com/s/8f559061460e8fede78ef18dce887236", traffic_limit: "10 GB", traffic_limit_bytes: 10737418240, traffic_used: "0 B", traffic_used_bytes: 0, }; DEV_MOCK.data.settings.trial_available = false; - return { ok: true, activated: true, end_date_text: "05.05.2026 12:00" }; + return { + ok: true, + activated: true, + days: 5, + end_date_text: "05.05.2026 12:00", + traffic_gb: 10, + config_link: "https://sub.example.com/sub/trial-preview-token", + connect_url: "https://sub.example.com/connect/trial-preview-token", + }; } if (path === "/auth/logout") return { ok: true }; if (path === "/account/language" && String(options.method || "").toUpperCase() === "POST") { diff --git a/frontend/src/lib/webapp/routes.js b/frontend/src/lib/webapp/routes.js index ec01ea1..0348a4e 100644 --- a/frontend/src/lib/webapp/routes.js +++ b/frontend/src/lib/webapp/routes.js @@ -7,6 +7,7 @@ export function normalizeSection(value) { if ( section === "invite" || section === "install" || + section === "trial" || section === "devices" || section === "support" || section === "settings" || diff --git a/frontend/src/webapp/screens/TrialActivationScreen.svelte b/frontend/src/webapp/screens/TrialActivationScreen.svelte new file mode 100644 index 0000000..c2be28b --- /dev/null +++ b/frontend/src/webapp/screens/TrialActivationScreen.svelte @@ -0,0 +1,278 @@ + + +
+ + + + + +
+ {#if trialBusy} +

{t("wa_trial_activation_loading", {}, "Activating trial...")}

+

{t("wa_trial_activation_wait", {}, "Preparing access and connection details.")}

+ {:else if hasActiveAccess} +

{successTitle}

+

+ {t( + "wa_trial_active_hint", + {}, + "Access is ready. Install the app and import the profile." + )} +

+
+ {#if endDateText} +
+
{t("wa_trial_active_until_label", {}, "Active until")}
+
{endDateText}
+
+ {/if} + {#if daysLeft > 0} +
+
{t("wa_trial_days_left_label", {}, "Time left")}
+
{t("wa_trial_days_left", { days: daysLeft }, "{days} days")}
+
+ {/if} +
+
{t("wa_trial_traffic_label", {}, "Traffic")}
+
{trafficLabel}
+
+
+ {:else if trialError} +

{t("wa_trial_activation_failed")}

+

{trialError}

+ {:else} +

{t("wa_trial_unavailable_title", {}, "Trial is unavailable")}

+

+ {t( + "wa_trial_unavailable_hint", + {}, + "Trial may already be used, or this account already has active access." + )} +

+ {/if} +
+
+ +
+ {#if hasActiveAccess} + + {:else if trialError && canRequestTrial} + + {/if} + +
+
+ + diff --git a/locales/en.json b/locales/en.json index 0f6199d..0c727fb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -749,6 +749,16 @@ "wa_trial_details": "{days} days, {traffic}", "wa_trial_activated": "Trial activated", "wa_trial_activation_failed": "Failed to activate 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.", + "wa_trial_active_until_label": "Active until", + "wa_trial_days_left_label": "Time left", + "wa_trial_days_left": "{days} days", + "wa_trial_traffic_label": "Traffic", + "wa_trial_unavailable_title": "Trial is unavailable", + "wa_trial_unavailable_hint": "Trial may already be used, or this account already has active access.", + "wa_trial_retry": "Try again", "wa_install_and_configure": "Install and configure", "wa_payment_methods_not_configured": "Payment methods are not configured yet", "wa_pay": "Pay", diff --git a/locales/ru.json b/locales/ru.json index 94dc920..5fccd7a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -749,6 +749,16 @@ "wa_trial_details": "{days} дн., {traffic}", "wa_trial_activated": "Пробный период активирован", "wa_trial_activation_failed": "Не удалось активировать пробный период", + "wa_trial_activation_loading": "Активируем пробный период...", + "wa_trial_activation_wait": "Готовим доступ и данные для подключения.", + "wa_trial_active_hint": "Доступ готов. Установите приложение и импортируйте профиль.", + "wa_trial_active_until_label": "Действует до", + "wa_trial_days_left_label": "Осталось", + "wa_trial_days_left": "{days} дн.", + "wa_trial_traffic_label": "Трафик", + "wa_trial_unavailable_title": "Пробный период недоступен", + "wa_trial_unavailable_hint": "Он уже использован или для аккаунта уже есть активный доступ.", + "wa_trial_retry": "Попробовать еще раз", "wa_install_and_configure": "Установить и настроить", "wa_payment_methods_not_configured": "Способы оплаты пока не настроены", "wa_pay": "Оплатить", diff --git a/tests/test_mini_app_url.py b/tests/test_mini_app_url.py index 1f551f4..b137fdf 100644 --- a/tests/test_mini_app_url.py +++ b/tests/test_mini_app_url.py @@ -5,6 +5,7 @@ from bot.utils.mini_app_url import ( subscription_mini_app_install_url, subscription_mini_app_path_url, subscription_mini_app_topup_url, + subscription_mini_app_trial_url, subscription_public_install_url, ) from config.settings import Settings @@ -62,6 +63,10 @@ class MiniAppUrlTests(unittest.TestCase): subscription_mini_app_install_url(s), "https://app.example.com/webapp/install", ) + self.assertEqual( + subscription_mini_app_trial_url(s), + "https://app.example.com/webapp/trial", + ) def test_subscription_public_install_url_uses_origin(self): s = Settings( diff --git a/tests/test_user_bot_menu.py b/tests/test_user_bot_menu.py index f5e6ebb..0f50f84 100644 --- a/tests/test_user_bot_menu.py +++ b/tests/test_user_bot_menu.py @@ -67,6 +67,46 @@ class UserBotMenuTests(unittest.TestCase): self.assertIn("main_action:bot_interface", callbacks) self.assertIn("main_action:info", callbacks) + def test_main_menu_shows_trial_button_as_mini_app_deeplink_when_available(self): + markup = get_main_menu_inline_keyboard( + "en", + self.i18n, + self.settings, + show_trial_button=True, + ) + + trial_button = markup.inline_keyboard[0][0] + + self.assertEqual(trial_button.text, self.i18n.gettext("en", "menu_activate_trial_button")) + self.assertIsNone(trial_button.callback_data) + self.assertEqual(trial_button.web_app.url, "https://app.example.com/trial") + + def test_main_menu_trial_button_falls_back_to_bot_callback_without_mini_app(self): + self.settings.SUBSCRIPTION_MINI_APP_URL = "" + markup = get_main_menu_inline_keyboard( + "en", + self.i18n, + self.settings, + show_trial_button=True, + ) + + trial_button = markup.inline_keyboard[0][0] + + self.assertEqual(trial_button.callback_data, "main_action:request_trial") + self.assertIsNone(trial_button.web_app) + + def test_bot_interface_trial_button_uses_mini_app_deeplink_when_available(self): + markup = get_bot_interface_inline_keyboard( + "en", + self.i18n, + self.settings, + show_trial_button=True, + ) + + trial_button = markup.inline_keyboard[0][0] + + self.assertEqual(trial_button.web_app.url, "https://app.example.com/trial") + def test_bot_interface_buttons_return_to_bot_interface(self): markup = get_bot_interface_inline_keyboard("en", self.i18n, self.settings) diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index ef298a5..b48203b 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -75,6 +75,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/login/password"): "index_route", ("GET", "/home"): "index_route", ("GET", "/install"): "index_route", + ("GET", "/trial"): "index_route", ("GET", "/open-app"): "app_deeplink_route", ("GET", "/s/{share_token}"): "index_route", ("GET", "/invite"): "index_route",