diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index abd4863..f774774 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -456,7 +456,8 @@ export const adminUsersApi = { | 'traffic' | 'last_activity' | 'total_spent' - | 'purchase_count'; + | 'purchase_count' + | 'subscription_end_date'; } = {}, ): Promise => { const response = await apiClient.get('/cabinet/admin/users', { params }); @@ -518,6 +519,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/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/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 15dc748..9cc0c59 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 @@ -168,7 +172,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget); useEffect(() => { - if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return; + // showDeepLinkUI обязан быть в зависимостях: пока он true, контейнер + // виджета размонтирован, а при возврате «Назад к виджету» сам по себе + // эффект не перезапустится — на legacy-пути scriptLoaded не меняется + // никогда, поэтому ни одна из остальных зависимостей не дрогнет, и + // пользователь получил бы пустое место вместо виджета. + if (showDeepLinkUI || isOIDC || !containerRef.current || !botUsername || !widgetConfig) return; const container = containerRef.current; while (container.firstChild) { @@ -229,7 +238,15 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto container.removeChild(container.firstChild); } }; - }, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate, handleScriptFailed]); + }, [ + showDeepLinkUI, + isOIDC, + botUsername, + widgetConfig, + loginWithTelegramWidget, + navigate, + handleScriptFailed, + ]); // Deep link auth: request token and start polling with recursive setTimeout const startDeepLinkAuth = useCallback(async () => { @@ -336,9 +353,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 +366,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 +449,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 +462,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
{/* Info message */}

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

{deepLinkToken && deepLinkUrl ? ( @@ -517,6 +536,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 && ( + + )} ); } @@ -551,24 +591,41 @@ 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 equal alternative to the widget for + users who'd rather confirm in the bot than type a phone 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..e5bbd95 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", + "backToWidget": "Back to widget login", "openBotToLogin": "Open bot to sign in", "waitingForConfirmation": "Waiting for confirmation...", "deepLinkExpired": "Link expired. Please try again.", @@ -3300,7 +3303,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 +3380,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": { @@ -3456,7 +3463,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}}" @@ -3646,7 +3654,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..9ba6a00 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -199,6 +199,9 @@ "orOpenInApp": "یا ربات را در برنامه باز کنید", "loginFailed": "ورود ناموفق", "telegramWidgetBlocked": "ویجت ورود تلگرام در دسترس نیست. از طریق ربات وارد شوید:", + "deepLinkIntro": "ورود را مستقیماً در ربات تأیید کنید — بدون نیاز به شماره تلفن:", + "loginWithBot": "ورود از طریق ربات", + "backToWidget": "بازگشت به ورود با ویجت", "openBotToLogin": "باز کردن ربات برای ورود", "waitingForConfirmation": "در انتظار تایید...", "deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.", @@ -2810,7 +2813,10 @@ "save": "ذخیره", "defaultTrialTariff": "تعرفه پیش‌فرض آزمایشی", "selectTariff": "تعرفه را انتخاب کنید", - "tariff": "تعرفه" + "tariff": "تعرفه", + "includeTraffic": "ترافیک", + "trafficAmount": "حجم ترافیک", + "gb": "گیگابایت" }, "stats": { "title": "آمار کد تخفیف", @@ -2882,7 +2888,8 @@ "daysRequired": "تعداد روزها باید بیشتر از 0 باشد", "groupRequired": "یک گروه تخفیف انتخاب کنید", "discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد", - "discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید" + "discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید", + "trafficRequired": "حجم ترافیک باید بیشتر از ۰ باشد" } }, "promoGroups": { @@ -2963,7 +2970,8 @@ "byDate": "بر اساس تاریخ", "byBalance": "بر اساس موجودی", "byActivity": "بر اساس فعالیت", - "bySpent": "بر اساس هزینه" + "bySpent": "بر اساس هزینه", + "byExpiry": "بر اساس انقضا" }, "pagination": { "showing": "نمایش {{from}}-{{to}} از {{total}}" @@ -3105,7 +3113,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..b1e0c61 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": "Ссылка истекла. Попробуйте снова.", @@ -3694,7 +3697,10 @@ "save": "Сохранить", "defaultTrialTariff": "Тариф триала по умолчанию", "selectTariff": "Выберите тариф", - "tariff": "Тариф" + "tariff": "Тариф", + "includeTraffic": "Трафик", + "trafficAmount": "Объём трафика", + "gb": "ГБ" }, "stats": { "title": "Статистика промокода", @@ -3770,7 +3776,8 @@ "daysRequired": "Количество дней должно быть больше 0", "groupRequired": "Выберите группу скидок", "discountPercentInvalid": "Процент скидки должен быть от 1 до 100", - "discountHoursRequired": "Укажите время действия скидки в часах" + "discountHoursRequired": "Укажите время действия скидки в часах", + "trafficRequired": "Объём трафика должен быть больше 0" } }, "promoGroups": { @@ -3853,7 +3860,8 @@ "byDate": "По дате", "byBalance": "По балансу", "byActivity": "По активности", - "bySpent": "По расходам" + "bySpent": "По расходам", + "byExpiry": "По истечению подписки" }, "pagination": { "showing": "Показано {{from}}-{{to}} из {{total}}" @@ -4043,7 +4051,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..f39b815 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -199,6 +199,9 @@ "orOpenInApp": "或在应用中打开机器人", "loginFailed": "登录失败", "telegramWidgetBlocked": "Telegram登录小部件不可用。请使用机器人登录:", + "deepLinkIntro": "直接在机器人中确认登录 — 无需手机号:", + "loginWithBot": "通过机器人登录", + "backToWidget": "返回小部件登录", "openBotToLogin": "打开机器人登录", "waitingForConfirmation": "等待确认...", "deepLinkExpired": "链接已过期,请重试。", @@ -2809,7 +2812,10 @@ "save": "保存", "defaultTrialTariff": "默认试用套餐", "selectTariff": "选择套餐", - "tariff": "套餐" + "tariff": "套餐", + "includeTraffic": "流量", + "trafficAmount": "流量额度", + "gb": "GB" }, "stats": { "title": "促销码统计", @@ -2881,7 +2887,8 @@ "daysRequired": "天数必须大于0", "groupRequired": "请选择折扣组", "discountPercentInvalid": "折扣百分比必须在1到100之间", - "discountHoursRequired": "请指定折扣有效期(小时)" + "discountHoursRequired": "请指定折扣有效期(小时)", + "trafficRequired": "流量额度必须大于 0" } }, "promoGroups": { @@ -2962,7 +2969,8 @@ "byDate": "按日期", "byBalance": "按余额", "byActivity": "按活跃度", - "bySpent": "按消费" + "bySpent": "按消费", + "byExpiry": "按到期时间" }, "pagination": { "showing": "显示 {{from}}-{{to}},共 {{total}}" @@ -3104,7 +3112,11 @@ "sbpCancelled": "SBP 自动扣款已关闭", "sbpStatus_PENDING": "待确认", "sbpStatus_ACTIVE": "已启用", - "sbpStatus_PAST_DUE": "扣款失败" + "sbpStatus_PAST_DUE": "扣款失败", + "deleteTitle": "删除订阅", + "deleteHint": "订阅及其设备将被永久删除", + "deleteButton": "删除订阅", + "deleted": "订阅已删除" }, "balance": { "current": "当前余额", diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx index f9975c3..56f703c 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); @@ -85,8 +87,15 @@ export default function AdminPromocodeCreate() { setMode(data.type); } else { setMode('bonus_set'); - setIncludeBalance(data.type === 'balance' || data.type === 'balance_and_days'); - setIncludeDays(data.type === 'subscription_days' || data.type === 'balance_and_days'); + // Галочки поднимаем по ЗНАЧЕНИЯМ, а не по типу. С появлением трафика + // balance_and_days перестал означать «есть и баланс, и дни»: этот тип + // теперь у любого набора с трафиком, в том числе с нулевым балансом и + // нулём дней. По типу такой код открывался бы с двумя чужими галочками + // на нулях, валидация требовала бы «больше 0», и Сохранить не работало + // бы, пока админ их не снимет, — а подсказка толкает вместо этого + // вписать сумму и молча добавить коду бонус, которого в нём не было. + setIncludeBalance(data.type === 'balance' || (data.balance_bonus_rubles || 0) > 0); + setIncludeDays(data.type === 'subscription_days' || (data.subscription_days || 0) > 0); // Промогруппа комбинируется с любым составом (bэкенд назначает её // независимо от типа), поэтому чекбокс — по факту наличия группы setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id); @@ -99,6 +108,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 +144,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 +159,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 +176,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 +210,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 +222,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 +324,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 +378,28 @@ export default function AdminPromocodeCreate() {
)} + {mode === 'bonus_set' && includeTraffic && ( +
+ +
+ + {t('admin.promocodes.form.gb')} +
+
+ )} + {(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
{/* Info line */}
- {(promo.type === 'balance' || promo.type === 'balance_and_days') && ( - - +{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')} - - )} + {/* Составляющие показываем по значению, а не по типу: + balance_and_days теперь стоит и у набора, где баланса + или дней нет вовсе, — иначе в списке висело бы «+0 ₽ + +0 дн.» у кода, который на самом деле раздаёт трафик. */} + {(promo.type === 'balance' || promo.type === 'balance_and_days') && + promo.balance_bonus_rubles > 0 && ( + + +{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')} + + )} {(promo.type === 'subscription_days' || promo.type === 'trial_subscription' || - promo.type === 'balance_and_days') && ( + promo.type === 'balance_and_days') && + promo.subscription_days > 0 && ( + + +{promo.subscription_days} {t('admin.promocodes.form.days')} + + )} + {promo.type === 'balance_and_days' && (promo.traffic_gb || 0) > 0 && ( - +{promo.subscription_days} {t('admin.promocodes.form.days')} + +{promo.traffic_gb} {t('admin.promocodes.form.gb')} )} {promo.type === 'discount' && ( diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index db7abb9..09a1e18 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -28,6 +28,7 @@ import { ActivityTab } from '../components/admin/userDetail/ActivityTab'; import { TicketsTab } from '../components/admin/userDetail/TicketsTab'; import { InfoTab } from '../components/admin/userDetail/InfoTab'; import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab'; +import { getApiErrorMessage } from '../utils/api-error'; import { toNumber } from '../utils/inputHelpers'; import { usePermissionStore } from '../store/permissions'; @@ -662,6 +663,28 @@ 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 (err) { + // Отказы тут осмысленные и действенные: открытый временный доступ + // (409, «сначала заверши или восстанови grace»), активная платная без + // force (409), подписки нет (404). Общее «Ошибка» оставило бы админа + // гадать, почему кнопка не сработала, — показываем текст сервера. + notify.error(getApiErrorMessage(err, t('admin.users.userActions.error')), t('common.error')); + } finally { + setActionLoading(false); + } + }; + const handleDisableUser = async () => { if (!userId) return; setActionLoading(true); @@ -874,6 +897,7 @@ export default function AdminUserDetail() { userSubscriptions={userSubscriptions} selectedSub={selectedSub} onCancelSbpRecurring={handleCancelSbpRecurring} + onDeleteSubscription={handleDeleteSubscription} activeSubscriptionId={activeSubscriptionId} onActiveSubscriptionChange={setActiveSubscriptionId} subscriptionDetailView={subscriptionDetailView} 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() { +