From 89a9f9f70c5910916097818696711cb99ae19bcc Mon Sep 17 00:00:00 2001 From: Dmitry Lunin Date: Fri, 31 Jul 2026 01:29:16 +0300 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] =?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/11] =?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)) && (