diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index 83acb23..cde1307 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -183,4 +183,12 @@ export const promocodesApi = { deletePromoGroup: async (id: number): Promise => { await apiClient.delete(`/cabinet/admin/promo-groups/${id}`); }, + + // Deactivate user's active discount (admin) + deactivateDiscount: async (userId: number): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post( + `/cabinet/admin/promocodes/deactivate-discount/${userId}`, + ); + return response.data; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index 3f70580..62ac714 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1886,6 +1886,22 @@ "title": "Actions", "areYouSure": "Are you sure?" }, + "promoGroup": "Promo group", + "noPromoGroup": "Not set", + "changePromoGroup": "Change", + "selectPromoGroup": "Select group", + "removePromoGroup": "Remove", + "activePromoOffer": "Active promo offer", + "discount": "Discount", + "source": "Source", + "expiresAt": "Expires", + "deactivateOffer": "Deactivate", + "sendOffer": "Send offer", + "discountPercent": "Discount %", + "validHours": "Valid hours", + "offerSent": "Offer sent", + "offerSendError": "Failed to send offer", + "offerDeactivated": "Offer deactivated", "subscription": { "current": "Current subscription", "tariff": "Tariff", diff --git a/src/locales/fa.json b/src/locales/fa.json index 5b7e28b..06eae91 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1585,6 +1585,22 @@ "subscription": "• خرید اشتراک ممنوع", "reason": "دلیل" }, + "promoGroup": "گروه تبلیغاتی", + "noPromoGroup": "تنظیم نشده", + "changePromoGroup": "تغییر", + "selectPromoGroup": "انتخاب گروه", + "removePromoGroup": "حذف", + "activePromoOffer": "پیشنهاد فعال", + "discount": "تخفیف", + "source": "منبع", + "expiresAt": "انقضا", + "deactivateOffer": "غیرفعال کردن", + "sendOffer": "ارسال پیشنهاد", + "discountPercent": "تخفیف %", + "validHours": "مدت اعتبار (ساعت)", + "offerSent": "پیشنهاد ارسال شد", + "offerSendError": "خطا در ارسال پیشنهاد", + "offerDeactivated": "پیشنهاد غیرفعال شد", "subscription": { "current": "اشتراک فعلی", "tariff": "تعرفه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 7dd81b3..2d3654b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2408,6 +2408,22 @@ "subscription": "• Запрет покупки подписки", "reason": "Причина" }, + "promoGroup": "Промо-группа", + "noPromoGroup": "Не задана", + "changePromoGroup": "Изменить", + "selectPromoGroup": "Выберите группу", + "removePromoGroup": "Убрать", + "activePromoOffer": "Активное промо-предложение", + "discount": "Скидка", + "source": "Источник", + "expiresAt": "Истекает", + "deactivateOffer": "Отключить", + "sendOffer": "Отправить предложение", + "discountPercent": "Скидка %", + "validHours": "Срок действия (часы)", + "offerSent": "Предложение отправлено", + "offerSendError": "Ошибка отправки предложения", + "offerDeactivated": "Предложение отключено", "actions": { "title": "Действия", "areYouSure": "Уверены?" @@ -2957,164 +2973,6 @@ "uptime": "Аптайм" } }, - "adminUsers": { - "title": "Пользователи", - "subtitle": "Управление пользователями", - "searchPlaceholder": "Поиск по ID, username, имени...", - "filters": { - "all": "Все", - "active": "Активные", - "blocked": "Заблокированные", - "deleted": "Удалённые" - }, - "sort": { - "created_at": "Дата регистрации", - "balance": "Баланс", - "traffic": "Трафик", - "last_activity": "Активность", - "total_spent": "Потрачено", - "purchase_count": "Покупки" - }, - "stats": { - "totalUsers": "Всего пользователей", - "activeUsers": "Активных", - "blockedUsers": "Заблокировано", - "withSubscription": "С подпиской", - "newToday": "Новых сегодня", - "newWeek": "За неделю", - "newMonth": "За месяц", - "totalBalance": "Общий баланс", - "activeToday": "Активны сегодня", - "activeWeek": "За неделю", - "activeMonth": "За месяц" - }, - "table": { - "user": "Пользователь", - "status": "Статус", - "balance": "Баланс", - "subscription": "Подписка", - "spent": "Потрачено", - "registered": "Регистрация", - "activity": "Активность", - "noUsers": "Пользователи не найдены" - }, - "userStatus": { - "active": "Активен", - "blocked": "Заблокирован", - "deleted": "Удалён" - }, - "subscriptionStatus": { - "active": "Активна", - "trial": "Триал", - "expired": "Истекла", - "none": "Нет подписки" - }, - "detail": { - "title": "Информация о пользователе", - "tabs": { - "info": "Инфо", - "subscription": "Подписка", - "balance": "Баланс", - "sync": "Синхронизация" - }, - "telegramId": "Telegram ID", - "username": "Username", - "fullName": "Имя", - "email": "Email", - "emailVerified": "Подтверждён", - "emailNotVerified": "Не подтверждён", - "language": "Язык", - "registeredAt": "Регистрация", - "lastActivity": "Последняя активность", - "cabinetLogin": "Вход в кабинет", - "referralCode": "Реферальный код", - "referralsCount": "Рефералов", - "referralEarnings": "Заработано", - "referredBy": "Приглашён", - "promoGroup": "Промо-группа", - "noPromoGroup": "Не задана", - "restrictions": "Ограничения", - "restrictionTopup": "Пополнение баланса", - "restrictionSubscription": "Покупка подписки", - "restrictionReason": "Причина", - "noRestrictions": "Нет ограничений", - "totalSpent": "Всего потрачено", - "purchaseCount": "Покупок", - "usedPromocodes": "Использовано промокодов", - "lifetimeTraffic": "Трафик за всё время", - "recentTransactions": "Последние транзакции", - "noTransactions": "Нет транзакций" - }, - "subscription": { - "noSubscription": "Подписка отсутствует", - "currentTariff": "Текущий тариф", - "status": "Статус", - "endDate": "Окончание", - "daysRemaining": "Осталось дней", - "trafficUsed": "Использовано трафика", - "deviceLimit": "Лимит устройств", - "autopay": "Автоплатёж", - "enabled": "Включён", - "disabled": "Отключён", - "actions": { - "extend": "Продлить", - "changeTariff": "Сменить тариф", - "setTraffic": "Установить трафик", - "toggleAutopay": "Вкл/выкл автоплатёж", - "cancel": "Отменить подписку", - "create": "Создать подписку" - }, - "extendDays": "Дней", - "selectTariff": "Выберите тариф", - "trafficLimitGb": "Лимит ГБ", - "trafficUsedGb": "Использовано ГБ", - "isTrial": "Пробный период" - }, - "balance": { - "currentBalance": "Текущий баланс", - "addFunds": "Пополнить", - "deductFunds": "Списать", - "amount": "Сумма", - "description": "Описание", - "createTransaction": "Создать транзакцию", - "transactionHistory": "История транзакций" - }, - "sync": { - "title": "Синхронизация с панелью", - "status": "Статус синхронизации", - "lastSync": "Последняя синхронизация", - "neverSynced": "Никогда", - "botData": "Данные бота", - "panelData": "Данные панели", - "notFound": "Не найден в панели", - "differences": "Различия", - "noDifferences": "Различий нет", - "syncFromPanel": "Синхронизировать из панели", - "syncToPanel": "Синхронизировать в панель", - "remawave_uuid": "Remnawave UUID", - "notLinked": "Не привязан" - }, - "actions": { - "block": "Заблокировать", - "unblock": "Разблокировать", - "delete": "Удалить", - "blockReason": "Причина блокировки", - "confirmBlock": "Подтвердить блокировку", - "confirmDelete": "Подтвердить удаление" - }, - "messages": { - "loadError": "Не удалось загрузить пользователей", - "userLoadError": "Не удалось загрузить пользователя", - "balanceUpdated": "Баланс обновлён", - "balanceError": "Ошибка обновления баланса", - "subscriptionUpdated": "Подписка обновлена", - "subscriptionError": "Ошибка обновления подписки", - "statusUpdated": "Статус обновлён", - "statusError": "Ошибка обновления статуса", - "syncSuccess": "Синхронизация выполнена", - "syncError": "Ошибка синхронизации" - } - }, "profile": { "title": "Профиль", "accountInfo": "Информация об аккаунте", diff --git a/src/locales/zh.json b/src/locales/zh.json index 568726e..6d8bd78 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1584,6 +1584,22 @@ "subscription": "• 禁止购买订阅", "reason": "原因" }, + "promoGroup": "优惠组", + "noPromoGroup": "未设置", + "changePromoGroup": "更改", + "selectPromoGroup": "选择组", + "removePromoGroup": "移除", + "activePromoOffer": "活跃促销优惠", + "discount": "折扣", + "source": "来源", + "expiresAt": "到期时间", + "deactivateOffer": "停用", + "sendOffer": "发送优惠", + "discountPercent": "折扣 %", + "validHours": "有效期(小时)", + "offerSent": "优惠已发送", + "offerSendError": "发送优惠失败", + "offerDeactivated": "优惠已停用", "subscription": { "current": "当前订阅", "tariff": "套餐", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 3aeec94..9e9fbdc 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -15,6 +15,8 @@ import { type UpdateSubscriptionRequest, } from '../api/adminUsers'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin'; +import { promocodesApi, type PromoGroup } from '../api/promocodes'; +import { promoOffersApi } from '../api/promoOffers'; import { ticketsApi } from '../api/tickets'; import { AdminBackButton } from '../components/admin'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; @@ -180,6 +182,15 @@ export default function AdminUserDetail() { const [subDays, setSubDays] = useState(30); const [selectedTariffId, setSelectedTariffId] = useState(null); + // Promo group + const [promoGroups, setPromoGroups] = useState([]); + const [editingPromoGroup, setEditingPromoGroup] = useState(false); + + // Send promo offer + const [offerDiscountPercent, setOfferDiscountPercent] = useState(''); + const [offerValidHours, setOfferValidHours] = useState(24); + const [offerSending, setOfferSending] = useState(false); + const userId = id ? parseInt(id, 10) : null; const loadUser = useCallback(async () => { @@ -282,6 +293,15 @@ export default function AdminUserDetail() { await Promise.all([loadPanelInfo(), loadNodeUsage()]); }, [loadPanelInfo, loadNodeUsage]); + const loadPromoGroups = useCallback(async () => { + try { + const data = await promocodesApi.getPromoGroups({ limit: 100 }); + setPromoGroups(data.items); + } catch { + // ignore + } + }, []); + const handleTicketReply = async () => { if (!selectedTicketId || !replyText.trim()) return; setReplySending(true); @@ -332,14 +352,25 @@ export default function AdminUserDetail() { }, [userId, loadUser, navigate]); useEffect(() => { - if (activeTab === 'info') loadReferrals(); + if (activeTab === 'info') { + loadReferrals(); + loadPromoGroups(); + } if (activeTab === 'sync') loadSyncStatus(); if (activeTab === 'subscription') { loadTariffs(); loadSubscriptionData(); } if (activeTab === 'tickets') loadTickets(); - }, [activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadSubscriptionData]); + }, [ + activeTab, + loadSyncStatus, + loadTariffs, + loadTickets, + loadReferrals, + loadSubscriptionData, + loadPromoGroups, + ]); const handleUpdateBalance = async (isAdd: boolean) => { if (balanceAmount === '' || !userId) return; @@ -364,17 +395,16 @@ export default function AdminUserDetail() { } }; - const handleUpdateSubscription = async () => { + const handleUpdateSubscription = async (overrideAction?: string) => { if (!userId) return; setActionLoading(true); try { + const action = overrideAction || subAction; const data: UpdateSubscriptionRequest = { - action: subAction as UpdateSubscriptionRequest['action'], - ...(subAction === 'extend' ? { days: toNumber(subDays, 30) } : {}), - ...(subAction === 'change_tariff' && selectedTariffId - ? { tariff_id: selectedTariffId } - : {}), - ...(subAction === 'create' + action: action as UpdateSubscriptionRequest['action'], + ...(action === 'extend' ? { days: toNumber(subDays, 30) } : {}), + ...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}), + ...(action === 'create' ? { days: toNumber(subDays, 30), ...(selectedTariffId ? { tariff_id: selectedTariffId } : {}), @@ -451,7 +481,7 @@ export default function AdminUserDetail() { if (confirmingAction === actionKey) { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); setConfirmingAction(null); - executeFn(); + executeFn().catch(() => {}); } else { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); setConfirmingAction(actionKey); @@ -459,6 +489,57 @@ export default function AdminUserDetail() { } }; + const handleChangePromoGroup = async (groupId: number | null) => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.updatePromoGroup(userId, groupId); + await loadUser(); + setEditingPromoGroup(false); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleDeactivateOffer = async () => { + if (!userId) return; + setActionLoading(true); + try { + await promocodesApi.deactivateDiscount(userId); + notify.success(t('admin.users.detail.offerDeactivated'), t('common.success')); + await loadUser(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleSendOffer = async () => { + if (!userId || offerDiscountPercent === '' || offerValidHours === '') return; + setOfferSending(true); + try { + await promoOffersApi.broadcastOffer({ + user_id: userId, + notification_type: 'admin_personal', + discount_percent: toNumber(offerDiscountPercent), + valid_hours: toNumber(offerValidHours, 24), + effect_type: 'percent_discount', + send_notification: true, + }); + notify.success(t('admin.users.detail.offerSent'), t('common.success')); + setOfferDiscountPercent(''); + setOfferValidHours(24); + await loadUser(); + } catch { + notify.error(t('admin.users.detail.offerSendError'), t('common.error')); + } finally { + setOfferSending(false); + } + }; + const handleResetTrial = async () => { if (!userId) return; setActionLoading(true); @@ -717,6 +798,56 @@ export default function AdminUserDetail() { )} + {/* Promo Group */} +
+
+ {t('admin.users.detail.promoGroup')} + +
+ {editingPromoGroup ? ( +
+ + {user.promo_group && ( + + )} +
+ ) : ( +
+ {user.promo_group?.name || ( + {t('admin.users.detail.noPromoGroup')} + )} +
+ )} +
+ {/* Referral */}
@@ -982,7 +1113,7 @@ export default function AdminUserDetail() { )}
+ {/* Active promo offer */} + {user.promo_offer_discount_percent > 0 && ( +
+
+ + {t('admin.users.detail.activePromoOffer')} + + +
+
+
+
+ {user.promo_offer_discount_percent}% +
+
{t('admin.users.detail.discount')}
+
+
+
+ {user.promo_offer_discount_source || '-'} +
+
{t('admin.users.detail.source')}
+
+
+
+ {user.promo_offer_discount_expires_at + ? formatDate(user.promo_offer_discount_expires_at) + : '-'} +
+
{t('admin.users.detail.expiresAt')}
+
+
+
+ )} + + {/* Send promo offer */} +
+
+ {t('admin.users.detail.sendOffer')} +
+
+ + + +
+
+ {/* Recent transactions */} {user.recent_transactions.length > 0 && (