Merge remote-tracking branch 'origin/dev' into pr-544

This commit is contained in:
Fringg
2026-08-07 15:24:13 +03:00
12 changed files with 283 additions and 48 deletions

View File

@@ -456,7 +456,8 @@ export const adminUsersApi = {
| 'traffic'
| 'last_activity'
| 'total_spent'
| 'purchase_count';
| 'purchase_count'
| 'subscription_end_date';
} = {},
): Promise<UsersListResponse> => {
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,

View File

@@ -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;

View File

@@ -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 #<issue-number>.
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
<div className="flex flex-col items-center space-y-5">
{/* Info message */}
<p className="max-w-xs text-center text-xs text-dark-400">
{t('auth.telegramWidgetBlocked')}
{t(scriptFailed ? 'auth.telegramWidgetBlocked' : 'auth.deepLinkIntro')}
</p>
{deepLinkToken && deepLinkUrl ? (
@@ -517,6 +536,27 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
{t('common.loading')}
</div>
)}
{/* Only offer a way back if the widget actually works — if the
script failed there is nothing to go back to. */}
{!scriptFailed && (
<button
type="button"
onClick={() => {
if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current);
if (expireTimeoutRef.current) clearTimeout(expireTimeoutRef.current);
pollTimeoutRef.current = null;
expireTimeoutRef.current = null;
setDeepLinkToken(null);
setDeepLinkPolling(false);
setDeepLinkError('');
setManualDeepLink(false);
}}
className="text-xs text-dark-400 underline decoration-dotted transition-colors hover:text-dark-300"
>
{t('auth.backToWidget')}
</button>
)}
</div>
);
}
@@ -551,24 +591,41 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
<div ref={containerRef} className="flex justify-center" />
)}
<div className="text-center">
<p className="mb-2 text-xs text-dark-400">{t('auth.orOpenInApp')}</p>
{/* 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 && (
<a
href={
referralCode
? `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`
: `https://t.me/${botUsername}`
}
href={`https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`}
target="_blank"
rel="noopener noreferrer"
className="text-telegram-blue inline-flex items-center text-sm hover:underline"
className="text-telegram-blue inline-flex items-center text-xs hover:underline"
>
<svg className="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
@{botUsername}
{t('auth.orOpenInApp')}&nbsp;@{botUsername}
</a>
)}
<div className="flex w-full max-w-xs items-center gap-3">
<div className="h-px flex-1 bg-dark-700" />
<span className="text-[11px] text-dark-500">{t('common.or')}</span>
<div className="h-px flex-1 bg-dark-700" />
</div>
{/* 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. */}
<button
type="button"
onClick={() => setManualDeepLink(true)}
className="inline-flex items-center gap-2 rounded-lg border border-dark-700 bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:border-dark-600 hover:bg-dark-800"
>
<svg className="h-5 w-5 text-telegram-blue" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
{t('auth.loginWithBot')}
</button>
</div>
);
}

View File

@@ -131,6 +131,7 @@ export interface SubscriptionTabProps {
onRemoveTraffic: (purchaseId: number) => Promise<void>;
onResetDevices: () => Promise<void>;
onCancelSbpRecurring: () => Promise<void>;
onDeleteSubscription: () => Promise<void>;
onDeleteDevice: (hwid: string) => Promise<void>;
onRenameDevice: (hwid: string) => Promise<void>;
onLoadDevices: () => Promise<void>;
@@ -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) {
</div>
)}
{/* 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') && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.deleteTitle')}
</div>
<div className="mt-0.5 text-xs text-dark-400">
{t('admin.users.detail.subscription.deleteHint')}
</div>
</div>
<button
// Per-subscription confirm key: an armed confirm must not
// survive switching to another subscription in the picker.
onClick={() =>
onInlineConfirm(`deleteSubscription_${selectedSub.id}`, onDeleteSubscription)
}
disabled={actionLoading}
className={`shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === `deleteSubscription_${selectedSub.id}`
? 'bg-error-500 text-white'
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
}`}
>
{confirmingAction === `deleteSubscription_${selectedSub.id}`
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.detail.subscription.deleteButton')}
</button>
</div>
</div>
)}
{/* Traffic Packages */}
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4">

View File

@@ -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",

View File

@@ -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": "موجودی فعلی",

View File

@@ -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": "Текущий баланс",

View File

@@ -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": "当前余额",

View File

@@ -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<number | ''>(0);
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
const [maxUses, setMaxUses] = useState<number | ''>(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() {
</div>
)}
{mode === 'bonus_set' && includeTraffic && (
<div>
<label htmlFor="pc-traffic-gb" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.trafficAmount')}
<span className="text-error-400">*</span>
</label>
<div className="flex items-center gap-2">
<input
id="pc-traffic-gb"
type="number"
value={trafficGb}
onChange={createNumberInputHandler(setTrafficGb, 0)}
className="input w-32"
min={0}
step={1}
placeholder="0"
/>
<span className="text-dark-400">{t('admin.promocodes.form.gb')}</span>
</div>
</div>
)}
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
<div>
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">

View File

@@ -196,16 +196,27 @@ export default function AdminPromocodes() {
</div>
{/* Info line */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
{(promo.type === 'balance' || promo.type === 'balance_and_days') && (
<span className="text-success-400">
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
</span>
)}
{/* Составляющие показываем по значению, а не по типу:
balance_and_days теперь стоит и у набора, где баланса
или дней нет вовсе, — иначе в списке висело бы «+0 ₽
+0 дн.» у кода, который на самом деле раздаёт трафик. */}
{(promo.type === 'balance' || promo.type === 'balance_and_days') &&
promo.balance_bonus_rubles > 0 && (
<span className="text-success-400">
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
</span>
)}
{(promo.type === 'subscription_days' ||
promo.type === 'trial_subscription' ||
promo.type === 'balance_and_days') && (
promo.type === 'balance_and_days') &&
promo.subscription_days > 0 && (
<span className="text-accent-400">
+{promo.subscription_days} {t('admin.promocodes.form.days')}
</span>
)}
{promo.type === 'balance_and_days' && (promo.traffic_gb || 0) > 0 && (
<span className="text-accent-400">
+{promo.subscription_days} {t('admin.promocodes.form.days')}
+{promo.traffic_gb} {t('admin.promocodes.form.gb')}
</span>
)}
{promo.type === 'discount' && (

View File

@@ -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}

View File

@@ -288,6 +288,7 @@ export default function AdminUsers() {
<option value="balance">{t('admin.users.filters.byBalance')}</option>
<option value="last_activity">{t('admin.users.filters.byActivity')}</option>
<option value="total_spent">{t('admin.users.filters.bySpent')}</option>
<option value="subscription_end_date">{t('admin.users.filters.byExpiry')}</option>
</select>
</div>
</div>