mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add promo group and promo offer management to AdminUserDetail
- Info tab: promo group card with inline dropdown to change/remove group - Balance tab: active promo offer display with deactivate button - Balance tab: send promo offer form (discount %, valid hours) - Add deactivateDiscount API method to promocodesApi - Add locale keys for ru/en/zh/fa - Fix race condition in subscription create (stale state read) - Fix unhandled promise in handleInlineConfirm - Remove unused legacy adminUsers block from ru.json
This commit is contained in:
@@ -183,4 +183,12 @@ export const promocodesApi = {
|
||||
deletePromoGroup: async (id: number): Promise<void> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "تعرفه",
|
||||
|
||||
@@ -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": "Информация об аккаунте",
|
||||
|
||||
@@ -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": "套餐",
|
||||
|
||||
@@ -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<number | ''>(30);
|
||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||
|
||||
// Promo group
|
||||
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
|
||||
const [editingPromoGroup, setEditingPromoGroup] = useState(false);
|
||||
|
||||
// Send promo offer
|
||||
const [offerDiscountPercent, setOfferDiscountPercent] = useState<number | ''>('');
|
||||
const [offerValidHours, setOfferValidHours] = useState<number | ''>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Promo Group */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs text-dark-500">{t('admin.users.detail.promoGroup')}</span>
|
||||
<button
|
||||
onClick={() => setEditingPromoGroup(!editingPromoGroup)}
|
||||
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
{editingPromoGroup
|
||||
? t('common.cancel')
|
||||
: t('admin.users.detail.changePromoGroup')}
|
||||
</button>
|
||||
</div>
|
||||
{editingPromoGroup ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<select
|
||||
value={user.promo_group?.id ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
handleChangePromoGroup(val ? parseInt(val, 10) : null);
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="input text-sm"
|
||||
>
|
||||
<option value="">{t('admin.users.detail.selectPromoGroup')}</option>
|
||||
{promoGroups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{user.promo_group && (
|
||||
<button
|
||||
onClick={() => handleChangePromoGroup(null)}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-dark-700 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
{t('admin.users.detail.removePromoGroup')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
{user.promo_group?.name || (
|
||||
<span className="text-dark-500">{t('admin.users.detail.noPromoGroup')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Referral */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||
@@ -982,7 +1113,7 @@ export default function AdminUserDetail() {
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpdateSubscription}
|
||||
onClick={() => handleUpdateSubscription()}
|
||||
disabled={actionLoading}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
@@ -1023,10 +1154,7 @@ export default function AdminUserDetail() {
|
||||
max={3650}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubAction('create');
|
||||
handleUpdateSubscription();
|
||||
}}
|
||||
onClick={() => handleUpdateSubscription('create')}
|
||||
disabled={actionLoading}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
@@ -1314,6 +1442,86 @@ export default function AdminUserDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active promo offer */}
|
||||
{user.promo_offer_discount_percent > 0 && (
|
||||
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-accent-400">
|
||||
{t('admin.users.detail.activePromoOffer')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleInlineConfirm('deactivateOffer', handleDeactivateOffer)}
|
||||
disabled={actionLoading}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-medium transition-all disabled:opacity-50 ${
|
||||
confirmingAction === 'deactivateOffer'
|
||||
? 'bg-error-500 text-white'
|
||||
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
|
||||
}`}
|
||||
>
|
||||
{confirmingAction === 'deactivateOffer'
|
||||
? t('admin.users.detail.actions.areYouSure')
|
||||
: t('admin.users.detail.deactivateOffer')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.promo_offer_discount_percent}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.users.detail.discount')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
{user.promo_offer_discount_source || '-'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.users.detail.source')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
{user.promo_offer_discount_expires_at
|
||||
? formatDate(user.promo_offer_discount_expires_at)
|
||||
: '-'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.users.detail.expiresAt')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Send promo offer */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||
{t('admin.users.detail.sendOffer')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="number"
|
||||
value={offerDiscountPercent}
|
||||
onChange={createNumberInputHandler(setOfferDiscountPercent, 1)}
|
||||
placeholder={t('admin.users.detail.discountPercent')}
|
||||
className="input"
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={offerValidHours}
|
||||
onChange={createNumberInputHandler(setOfferValidHours, 1)}
|
||||
placeholder={t('admin.users.detail.validHours')}
|
||||
className="input"
|
||||
min={1}
|
||||
max={8760}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendOffer}
|
||||
disabled={offerSending || offerDiscountPercent === '' || offerValidHours === ''}
|
||||
className="btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{offerSending ? t('common.loading') : t('admin.users.detail.sendOffer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent transactions */}
|
||||
{user.recent_transactions.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
|
||||
Reference in New Issue
Block a user