From 89a9f9f70c5910916097818696711cb99ae19bcc Mon Sep 17 00:00:00 2001 From: Dmitry Lunin Date: Fri, 31 Jul 2026 01:29:16 +0300 Subject: [PATCH 01/26] feat: add sort by subscription expiry to user list --- src/locales/en.json | 3 ++- src/locales/fa.json | 3 ++- src/locales/ru.json | 3 ++- src/locales/zh.json | 3 ++- src/pages/AdminUsers.tsx | 1 + 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index d29d33f..adf89c0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3449,7 +3449,8 @@ "byDate": "By date", "byBalance": "By balance", "byActivity": "By activity", - "bySpent": "By spending" + "bySpent": "By spending", + "byExpiry": "By expiry" }, "pagination": { "showing": "Showing {{from}}-{{to}} of {{total}}" diff --git a/src/locales/fa.json b/src/locales/fa.json index eebca41..59410af 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2956,7 +2956,8 @@ "byDate": "بر اساس تاریخ", "byBalance": "بر اساس موجودی", "byActivity": "بر اساس فعالیت", - "bySpent": "بر اساس هزینه" + "bySpent": "بر اساس هزینه", + "byExpiry": "بر اساس انقضا" }, "pagination": { "showing": "نمایش {{from}}-{{to}} از {{total}}" diff --git a/src/locales/ru.json b/src/locales/ru.json index 38614c1..dbeaf00 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3846,7 +3846,8 @@ "byDate": "По дате", "byBalance": "По балансу", "byActivity": "По активности", - "bySpent": "По расходам" + "bySpent": "По расходам", + "byExpiry": "По истечению подписки" }, "pagination": { "showing": "Показано {{from}}-{{to}} из {{total}}" diff --git a/src/locales/zh.json b/src/locales/zh.json index 09f525e..61591ea 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2955,7 +2955,8 @@ "byDate": "按日期", "byBalance": "按余额", "byActivity": "按活跃度", - "bySpent": "按消费" + "bySpent": "按消费", + "byExpiry": "按到期" }, "pagination": { "showing": "显示 {{from}}-{{to}},共 {{total}}" diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx index 315dd3b..d2a2382 100644 --- a/src/pages/AdminUsers.tsx +++ b/src/pages/AdminUsers.tsx @@ -288,6 +288,7 @@ export default function AdminUsers() { + From d03b9bdad785ac7710f30527f230eeb793e1089b Mon Sep 17 00:00:00 2001 From: "Artem (OpenIN)" Date: Mon, 3 Aug 2026 17:46:28 +0000 Subject: [PATCH 02/26] feat(auth): allow manual opt-in for deep-link Telegram login Currently the deep-link auth flow (t.me/{bot}?start=webauth_{token}) is only triggered automatically as a fallback when the Telegram widget script (oauth.telegram.org or telegram.org/js/telegram-widget.js) fails to load. Users on unaffected networks have no way to choose this login method even when they'd prefer confirming in the bot over typing a phone number into the widget popup. This adds a small 'Login via bot' link next to the existing widget, reusing the exact same startDeepLinkAuth/poll logic already used by the automatic fallback. No changes to the fallback behavior itself. --- src/components/TelegramLoginButton.tsx | 50 ++++++++++++++++++++++---- src/locales/en.json | 3 ++ src/locales/ru.json | 3 ++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 15dc748..e388376 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -26,6 +26,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const [oidcError, setOidcError] = useState(''); const [scriptLoaded, setScriptLoaded] = useState(false); const [scriptFailed, setScriptFailed] = useState(false); + // Lets the user opt into deep-link auth manually, without waiting for the + // Telegram widget script to fail. See #. + const [manualDeepLink, setManualDeepLink] = useState(false); + const showDeepLinkUI = scriptFailed || manualDeepLink; const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC); // Deep link auth state @@ -336,9 +340,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } }, [botUsername, loginWithDeepLink, navigate, t]); - // Auto-start deep link auth when script fails (with cancellation for Strict Mode) + // Auto-start deep link auth when script fails OR the user opts in manually + // (with cancellation for Strict Mode) useEffect(() => { - if (scriptFailed && !deepLinkToken && !deepLinkPolling) { + if (showDeepLinkUI && !deepLinkToken && !deepLinkPolling) { let cancelled = false; const start = async () => { if (!cancelled) await startDeepLinkAuth(); @@ -348,7 +353,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto cancelled = true; }; } - }, [scriptFailed, deepLinkToken, deepLinkPolling, startDeepLinkAuth]); + }, [showDeepLinkUI, deepLinkToken, deepLinkPolling, startDeepLinkAuth]); // Resume polling immediately when user returns to the page (e.g. after confirming in Telegram) // Browsers throttle setTimeout in background tabs, so polling may have stalled. @@ -431,8 +436,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto ); } - // Deep link fallback UI - if (scriptFailed) { + // Deep link UI — shown either as an automatic fallback (widget script + // failed to load) or because the user explicitly chose this method. + if (showDeepLinkUI) { const resolvedBotUsername = deepLinkBotUsername || botUsername; const deepLinkUrl = deepLinkToken ? `https://t.me/${resolvedBotUsername}?start=webauth_${deepLinkToken}` @@ -443,7 +449,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
{/* Info message */}

- {t('auth.telegramWidgetBlocked')} + {t(scriptFailed ? 'auth.telegramWidgetBlocked' : 'auth.deepLinkIntro')}

{deepLinkToken && deepLinkUrl ? ( @@ -517,6 +523,27 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto {t('common.loading')}
)} + + {/* Only offer a way back if the widget actually works — if the + script failed there is nothing to go back to. */} + {!scriptFailed && ( + + )} ); } @@ -569,6 +596,17 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto @{botUsername} + + {/* Manual opt-in: same deep-link flow used as the anti-block fallback, + offered here as an explicit alternative for users who'd rather + confirm in the bot than type a phone number into the widget. */} + ); } diff --git a/src/locales/en.json b/src/locales/en.json index b07b36d..8bd981e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -207,6 +207,9 @@ "orOpenInApp": "Or open the bot in the app", "loginFailed": "Login Failed", "telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:", + "deepLinkIntro": "Confirm sign-in right in the bot — no phone number needed:", + "loginWithBot": "Login via bot (no phone number)", + "backToWidget": "Back to widget login", "openBotToLogin": "Open bot to sign in", "waitingForConfirmation": "Waiting for confirmation...", "deepLinkExpired": "Link expired. Please try again.", diff --git a/src/locales/ru.json b/src/locales/ru.json index 080e88f..971a829 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -210,6 +210,9 @@ "orOpenInApp": "Или откройте бота в приложении", "loginFailed": "Ошибка входа", "telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:", + "deepLinkIntro": "Подтвердите вход прямо в боте — без ввода номера телефона:", + "loginWithBot": "Войти через бота (без номера телефона)", + "backToWidget": "Назад к входу через виджет", "openBotToLogin": "Открыть бота для входа", "waitingForConfirmation": "Ожидание подтверждения...", "deepLinkExpired": "Ссылка истекла. Попробуйте снова.", From 75424dba473925b99d993d0e52f33067f14125ce Mon Sep 17 00:00:00 2001 From: "Artem (OpenIN)" Date: Mon, 3 Aug 2026 18:23:16 +0000 Subject: [PATCH 03/26] refactor(auth): consolidate three Telegram entry points into two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the passive '@bot_username' link (opens the bot chat with no auth purpose) for the common case — it's now redundant with the new 'Login via bot' button, which offers the same open-bot action plus QR and actual authentication. - Keep the referral deep link (bot start=) only when a referralCode prop is present — that's a distinct registration flow for not-yet-registered users, not a login method. - Add an 'or' divider between the widget and the manual deep-link button so the two remaining options read as equal alternatives rather than a stack of similar-looking Telegram links. - Shorten the deep-link button label to 'Login via bot' — the no-phone-number framing is already implied and shown once the flow starts. No changes to the OIDC/widget button logic itself. --- src/components/TelegramLoginButton.tsx | 36 +++++++++++++++----------- src/locales/en.json | 2 +- src/locales/ru.json | 2 +- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index e388376..05d79de 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -578,33 +578,39 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
)} -
-

{t('auth.orOpenInApp')}

+ {/* Referral deep link — only relevant for not-yet-registered users who + arrived via a referral link; the bot itself handles registering + them with the code attached. Hidden otherwise to avoid a third, + visually-identical "Telegram" entry point next to the two auth + methods below. */} + {referralCode && ( - - - - @{botUsername} + {t('auth.orOpenInApp')} @{botUsername} + )} + +
+
+ {t('common.or')} +
{/* Manual opt-in: same deep-link flow used as the anti-block fallback, - offered here as an explicit alternative for users who'd rather - confirm in the bot than type a phone number into the widget. */} + offered here as an explicit equal alternative to the widget for + users who'd rather confirm in the bot than type a phone number. */}
diff --git a/src/locales/en.json b/src/locales/en.json index 8bd981e..80e72e4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -208,7 +208,7 @@ "loginFailed": "Login Failed", "telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:", "deepLinkIntro": "Confirm sign-in right in the bot — no phone number needed:", - "loginWithBot": "Login via bot (no phone number)", + "loginWithBot": "Login via bot", "backToWidget": "Back to widget login", "openBotToLogin": "Open bot to sign in", "waitingForConfirmation": "Waiting for confirmation...", diff --git a/src/locales/ru.json b/src/locales/ru.json index 971a829..2feebe2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -211,7 +211,7 @@ "loginFailed": "Ошибка входа", "telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:", "deepLinkIntro": "Подтвердите вход прямо в боте — без ввода номера телефона:", - "loginWithBot": "Войти через бота (без номера телефона)", + "loginWithBot": "Войти через бота", "backToWidget": "Назад к входу через виджет", "openBotToLogin": "Открыть бота для входа", "waitingForConfirmation": "Ожидание подтверждения...", From eafc563128b68f49f0a1f03a30c899b527ce4734 Mon Sep 17 00:00:00 2001 From: Case211 <87642841+Case211@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:38:20 +0500 Subject: [PATCH 04/26] =?UTF-8?q?feat(users):=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В мультитарифном режиме у пользователя несколько подписок, и убрать лишнюю — например, отработавший триал — можно было только через экран «Массовые действия». Во вкладке «Подписка» карточки подписки видны и открываются, но удалить выбранную нечем. Кнопка живёт в детальном виде подписки и требует подтверждения тем же inline-механизмом, что и отмена автоплатежа; ключ подтверждения включает id подписки, чтобы взведённое согласие не пережило переключение на соседнюю. Активная платная подписка на сервере защищена от случайного удаления, поэтому для неё запрос идёт с явным force — намерение админ уже подтвердил. --- src/api/adminUsers.ts | 13 +++++++ .../admin/userDetail/SubscriptionTab.tsx | 37 +++++++++++++++++++ src/locales/en.json | 6 ++- src/locales/fa.json | 6 ++- src/locales/ru.json | 6 ++- src/locales/zh.json | 6 ++- src/pages/AdminUserDetail.tsx | 19 ++++++++++ 7 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index abd4863..4df5c37 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -518,6 +518,19 @@ export const adminUsersApi = { return response.data; }, + // Delete one of the user's subscriptions (multi-tariff: trials pile up) + deleteSubscription: async ( + userId: number, + subId: number, + force = false, + ): Promise<{ status: string }> => { + const response = await apiClient.delete( + `/cabinet/admin/users/${userId}/subscriptions/${subId}`, + { params: force ? { force: true } : undefined }, + ); + return response.data; + }, + // Update status updateStatus: async ( userId: number, diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx index a8b83c8..40e66de 100644 --- a/src/components/admin/userDetail/SubscriptionTab.tsx +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -131,6 +131,7 @@ export interface SubscriptionTabProps { onRemoveTraffic: (purchaseId: number) => Promise; onResetDevices: () => Promise; onCancelSbpRecurring: () => Promise; + onDeleteSubscription: () => Promise; onDeleteDevice: (hwid: string) => Promise; onRenameDevice: (hwid: string) => Promise; onLoadDevices: () => Promise; @@ -195,6 +196,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { onRemoveTraffic, onResetDevices, onCancelSbpRecurring, + onDeleteSubscription, onDeleteDevice, onRenameDevice, onLoadDevices, @@ -429,6 +431,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
)} + {/* Delete this subscription — in multi-tariff mode spent trials + pile up in the card, and removing one used to be possible + only through the bulk-actions screen. */} + {hasPermission('users:subscription') && ( +
+
+
+
+ {t('admin.users.detail.subscription.deleteTitle')} +
+
+ {t('admin.users.detail.subscription.deleteHint')} +
+
+ +
+
+ )} + {/* Traffic Packages */} {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
diff --git a/src/locales/en.json b/src/locales/en.json index b07b36d..133b0af 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3646,7 +3646,11 @@ "sbpCancelled": "SBP auto-payment disabled", "sbpStatus_PENDING": "Pending", "sbpStatus_ACTIVE": "Active", - "sbpStatus_PAST_DUE": "Payment failed" + "sbpStatus_PAST_DUE": "Payment failed", + "deleteTitle": "Delete subscription", + "deleteHint": "The subscription and its devices will be removed permanently", + "deleteButton": "Delete subscription", + "deleted": "Subscription deleted" }, "balance": { "current": "Current balance", diff --git a/src/locales/fa.json b/src/locales/fa.json index cbbb785..f343f7d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3105,7 +3105,11 @@ "sbpCancelled": "پرداخت خودکار SBP غیرفعال شد", "sbpStatus_PENDING": "در انتظار", "sbpStatus_ACTIVE": "فعال", - "sbpStatus_PAST_DUE": "پرداخت ناموفق" + "sbpStatus_PAST_DUE": "پرداخت ناموفق", + "deleteTitle": "حذف اشتراک", + "deleteHint": "اشتراک و دستگاه‌های آن برای همیشه حذف می‌شوند", + "deleteButton": "حذف اشتراک", + "deleted": "اشتراک حذف شد" }, "balance": { "current": "موجودی فعلی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 080e88f..cb13ebb 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4043,7 +4043,11 @@ "sbpCancelled": "Автоплатёж по СБП отключён", "sbpStatus_PENDING": "Ожидает подтверждения", "sbpStatus_ACTIVE": "Активен", - "sbpStatus_PAST_DUE": "Платёж не прошёл" + "sbpStatus_PAST_DUE": "Платёж не прошёл", + "deleteTitle": "Удаление подписки", + "deleteHint": "Подписка и её устройства будут удалены безвозвратно", + "deleteButton": "Удалить подписку", + "deleted": "Подписка удалена" }, "balance": { "current": "Текущий баланс", diff --git a/src/locales/zh.json b/src/locales/zh.json index 49b202d..797a62e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3104,7 +3104,11 @@ "sbpCancelled": "SBP 自动扣款已关闭", "sbpStatus_PENDING": "待确认", "sbpStatus_ACTIVE": "已启用", - "sbpStatus_PAST_DUE": "扣款失败" + "sbpStatus_PAST_DUE": "扣款失败", + "deleteTitle": "删除订阅", + "deleteHint": "订阅及其设备将被永久删除", + "deleteButton": "删除订阅", + "deleted": "订阅已删除" }, "balance": { "current": "当前余额", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index db7abb9..7902e06 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -662,6 +662,24 @@ export default function AdminUserDetail() { } }; + const handleDeleteSubscription = async () => { + if (!userId || !selectedSub) return; + setActionLoading(true); + try { + // Активную платную подписку сервер по умолчанию бережёт — админ уже + // подтвердил намерение кнопкой, поэтому просим удалить именно её. + const force = Boolean(selectedSub.is_active) && !selectedSub.is_trial; + await adminUsersApi.deleteSubscription(userId, selectedSub.id, force); + notify.success(t('admin.users.detail.subscription.deleted'), t('common.success')); + setSubscriptionDetailView(false); + await loadUser(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + const handleDisableUser = async () => { if (!userId) return; setActionLoading(true); @@ -874,6 +892,7 @@ export default function AdminUserDetail() { userSubscriptions={userSubscriptions} selectedSub={selectedSub} onCancelSbpRecurring={handleCancelSbpRecurring} + onDeleteSubscription={handleDeleteSubscription} activeSubscriptionId={activeSubscriptionId} onActiveSubscriptionChange={setActiveSubscriptionId} subscriptionDetailView={subscriptionDetailView} From f6a64bfc1eb885cde31bcbab1f1474b1d693e718 Mon Sep 17 00:00:00 2001 From: Case211 <87642841+Case211@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:02:29 +0500 Subject: [PATCH 05/26] =?UTF-8?q?feat(promocodes):=20=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=20=D0=B2=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B5=20=D0=B1=D0=BE=D0=BD=D1=83=D1=81=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=D0=BE=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Набор собирался из баланса, дней и промогруппы. Трафик добавляется четвёртой галочкой: подписка у пользователя уже есть, и гигабайты начисляются к ней без смены тарифа. Включённый трафик переводит код в тип «набор бонусов» — на бэкенде трафик живёт только там. Валидация требует положительный объём, как у суммы и дней; при редактировании галочка поднимается сама, если у кода уже задан трафик. --- src/api/promocodes.ts | 4 ++++ src/locales/en.json | 8 +++++-- src/locales/fa.json | 8 +++++-- src/locales/ru.json | 8 +++++-- src/locales/zh.json | 8 +++++-- src/pages/AdminPromocodeCreate.tsx | 36 ++++++++++++++++++++++++++++-- 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index e459f1e..e162937 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -17,6 +17,8 @@ export interface PromoCode { balance_bonus_kopeks: number; balance_bonus_rubles: number; subscription_days: number; + /** Гигабайты к подписке — третья составляющая набора бонусов. */ + traffic_gb: number; max_uses: number; current_uses: number; uses_left: number; @@ -60,6 +62,7 @@ export interface PromoCodeCreateRequest { type: PromoCodeType; balance_bonus_kopeks?: number; subscription_days?: number; + traffic_gb?: number; max_uses?: number; valid_from?: string; valid_until?: string | null; @@ -74,6 +77,7 @@ export interface PromoCodeUpdateRequest { type?: PromoCodeType; balance_bonus_kopeks?: number; subscription_days?: number; + traffic_gb?: number; max_uses?: number; valid_from?: string; valid_until?: string | null; diff --git a/src/locales/en.json b/src/locales/en.json index b07b36d..2456821 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3300,7 +3300,10 @@ "save": "Save", "defaultTrialTariff": "Default trial tariff", "selectTariff": "Select tariff", - "tariff": "Tariff" + "tariff": "Tariff", + "includeTraffic": "Traffic", + "trafficAmount": "Traffic amount", + "gb": "GB" }, "stats": { "title": "Promo code statistics", @@ -3374,7 +3377,8 @@ "daysRequired": "Number of days must be greater than 0", "groupRequired": "Select a discount group", "discountPercentInvalid": "Discount percent must be between 1 and 100", - "discountHoursRequired": "Specify the discount validity period in hours" + "discountHoursRequired": "Specify the discount validity period in hours", + "trafficRequired": "Traffic amount must be greater than 0" } }, "promoGroups": { diff --git a/src/locales/fa.json b/src/locales/fa.json index cbbb785..bcd755b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2810,7 +2810,10 @@ "save": "ذخیره", "defaultTrialTariff": "تعرفه پیش‌فرض آزمایشی", "selectTariff": "تعرفه را انتخاب کنید", - "tariff": "تعرفه" + "tariff": "تعرفه", + "includeTraffic": "ترافیک", + "trafficAmount": "حجم ترافیک", + "gb": "گیگابایت" }, "stats": { "title": "آمار کد تخفیف", @@ -2882,7 +2885,8 @@ "daysRequired": "تعداد روزها باید بیشتر از 0 باشد", "groupRequired": "یک گروه تخفیف انتخاب کنید", "discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد", - "discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید" + "discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید", + "trafficRequired": "حجم ترافیک باید بیشتر از ۰ باشد" } }, "promoGroups": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 080e88f..d6e166d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3694,7 +3694,10 @@ "save": "Сохранить", "defaultTrialTariff": "Тариф триала по умолчанию", "selectTariff": "Выберите тариф", - "tariff": "Тариф" + "tariff": "Тариф", + "includeTraffic": "Трафик", + "trafficAmount": "Объём трафика", + "gb": "ГБ" }, "stats": { "title": "Статистика промокода", @@ -3770,7 +3773,8 @@ "daysRequired": "Количество дней должно быть больше 0", "groupRequired": "Выберите группу скидок", "discountPercentInvalid": "Процент скидки должен быть от 1 до 100", - "discountHoursRequired": "Укажите время действия скидки в часах" + "discountHoursRequired": "Укажите время действия скидки в часах", + "trafficRequired": "Объём трафика должен быть больше 0" } }, "promoGroups": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 49b202d..db3ca90 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2809,7 +2809,10 @@ "save": "保存", "defaultTrialTariff": "默认试用套餐", "selectTariff": "选择套餐", - "tariff": "套餐" + "tariff": "套餐", + "includeTraffic": "流量", + "trafficAmount": "流量额度", + "gb": "GB" }, "stats": { "title": "促销码统计", @@ -2881,7 +2884,8 @@ "daysRequired": "天数必须大于0", "groupRequired": "请选择折扣组", "discountPercentInvalid": "折扣百分比必须在1到100之间", - "discountHoursRequired": "请指定折扣有效期(小时)" + "discountHoursRequired": "请指定折扣有效期(小时)", + "trafficRequired": "流量额度必须大于 0" } }, "promoGroups": { diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx index f9975c3..f24c39a 100644 --- a/src/pages/AdminPromocodeCreate.tsx +++ b/src/pages/AdminPromocodeCreate.tsx @@ -44,6 +44,8 @@ export default function AdminPromocodeCreate() { const [includeBalance, setIncludeBalance] = useState(true); const [includeDays, setIncludeDays] = useState(false); const [includeGroup, setIncludeGroup] = useState(false); + const [includeTraffic, setIncludeTraffic] = useState(false); + const [trafficGb, setTrafficGb] = useState(0); const [balanceBonusRubles, setBalanceBonusRubles] = useState(0); const [subscriptionDays, setSubscriptionDays] = useState(0); const [maxUses, setMaxUses] = useState(1); @@ -99,6 +101,8 @@ export default function AdminPromocodeCreate() { setBalanceBonusRubles(data.balance_bonus_rubles || 0); } setSubscriptionDays(data.subscription_days || 0); + setTrafficGb(data.traffic_gb || 0); + setIncludeTraffic((data.traffic_gb || 0) > 0); setMaxUses(data.max_uses || 1); setIsActive(data.is_active ?? true); setFirstPurchaseOnly(data.first_purchase_only || false); @@ -133,7 +137,7 @@ export default function AdminPromocodeCreate() { // едет через promo_group_id при любом типе (бэкенд применяет её независимо). const derivedType: PromoCodeType = mode === 'bonus_set' - ? includeBalance && includeDays + ? includeTraffic || (includeBalance && includeDays) ? 'balance_and_days' : includeBalance ? 'balance' @@ -148,6 +152,7 @@ export default function AdminPromocodeCreate() { const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles; const daysValue = subscriptionDays === '' ? 0 : subscriptionDays; const maxUsesValue = maxUses === '' ? 0 : maxUses; + const trafficValue = trafficGb === '' ? 0 : trafficGb; const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = { code: code.trim().toUpperCase(), @@ -164,6 +169,7 @@ export default function AdminPromocodeCreate() { (mode === 'bonus_set' && includeDays) ? daysValue : 0, + traffic_gb: mode === 'bonus_set' && includeTraffic ? trafficValue : 0, max_uses: maxUsesValue, is_active: isActive, first_purchase_only: firstPurchaseOnly, @@ -197,7 +203,7 @@ export default function AdminPromocodeCreate() { validationErrors.push('codeRequired'); } if (mode === 'bonus_set') { - if (!includeBalance && !includeDays && !includeGroup) { + if (!includeBalance && !includeDays && !includeGroup && !includeTraffic) { validationErrors.push('bonusSetEmpty'); } if (includeBalance && balanceValue <= 0) { @@ -209,6 +215,9 @@ export default function AdminPromocodeCreate() { if (includeGroup && !promoGroupId) { validationErrors.push('groupRequired'); } + if (includeTraffic && (trafficGb === '' || trafficGb <= 0)) { + validationErrors.push('trafficRequired'); + } } if (mode === 'trial_subscription' && daysValue <= 0) { validationErrors.push('daysRequired'); @@ -308,6 +317,7 @@ export default function AdminPromocodeCreate() { [ ['includeBalance', includeBalance, setIncludeBalance] as const, ['includeDays', includeDays, setIncludeDays] as const, + ['includeTraffic', includeTraffic, setIncludeTraffic] as const, ['includePromoGroup', includeGroup, setIncludeGroup] as const, ] as const ).map(([key, checked, setChecked]) => ( @@ -361,6 +371,28 @@ export default function AdminPromocodeCreate() {
)} + {mode === 'bonus_set' && includeTraffic && ( +
+ +
+ + {t('admin.promocodes.form.gb')} +
+
+ )} + {(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
{msg.message_text && (

)} diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 40ebf7a..b5640eb 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -600,7 +600,7 @@ export default function Support() {

{msg.message_text && (
)} From 8e9bb0dc23e2f963673a25a494d58d549327a759 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 17 Aug 2026 21:23:35 +0300 Subject: [PATCH 16/26] =?UTF-8?q?test(landing):=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=B8=D1=82=D1=8C=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D1=83=20=D1=81=D0=BB=D0=B0=D0=B3=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=B3=D0=BE=D1=81=D1=82=D0=B5=D0=B2=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BF=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Покрытия у слага не было вовсе. Пиним два инварианта: слаг попадает в тело запроса покупки и читается БЕЗ потребления — очистка на этом шаге лишила бы привязки того же клиента, если он позже войдёт в кабинет. Компоненты в репозитории не рендерятся (vitest на node, без jsdom), поэтому отправка фиксируется по исходнику; поведение самих хелперов проверяется обычными тестами с подменённым localStorage. --- src/utils/campaign.test.ts | 91 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/utils/campaign.test.ts diff --git a/src/utils/campaign.test.ts b/src/utils/campaign.test.ts new file mode 100644 index 0000000..c4cfb35 --- /dev/null +++ b/src/utils/campaign.test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { consumeCampaignSlug, getPendingCampaignSlug } from './campaign'; + +const CAMPAIGN_KEY = 'campaign_slug'; +const CAMPAIGN_TTL_KEY = 'campaign_slug_ttl'; +const HOUR = 60 * 60 * 1000; + +function fakeLocalStorage(): Storage { + const store = new Map(); + return { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => store.get(key) ?? null, + key: (index: number) => [...store.keys()][index] ?? null, + removeItem: (key: string) => void store.delete(key), + setItem: (key: string, value: string) => void store.set(key, value), + } as Storage; +} + +function storeSlug(slug: string, expiresInMs = 24 * HOUR): void { + localStorage.setItem(CAMPAIGN_KEY, slug); + localStorage.setItem(CAMPAIGN_TTL_KEY, String(Date.now() + expiresInMs)); +} + +beforeEach(() => { + vi.stubGlobal('localStorage', fakeLocalStorage()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('getPendingCampaignSlug', () => { + it('returns the stored slug', () => { + storeSlug('summer_sale'); + + expect(getPendingCampaignSlug()).toBe('summer_sale'); + }); + + // Гостевая покупка на лендинге читает слаг этим геттером. Если он начнёт + // очищать хранилище, тот же клиент, зайдя потом в кабинет, потеряет + // привязку к кампании — auth-флоу уже ничего не найдёт. + it('does not consume the slug', () => { + storeSlug('summer_sale'); + + getPendingCampaignSlug(); + + expect(getPendingCampaignSlug()).toBe('summer_sale'); + expect(localStorage.getItem(CAMPAIGN_KEY)).toBe('summer_sale'); + }); + + it('drops an expired slug', () => { + storeSlug('summer_sale', -HOUR); + + expect(getPendingCampaignSlug()).toBeNull(); + expect(localStorage.getItem(CAMPAIGN_TTL_KEY)).toBeNull(); + }); + + it('returns null when nothing was captured', () => { + expect(getPendingCampaignSlug()).toBeNull(); + }); +}); + +describe('consumeCampaignSlug', () => { + it('clears the slug after reading it once', () => { + storeSlug('summer_sale'); + + expect(consumeCampaignSlug()).toBe('summer_sale'); + expect(consumeCampaignSlug()).toBeNull(); + }); +}); + +// Компоненты в этом репозитории не рендерятся в тестах (vitest на node, без +// jsdom), поэтому отправку слага фиксируем по исходнику: без неё покупка +// гостем не попадает в статистику кампании и не даёт её бонус. +describe('QuickPurchase source', () => { + const source = readFileSync(new URL('../pages/QuickPurchase.tsx', import.meta.url), 'utf8'); + + it('puts the campaign slug into the purchase payload', () => { + expect(source).toContain('data.campaign_slug = campaignSlug'); + }); + + it('reads the slug without consuming it', () => { + expect(source).toContain('getPendingCampaignSlug()'); + expect(source).not.toContain('consumeCampaignSlug'); + }); +}); From 714ece82508533b076eeecdb4a503a8600186d63 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 17 Aug 2026 21:54:25 +0300 Subject: [PATCH 17/26] =?UTF-8?q?test(tickets):=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=20=D0=B4=D0=BB=D0=B8=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D0=B2=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правка презентационная и покрытия не имела: класс break-words легко потерять при переборе классов, а симптом невидим — контейнер сообщений скроллится по горизонтали, но полосу прячет .scrollbar-hide. Замер в браузере на ширине 1280px подтверждает цену потери: без break-words контейнер 803px против содержимого 3179px, то есть 2376px текста недостижимы. Класс проверяется рядом с самим выводом тела сообщения во всех трёх рендерерах. --- src/utils/ticketMessageWrap.test.ts | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/utils/ticketMessageWrap.test.ts diff --git a/src/utils/ticketMessageWrap.test.ts b/src/utils/ticketMessageWrap.test.ts new file mode 100644 index 0000000..ceb1800 --- /dev/null +++ b/src/utils/ticketMessageWrap.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +/** + * Тело сообщения тикета рендерится в трёх местах, и во всех трёх текст должен + * переноситься по любому символу. + * + * `whitespace-pre-wrap` переносит только по пробелам, поэтому неразрывный токен + * — ссылка на подписку, ключ, base64 — уезжает за пузырь. Заметить это нельзя: + * контейнер сообщений идёт с `overflow-y-auto` (браузер добирает `overflow-x` + * в `auto`), а полосу прокрутки прячет `.scrollbar-hide`. Замер в браузере на + * ширине 1280px: без `break-words` контейнер 803px против содержимого 3179px, + * то есть 2376px текста недостижимы ни колесом, ни глазом. + * + * Компоненты здесь не рендерятся (vitest на node, без jsdom), поэтому класс + * проверяется по исходнику — рядом с тем самым `linkifyText(msg.message_text)`, + * а не где угодно в файле. + */ + +const RENDERERS = [ + 'src/pages/Support.tsx', + 'src/pages/AdminTickets.tsx', + 'src/components/admin/userDetail/TicketsTab.tsx', +]; + +// className="..." непосредственно перед выводом тела сообщения +const MESSAGE_BODY_RE = + /className="([^"]*)"\s*\n\s*dangerouslySetInnerHTML=\{\{\s*__html:\s*linkifyText\(msg\.message_text\)/g; + +function messageBodyClasses(file: string): string[] { + const source = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8'); + return [...source.matchAll(MESSAGE_BODY_RE)].map((match) => match[1]); +} + +describe.each(RENDERERS)('%s', (file) => { + it('renders the ticket message body', () => { + expect(messageBodyClasses(file).length).toBeGreaterThan(0); + }); + + it('wraps long unbreakable tokens', () => { + for (const classes of messageBodyClasses(file)) { + expect(classes).toContain('whitespace-pre-wrap'); + expect(classes).toContain('break-words'); + } + }); +}); From cb309ead51dd6031c640927d0accfeb111e9ff63 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Mon, 17 Aug 2026 23:18:48 +0300 Subject: [PATCH 18/26] now able to pass username | email in params --- src/pages/QuickPurchase.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index ebd236f..9d68052 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -870,7 +870,8 @@ export default function QuickPurchase() { const contactKey = `lp_contact_${slug ?? ''}`; const [contactValue, setContactValue] = useState(() => { try { - return localStorage.getItem(contactKey) || ''; + const urlContact = new URLSearchParams(window.location.search).get('contact'); + return urlContact || localStorage.getItem(contactKey) || ''; } catch { return ''; } From acbffa4e17e4549a1068f286e3009b38d30ae6be Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 18 Aug 2026 15:11:49 +0300 Subject: [PATCH 19/26] =?UTF-8?q?fix(landing):=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D0=BA=D1=82=20=D0=B8=D0=B7=20=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B8=20=D0=B2=20=D0=B0=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лендинг поднимает Яндекс.Метрику с webvisor, а она пишет URL страницы и запись сессии. Личный email или @username из ?contact= уезжал бы в аналитику, в Referer при переходе на оплату, в историю браузера и в логи сервера. Читаем параметр и сразу убираем его через history.replaceState, сохраняя остальные параметры и хеш, — тем же приёмом, что применяет captureCampaignFromUrl(). Чтение и очистка вынесены в src/utils/contactPrefill.ts: в компоненте это были бы непокрытые строки, а обращение с персональными данными хочется иметь под тестами. --- src/pages/QuickPurchase.tsx | 15 ++-- src/utils/contactPrefill.test.ts | 127 +++++++++++++++++++++++++++++++ src/utils/contactPrefill.ts | 41 ++++++++++ 3 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 src/utils/contactPrefill.test.ts create mode 100644 src/utils/contactPrefill.ts diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 37d3f45..7b1165a 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -30,6 +30,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher'; import { cn } from '../lib/utils'; import { getApiErrorMessage } from '../utils/api-error'; import { getPendingCampaignSlug } from '../utils/campaign'; +import { readContactPrefill, stripContactFromUrl } from '../utils/contactPrefill'; import { formatPrice } from '../utils/format'; import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '../utils/favicon'; import { useCurrency } from '../hooks/useCurrency'; @@ -869,14 +870,12 @@ export default function QuickPurchase() { const [selectedTariffId, setSelectedTariffId] = useState(null); const [selectedPeriodDays, setSelectedPeriodDays] = useState(null); const contactKey = `lp_contact_${slug ?? ''}`; - const [contactValue, setContactValue] = useState(() => { - try { - const urlContact = new URLSearchParams(window.location.search).get('contact'); - return urlContact || localStorage.getItem(contactKey) || ''; - } catch { - return ''; - } - }); + const [contactValue, setContactValue] = useState(() => readContactPrefill(contactKey)); + // Контакт уже в состоянии — вычищаем его из адресной строки, чтобы личный + // email не уехал в Метрику, Referer и историю браузера. + useEffect(() => { + stripContactFromUrl(); + }, []); const [isGift, setIsGift] = useState(false); const [giftRecipient, setGiftRecipient] = useState(''); const [giftMessage, setGiftMessage] = useState(''); diff --git a/src/utils/contactPrefill.test.ts b/src/utils/contactPrefill.test.ts new file mode 100644 index 0000000..2413104 --- /dev/null +++ b/src/utils/contactPrefill.test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readContactPrefill, stripContactFromUrl } from './contactPrefill'; + +const STORAGE_KEY = 'lp_contact_promo'; + +let replaced: string[] = []; + +function fakeLocalStorage(): Storage { + const store = new Map(); + return { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => store.get(key) ?? null, + key: (index: number) => [...store.keys()][index] ?? null, + removeItem: (key: string) => void store.delete(key), + setItem: (key: string, value: string) => void store.set(key, value), + } as Storage; +} + +function stubLocation(search: string, pathname = '/buy/promo', hash = ''): void { + vi.stubGlobal('window', { + location: { search, pathname, hash }, + history: { + replaceState: (_state: unknown, _title: string, url: string) => replaced.push(url), + }, + }); +} + +beforeEach(() => { + replaced = []; + vi.stubGlobal('localStorage', fakeLocalStorage()); + stubLocation(''); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('readContactPrefill', () => { + it('takes the contact from the URL', () => { + stubLocation('?contact=client%40example.com'); + + expect(readContactPrefill(STORAGE_KEY)).toBe('client@example.com'); + }); + + it('keeps the @ of a telegram username', () => { + stubLocation('?contact=%40durov'); + + expect(readContactPrefill(STORAGE_KEY)).toBe('@durov'); + }); + + it('prefers the URL over the remembered value', () => { + localStorage.setItem(STORAGE_KEY, 'old@example.com'); + stubLocation('?contact=new%40example.com'); + + expect(readContactPrefill(STORAGE_KEY)).toBe('new@example.com'); + }); + + it('falls back to the remembered value', () => { + localStorage.setItem(STORAGE_KEY, 'old@example.com'); + + expect(readContactPrefill(STORAGE_KEY)).toBe('old@example.com'); + }); + + it('returns an empty string when there is nothing to prefill', () => { + expect(readContactPrefill(STORAGE_KEY)).toBe(''); + }); +}); + +describe('stripContactFromUrl', () => { + // Лендинг поднимает Яндекс.Метрику с webvisor: оставленный в адресе email + // уедет в аналитику, в Referer при переходе на оплату и в историю браузера. + it('removes the contact from the address bar', () => { + stubLocation('?contact=client%40example.com'); + + stripContactFromUrl(); + + expect(replaced).toEqual(['/buy/promo']); + }); + + it('keeps the other query params', () => { + stubLocation('?campaign=summer&contact=client%40example.com&subid=42'); + + stripContactFromUrl(); + + expect(replaced).toHaveLength(1); + const params = new URLSearchParams(replaced[0].split('?')[1]); + expect(params.get('campaign')).toBe('summer'); + expect(params.get('subid')).toBe('42'); + expect(params.has('contact')).toBe(false); + }); + + it('keeps the hash', () => { + stubLocation('?contact=client%40example.com', '/buy/promo', '#tariffs'); + + stripContactFromUrl(); + + expect(replaced).toEqual(['/buy/promo#tariffs']); + }); + + it('does not touch the URL when there is no contact param', () => { + stubLocation('?campaign=summer'); + + stripContactFromUrl(); + + expect(replaced).toEqual([]); + }); +}); + +// Компоненты здесь не рендерятся (vitest на node, без jsdom), поэтому вызовы +// фиксируем по исходнику: чтение без очистки оставит контакт в адресе, а это +// вся суть второй функции. +describe('QuickPurchase source', () => { + const source = readFileSync(new URL('../pages/QuickPurchase.tsx', import.meta.url), 'utf8'); + + it('prefills the contact field from the URL', () => { + expect(source).toContain('readContactPrefill(contactKey)'); + }); + + it('cleans the contact out of the address bar', () => { + expect(source).toContain('stripContactFromUrl()'); + }); +}); diff --git a/src/utils/contactPrefill.ts b/src/utils/contactPrefill.ts new file mode 100644 index 0000000..8a034c3 --- /dev/null +++ b/src/utils/contactPrefill.ts @@ -0,0 +1,41 @@ +/** + * Предзаполнение поля контакта на странице быстрой покупки. + * + * Персональную ссылку вида `/buy/slug?contact=@username` формирует бот или + * Happ, чтобы клиенту не пришлось вводить контакт вручную. + */ + +const CONTACT_PARAM = 'contact'; + +/** + * Контакт для формы: сначала параметр URL, иначе последнее введённое значение. + */ +export function readContactPrefill(storageKey: string): string { + try { + const fromUrl = new URLSearchParams(window.location.search).get(CONTACT_PARAM); + return fromUrl || localStorage.getItem(storageKey) || ''; + } catch { + return ''; + } +} + +/** + * Убирает `contact` из адресной строки, сохраняя остальные параметры. + * + * Вызывать сразу после чтения. В параметре лежит личный email или @username, а + * лендинг инициализирует Яндекс.Метрику (та пишет URL страницы, а с + * `webvisor` — ещё и запись сессии). Без очистки контакт уезжает в аналитику, + * в `Referer` при переходе на оплату, в историю браузера и в логи. Тот же приём + * применяет `captureCampaignFromUrl()`. + */ +export function stripContactFromUrl(): void { + try { + const params = new URLSearchParams(window.location.search); + if (!params.has(CONTACT_PARAM)) return; + + params.delete(CONTACT_PARAM); + const search = params.toString(); + const url = window.location.pathname + (search ? `?${search}` : '') + window.location.hash; + window.history.replaceState(null, '', url); + } catch {} +} From f81af401f7f6ca1a28806cbf237a33da7449d893 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 19 Aug 2026 02:00:41 +0300 Subject: [PATCH 20/26] =?UTF-8?q?fix(auth):=20=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20initData=20=D0=B8=D0=B7=20=D0=BC=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=20Telegram,=20=D0=B0=20=D0=BD=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B8=D0=B7=20=D0=BA=D1=8D=D1=88?= =?UTF-8?q?=D0=B0=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retrieveRawInitData() ищет параметры запуска по цепочке location.href -> запись performance о навигации -> кэш tapps/launchParams в sessionStorage, записывая удачный результат обратно. Последние два источника привязаны к документу, а не к текущему запуску мини-аппы: как только SPA сменила маршрут и hash с tgWebAppData ушёл из адреса, остаются данные ПЕРВОГО запуска в этой сессии WebView. На iOS WebView переживает переоткрытия, и SDK молча отдаёт старую копию. Бэкенд принимает initData с auth_date не старше 30 суток, поэтому застрявшая копия рано или поздно пересекает порог и вход отваливается с «Invalid or expired Telegram authentication data». Спрашиваем ещё и window.Telegram.WebApp.initData (официальный мост, telegram-web-app.js из index.html) и берём вариант со свежим auth_date: какой источник протух, зависит от платформы, «самый свежий» верен всегда. Все три места чтения initData сведены в один модуль. --- src/api/client.ts | 14 ++- src/hooks/useTelegramSDK.ts | 8 +- src/main.tsx | 7 +- src/utils/telegramInitData.test.ts | 139 +++++++++++++++++++++++++++++ src/utils/telegramInitData.ts | 63 +++++++++++++ src/vite-env.d.ts | 6 ++ 6 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 src/utils/telegramInitData.test.ts create mode 100644 src/utils/telegramInitData.ts diff --git a/src/api/client.ts b/src/api/client.ts index c643618..74600b6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; -import { retrieveRawInitData } from '@telegram-apps/sdk-react'; +import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData'; import { tokenStorage, isTokenExpired, @@ -41,13 +41,11 @@ function ensureCsrfToken(): string { const getTelegramInitData = (): string | null => { if (typeof window === 'undefined') return null; - try { - const raw = retrieveRawInitData(); - if (raw) { - tokenStorage.setTelegramInitData(raw); - return raw; - } - } catch {} + const raw = readTelegramInitData(); + if (raw) { + tokenStorage.setTelegramInitData(raw); + return raw; + } return tokenStorage.getTelegramInitData(); }; diff --git a/src/hooks/useTelegramSDK.ts b/src/hooks/useTelegramSDK.ts index 2a95b87..ced149d 100644 --- a/src/hooks/useTelegramSDK.ts +++ b/src/hooks/useTelegramSDK.ts @@ -13,11 +13,11 @@ import { enableVerticalSwipes as sdkEnableVerticalSwipes, expandViewport, retrieveLaunchParams, - retrieveRawInitData, themeParamsState, closeMiniApp as sdkCloseMiniApp, postEvent, } from '@telegram-apps/sdk-react'; +import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData'; const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; @@ -90,11 +90,7 @@ export function isTelegramMobile(): boolean { } export function getTelegramInitData(): string | null { - try { - return retrieveRawInitData() || null; - } catch { - return null; - } + return readTelegramInitData(); } function isDarkHexColor(hex: string): boolean { diff --git a/src/main.tsx b/src/main.tsx index d4981db..f2b7cdc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { init, restoreInitData, - retrieveRawInitData, mountMiniApp, miniAppReady, mountViewport, @@ -21,6 +20,7 @@ import { } from '@telegram-apps/sdk-react'; import { clearStaleSessionIfNeeded } from './utils/token'; import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard'; +import { getTelegramInitData } from './utils/telegramInitData'; import { useAuthStore } from './store/auth'; import { AppWithNavigator } from './AppWithNavigator'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -38,7 +38,8 @@ installEncodingSurrogateGuard(); // Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome). // @telegram-apps/sdk v3 depends on valibot which uses Object.hasOwn internally. -// Without this, init() throws LaunchParamsRetrieveError on affected devices. +// Without this, init() and any launch-params retrieval below throw +// LaunchParamsRetrieveError on affected devices. // See: https://github.com/Telegram-Mini-Apps/tma.js/issues/683 if (typeof (Object as { hasOwn?: unknown }).hasOwn !== 'function') { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -62,7 +63,7 @@ if (isTelegramEnv && !alreadyInitialized) { init(); restoreInitData(); - clearStaleSessionIfNeeded(retrieveRawInitData() || null); + clearStaleSessionIfNeeded(getTelegramInitData()); // Adopt the user's Telegram client language on first run (no explicit choice yet). applyTelegramLanguage(); diff --git a/src/utils/telegramInitData.test.ts b/src/utils/telegramInitData.test.ts new file mode 100644 index 0000000..ad0074f --- /dev/null +++ b/src/utils/telegramInitData.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const retrieveRawInitData = vi.fn<() => string | undefined>(); + +vi.mock('@telegram-apps/sdk-react', () => ({ + retrieveRawInitData: () => retrieveRawInitData(), +})); + +const { getTelegramInitData } = await import('./telegramInitData'); + +/** initData ровно той формы, что приходит от Telegram. */ +function initData(authDate: number): string { + const user = encodeURIComponent(JSON.stringify({ id: 1, first_name: 'A' })); + return `user=${user}&auth_date=${authDate}&signature=s&hash=h${authDate}`; +} + +const OLD = initData(1_700_000_000); +const FRESH = initData(1_755_000_000); + +function setBridge(value: string | undefined): void { + vi.stubGlobal('window', value === undefined ? {} : { Telegram: { WebApp: { initData: value } } }); +} + +beforeEach(() => { + retrieveRawInitData.mockReset(); + setBridge(undefined); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('getTelegramInitData', () => { + it('uses the Telegram bridge value', () => { + setBridge(FRESH); + retrieveRawInitData.mockReturnValue(undefined); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + it('falls back to the SDK when the bridge script has not loaded', () => { + setBridge(undefined); + retrieveRawInitData.mockReturnValue(FRESH); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + // Ради этого правка и делается: SDK берёт параметры запуска из записи о + // навигации и из своего кэша в sessionStorage, а оба привязаны к документу, + // а не к текущему запуску мини-аппы. На iOS WebView переживает переоткрытия, + // и SDK молча отдаёт initData прошлой недели — бэкенд её отвергает. + it('prefers the bridge when the SDK serves a stale cached launch', () => { + setBridge(FRESH); + retrieveRawInitData.mockReturnValue(OLD); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + // Обратный случай встречается на других платформах, поэтому выбираем не + // «первый доступный», а самый свежий по auth_date. + it('prefers the SDK when the bridge itself is the stale one', () => { + setBridge(OLD); + retrieveRawInitData.mockReturnValue(FRESH); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + it('keeps the bridge value when both are equally fresh', () => { + const bridgeCopy = `${FRESH}&tgWebAppBotInline=0`; + setBridge(bridgeCopy); + retrieveRawInitData.mockReturnValue(FRESH); + + expect(getTelegramInitData()).toBe(bridgeCopy); + }); + + it('treats an empty bridge value as absent', () => { + setBridge(''); + retrieveRawInitData.mockReturnValue(FRESH); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + it('survives the SDK throwing outside Telegram', () => { + setBridge(FRESH); + retrieveRawInitData.mockImplementation(() => { + throw new Error('LaunchParamsRetrieveError'); + }); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + it('returns null when there is no init data at all', () => { + setBridge(undefined); + retrieveRawInitData.mockImplementation(() => { + throw new Error('LaunchParamsRetrieveError'); + }); + + expect(getTelegramInitData()).toBeNull(); + }); + + it('does not crash on init data without a usable auth_date', () => { + setBridge('user=%7B%7D&hash=h'); + retrieveRawInitData.mockReturnValue('auth_date=not-a-number&hash=h'); + + expect(getTelegramInitData()).toBe('user=%7B%7D&hash=h'); + }); +}); + +// Смысл правки — единственная точка чтения initData. Прямой вызов +// retrieveRawInitData() в обход неё вернёт ту самую протухшую копию из кэша +// SDK, причём молча: ошибки не будет, вход просто перестанет работать. +describe('single source of init data', () => { + it('is read through this module only', async () => { + const { readdirSync, readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + + const srcDir = new URL('..', import.meta.url).pathname; + const offenders: string[] = []; + + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!/\.tsx?$/.test(entry.name)) continue; + if (full.endsWith(join('utils', 'telegramInitData.ts'))) continue; + if (full.endsWith(join('utils', 'telegramInitData.test.ts'))) continue; + if (readFileSync(full, 'utf8').includes('retrieveRawInitData')) { + offenders.push(full.slice(srcDir.length)); + } + } + }; + walk(srcDir); + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/utils/telegramInitData.ts b/src/utils/telegramInitData.ts new file mode 100644 index 0000000..ae03a4e --- /dev/null +++ b/src/utils/telegramInitData.ts @@ -0,0 +1,63 @@ +import { retrieveRawInitData } from '@telegram-apps/sdk-react'; + +/** + * Сырая строка initData Telegram, прочитанная в момент вызова. + * + * Почему мало одного `retrieveRawInitData()` из @telegram-apps/sdk: он ищет + * параметры запуска по цепочке `location.href` → запись performance о навигации + * → собственный кэш `tapps/launchParams` в sessionStorage, записывая удачный + * результат обратно в кэш. Последние два источника привязаны не к текущему + * запуску мини-аппы, а к документу: как только SPA сменила маршрут и hash с + * `tgWebAppData` ушёл из адреса, остаются запись о навигации исходного + * документа и кэш — оба из ПЕРВОГО запуска в этой сессии WebView. На iOS такой + * WebView переживает переоткрытия мини-аппы, и SDK молча отдаёт initData + * недельной давности, без единой ошибки. + * + * Цена — отказ входа: бэкенд принимает initData с `auth_date` не старше 30 + * суток, и застрявшая копия рано или поздно пересекает порог, после чего + * логин отвечает «Invalid or expired Telegram authentication data». + * + * Поэтому спрашиваем ещё и `window.Telegram.WebApp.initData` — его пишет + * официальный мост Telegram (telegram-web-app.js, подключается в index.html), + * и он не завязан на кэш SDK. Какой из источников протух, зависит от платформы + * и способа открытия, поэтому берём не «первый доступный», а тот, у которого + * `auth_date` больше. + */ +export function getTelegramInitData(): string | null { + if (typeof window === 'undefined') return null; + + const candidates = [fromTelegramBridge(), fromSdk()].filter((value): value is string => + Boolean(value), + ); + if (candidates.length === 0) return null; + + // При равном или неразобранном auth_date побеждает мост: он ближе к + // источнику, чем кэш SDK. + return candidates.reduce((best, candidate) => + authDateOf(candidate) > authDateOf(best) ? candidate : best, + ); +} + +function fromTelegramBridge(): string | null { + return window.Telegram?.WebApp?.initData || null; +} + +function fromSdk(): string | null { + try { + return retrieveRawInitData() || null; + } catch { + // Вне Telegram параметров запуска нет — это не ошибка. + return null; + } +} + +/** Метка времени выдачи initData; 0, если её не удалось разобрать. */ +function authDateOf(initData: string): number { + try { + const raw = new URLSearchParams(initData).get('auth_date'); + const parsed = Number(raw); + return raw && Number.isFinite(parsed) ? parsed : 0; + } catch { + return 0; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a05d633..98cb105 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -21,6 +21,12 @@ interface TelegramWebAppGlobal { offEvent?: (event: string, callback: () => void) => void; /** Closes the Mini App (injected by telegram-web-app.js). */ close?: () => void; + /** + * Raw init data, snapshotted by Telegram's own bridge script at page load. + * Independent of the @telegram-apps/sdk launch-params cache, which can go + * stale — see src/utils/telegramInitData.ts. + */ + initData?: string; } /** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */ From b4e1de6152cfc85418c62e4f840180411989a2a7 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 19 Aug 2026 16:01:05 +0300 Subject: [PATCH 21/26] =?UTF-8?q?feat(remnawave):=20GeoCheck=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B5=20=E2=80=94=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D1=91=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка в карточке ноды открывает модалку: выбор маршрута (по умолчанию / IP-адрес / интерфейс) с подсказками из данных ноды, ожидание, затем отчёт картинкой либо тем же отчётом в JSON. Есть копирование JSON, скачивание SVG, повтор проверки и полноэкранный режим. Кнопка показывается только для узлов 3.3.0+ (versions.node) и только для подключённых — иначе админ упирался бы в ошибку панели. Отчёт вставляется как , а не сырым SVG в разметку: он несёт встроенный моноширинный шрифт и