diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 1e88233..f64b685 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -351,6 +351,36 @@ export interface SyncToPanelRequest { update_squads?: boolean; } +export interface AdminUserGiftItem { + id: number; + token: string; + status: string; + tariff_name: string | null; + period_days: number; + device_limit: number; + amount_kopeks: number; + payment_method: string | null; + gift_recipient_type: string | null; + gift_recipient_value: string | null; + gift_message: string | null; + buyer_user_id: number | null; + buyer_username: string | null; + buyer_full_name: string | null; + receiver_user_id: number | null; + receiver_username: string | null; + receiver_full_name: string | null; + created_at: string | null; + paid_at: string | null; + delivered_at: string | null; +} + +export interface AdminUserGiftsResponse { + sent: AdminUserGiftItem[]; + received: AdminUserGiftItem[]; + sent_total: number; + received_total: number; +} + // ============ API ============ export const adminUsersApi = { @@ -614,4 +644,10 @@ export const adminUsersApi = { const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`); return response.data; }, + + // Get user gifts + getUserGifts: async (userId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/gifts`); + return response.data; + }, }; diff --git a/src/api/gift.ts b/src/api/gift.ts index 72c7d89..3a607d9 100644 --- a/src/api/gift.ts +++ b/src/api/gift.ts @@ -40,6 +40,9 @@ export interface GiftConfig { payment_methods: GiftPaymentMethod[]; balance_kopeks: number; currency_symbol: string; + promo_group_name: string | null; + active_discount_percent: number | null; + active_discount_expires_at: string | null; } export interface GiftPurchaseRequest { diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index fbc10f8..80abd12 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useLocation, Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -269,31 +269,6 @@ export function AppShell({ children }: AppShellProps) { haptic.impact('light'); }; - // Desktop nav scroll fade indicators - const navRef = useRef(null); - const [navCanScrollLeft, setNavCanScrollLeft] = useState(false); - const [navCanScrollRight, setNavCanScrollRight] = useState(false); - - const updateNavScroll = useCallback(() => { - const el = navRef.current; - if (!el) return; - setNavCanScrollLeft(el.scrollLeft > 2); - setNavCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2); - }, []); - - useEffect(() => { - const el = navRef.current; - if (!el) return; - updateNavScroll(); - el.addEventListener('scroll', updateNavScroll, { passive: true }); - const ro = new ResizeObserver(updateNavScroll); - ro.observe(el); - return () => { - el.removeEventListener('scroll', updateNavScroll); - ro.disconnect(); - }; - }, [updateNavScroll]); - // Calculate header height based on fullscreen mode (only on mobile Telegram) // On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header // On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px) @@ -342,89 +317,80 @@ export function AppShell({ children }: AppShellProps) { {/* Center Navigation */} -
- {/* Left fade */} -
- {/* Right fade */} -
-
+ + )} + {/* Right side actions */}
diff --git a/src/locales/en.json b/src/locales/en.json index 0df50cd..710abaf 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2662,7 +2662,8 @@ "subscription": "Subscription", "balance": "Balance", "sync": "Synchronization", - "tickets": "Tickets" + "tickets": "Tickets", + "gifts": "Gifts" }, "noTickets": "No tickets from this user", "ticketsCount": "tickets", @@ -2786,6 +2787,41 @@ "notLinked": "Not linked", "fromPanel": "From panel to bot", "toPanel": "From bot to panel" + }, + "gifts": { + "title": "User Gifts", + "sentTitle": "Sent Gifts", + "receivedTitle": "Received Gifts", + "noSent": "No gifts sent by this user", + "noReceived": "No gifts received by this user", + "noGifts": "No gifts", + "token": "Code", + "tariff": "Tariff", + "period": "Period", + "devices": "Devices", + "amount": "Amount", + "paymentMethod": "Payment method", + "recipient": "Recipient", + "sender": "Sender", + "message": "Message", + "createdAt": "Created", + "paidAt": "Paid", + "deliveredAt": "Activated", + "status": { + "pending": "Pending", + "paid": "Paid", + "delivered": "Delivered", + "pending_activation": "Pending activation", + "failed": "Failed", + "expired": "Expired" + }, + "days": "d", + "codeOnly": "Code only", + "via": "via", + "balance": "balance", + "unknownUser": "Unknown", + "totalSent": "Total sent", + "totalReceived": "Total received" } } }, diff --git a/src/locales/fa.json b/src/locales/fa.json index b292fba..83d0a57 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2300,7 +2300,8 @@ "subscription": "اشتراک", "balance": "موجودی", "sync": "همگام‌سازی", - "tickets": "تیکت‌ها" + "tickets": "تیکت‌ها", + "gifts": "هدایا" }, "noTickets": "این کاربر تیکتی ندارد", "ticketsCount": "تیکت", @@ -2420,6 +2421,41 @@ "notLinked": "متصل نشده", "fromPanel": "از پنل به ربات", "toPanel": "از ربات به پنل" + }, + "gifts": { + "title": "هدایای کاربر", + "sentTitle": "هدایای ارسال شده", + "receivedTitle": "هدایای دریافت شده", + "noSent": "این کاربر هدیه‌ای ارسال نکرده است", + "noReceived": "این کاربر هدیه‌ای دریافت نکرده است", + "noGifts": "بدون هدیه", + "token": "کد", + "tariff": "تعرفه", + "period": "دوره", + "devices": "دستگاه‌ها", + "amount": "مبلغ", + "paymentMethod": "روش پرداخت", + "recipient": "گیرنده", + "sender": "فرستنده", + "message": "پیام", + "createdAt": "ایجاد شده", + "paidAt": "پرداخت شده", + "deliveredAt": "فعال شده", + "status": { + "pending": "در انتظار", + "paid": "پرداخت شده", + "delivered": "تحویل داده شده", + "pending_activation": "در انتظار فعال‌سازی", + "failed": "ناموفق", + "expired": "منقضی شده" + }, + "days": "روز", + "codeOnly": "فقط کد", + "via": "از طریق", + "balance": "موجودی", + "unknownUser": "ناشناس", + "totalSent": "کل ارسال شده", + "totalReceived": "کل دریافت شده" } } }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 6b02b9e..f77d001 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3184,7 +3184,8 @@ "subscription": "Подписка", "balance": "Баланс", "sync": "Синхронизация", - "tickets": "Тикеты" + "tickets": "Тикеты", + "gifts": "Подарки" }, "noTickets": "У пользователя нет тикетов", "ticketsCount": "тикетов", @@ -3307,6 +3308,41 @@ "notLinked": "Не привязан", "fromPanel": "Из панели в бота", "toPanel": "Из бота в панель" + }, + "gifts": { + "title": "Подарки пользователя", + "sentTitle": "Отправленные подарки", + "receivedTitle": "Полученные подарки", + "noSent": "Пользователь не отправлял подарков", + "noReceived": "Пользователь не получал подарков", + "noGifts": "Нет подарков", + "token": "Код", + "tariff": "Тариф", + "period": "Период", + "devices": "Устройств", + "amount": "Сумма", + "paymentMethod": "Способ оплаты", + "recipient": "Получатель", + "sender": "Отправитель", + "message": "Сообщение", + "createdAt": "Создан", + "paidAt": "Оплачен", + "deliveredAt": "Активирован", + "status": { + "pending": "Ожидание", + "paid": "Оплачен", + "delivered": "Доставлен", + "pending_activation": "Ожидает активации", + "failed": "Ошибка", + "expired": "Истёк" + }, + "days": "дн.", + "codeOnly": "Только код", + "via": "через", + "balance": "баланс", + "unknownUser": "Неизвестный", + "totalSent": "Всего отправлено", + "totalReceived": "Всего получено" } } }, diff --git a/src/locales/zh.json b/src/locales/zh.json index d5b4e1b..e9fad4d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2299,7 +2299,8 @@ "subscription": "订阅", "balance": "余额", "sync": "同步", - "tickets": "工单" + "tickets": "工单", + "gifts": "礼物" }, "noTickets": "该用户没有工单", "ticketsCount": "个工单", @@ -2419,6 +2420,41 @@ "notLinked": "未绑定", "fromPanel": "从面板同步到机器人", "toPanel": "从机器人同步到面板" + }, + "gifts": { + "title": "用户礼物", + "sentTitle": "已发送的礼物", + "receivedTitle": "已收到的礼物", + "noSent": "该用户未发送过礼物", + "noReceived": "该用户未收到过礼物", + "noGifts": "没有礼物", + "token": "代码", + "tariff": "套餐", + "period": "期限", + "devices": "设备数", + "amount": "金额", + "paymentMethod": "支付方式", + "recipient": "收件人", + "sender": "发送人", + "message": "留言", + "createdAt": "创建时间", + "paidAt": "支付时间", + "deliveredAt": "激活时间", + "status": { + "pending": "待处理", + "paid": "已支付", + "delivered": "已送达", + "pending_activation": "待激活", + "failed": "失败", + "expired": "已过期" + }, + "days": "天", + "codeOnly": "仅代码", + "via": "通过", + "balance": "余额", + "unknownUser": "未知", + "totalSent": "已发送总数", + "totalReceived": "已收到总数" } } }, diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 7bf378d..35c14d1 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -13,6 +13,7 @@ import { type UserNodeUsageResponse, type PanelSyncStatusResponse, type UpdateSubscriptionRequest, + type AdminUserGiftsResponse, } from '../api/adminUsers'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin'; import { promocodesApi, type PromoGroup } from '../api/promocodes'; @@ -128,6 +129,156 @@ function StatusBadge({ status }: { status: string }) { ); } +function GiftStatusBadge({ status }: { status: string }) { + const { t } = useTranslation(); + const styles: Record = { + pending: 'bg-warning-500/20 text-warning-400 border-warning-500/30', + paid: 'bg-accent-500/20 text-accent-400 border-accent-500/30', + delivered: 'bg-success-500/20 text-success-400 border-success-500/30', + pending_activation: 'bg-accent-500/20 text-accent-400 border-accent-500/30', + failed: 'bg-error-500/20 text-error-400 border-error-500/30', + expired: 'bg-dark-600 text-dark-400 border-dark-500', + }; + const fallback = 'bg-dark-600 text-dark-400 border-dark-500'; + + const label = t(`admin.users.detail.gifts.status.${status}`, { defaultValue: '' }) || status; + + return ( + + {label} + + ); +} + +function GiftCard({ + gift, + direction, + locale, + onNavigateToUser, +}: { + gift: import('../api/adminUsers').AdminUserGiftItem; + direction: 'sent' | 'received'; + locale: string; + onNavigateToUser: (userId: number) => void; +}) { + const { t } = useTranslation(); + const { formatWithCurrency } = useCurrency(); + const isSent = direction === 'sent'; + + const otherPartyLabel = isSent + ? t('admin.users.detail.gifts.recipient') + : t('admin.users.detail.gifts.sender'); + const otherPartyName = isSent + ? gift.receiver_username + ? `@${gift.receiver_username}` + : gift.gift_recipient_value || t('admin.users.detail.gifts.codeOnly') + : gift.buyer_username + ? `@${gift.buyer_username}` + : gift.buyer_full_name || t('admin.users.detail.gifts.unknownUser'); + const otherPartyId = isSent ? gift.receiver_user_id : gift.buyer_user_id; + + const dateOpts: Intl.DateTimeFormatOptions = { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }; + + return ( +
+ {/* Header */} +
+
+
+ + + +
+
+
{gift.tariff_name || '—'}
+
+ {gift.period_days} {t('admin.users.detail.gifts.days')} · {gift.device_limit}{' '} + {t('admin.users.detail.gifts.devices')} +
+
+
+ +
+ + {/* Details grid */} +
+
+ {otherPartyLabel}:{' '} + {otherPartyName} + {otherPartyId && ( + + )} +
+
+ {t('admin.users.detail.gifts.amount')}:{' '} + {formatWithCurrency(gift.amount_kopeks / 100)} +
+
+ {t('admin.users.detail.gifts.paymentMethod')}:{' '} + {gift.payment_method || '—'} +
+
+ {t('admin.users.detail.gifts.createdAt')}:{' '} + + {gift.created_at ? new Date(gift.created_at).toLocaleString(locale, dateOpts) : '—'} + +
+ {gift.paid_at && ( +
+ {t('admin.users.detail.gifts.paidAt')}:{' '} + + {new Date(gift.paid_at).toLocaleString(locale, dateOpts)} + +
+ )} + {gift.delivered_at && ( +
+ {t('admin.users.detail.gifts.deliveredAt')}:{' '} + + {new Date(gift.delivered_at).toLocaleString(locale, dateOpts)} + +
+ )} +
+ + {/* Gift message */} + {gift.gift_message && ( +
+ “{gift.gift_message}” +
+ )} + + {/* Token */} +
GIFT-{gift.token}
+
+ ); +} + // ============ Main Page ============ export default function AdminUserDetail() { @@ -144,7 +295,7 @@ export default function AdminUserDetail() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState< - 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' + 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' >('info'); const [syncStatus, setSyncStatus] = useState(null); const [tariffs, setTariffs] = useState([]); @@ -208,6 +359,10 @@ export default function AdminUserDetail() { const [deviceLimit, setDeviceLimit] = useState(0); const [devicesLoading, setDevicesLoading] = useState(false); + // Gifts + const [giftsData, setGiftsData] = useState(null); + const [giftsLoading, setGiftsLoading] = useState(false); + const userId = id ? parseInt(id, 10) : null; const loadUser = useCallback(async () => { @@ -325,6 +480,19 @@ export default function AdminUserDetail() { await Promise.all([loadPanelInfo(), loadNodeUsage(), loadDevices()]); }, [loadPanelInfo, loadNodeUsage, loadDevices]); + const loadGifts = useCallback(async () => { + if (!userId) return; + try { + setGiftsLoading(true); + const data = await adminUsersApi.getUserGifts(userId); + setGiftsData(data); + } catch { + // ignore + } finally { + setGiftsLoading(false); + } + }, [userId]); + const loadPromoGroups = useCallback(async () => { try { const data = await promocodesApi.getPromoGroups({ limit: 100 }); @@ -394,6 +562,7 @@ export default function AdminUserDetail() { loadSubscriptionData(); } if (activeTab === 'tickets') loadTickets(); + if (activeTab === 'gifts') loadGifts(); }, [ activeTab, loadSyncStatus, @@ -402,6 +571,7 @@ export default function AdminUserDetail() { loadReferrals, loadSubscriptionData, loadPromoGroups, + loadGifts, hasPermission, ]); @@ -838,7 +1008,7 @@ export default function AdminUserDetail() { className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1" style={{ WebkitOverflowScrolling: 'touch' }} > - {(['info', 'subscription', 'balance', 'sync', 'tickets'] as const) + {(['info', 'subscription', 'balance', 'sync', 'tickets', 'gifts'] as const) .filter((tab) => tab !== 'sync' || hasPermission('users:sync')) .map((tab) => (
@@ -2357,6 +2528,130 @@ export default function AdminUserDetail() { )}
)} + + {/* Gifts Tab */} + {activeTab === 'gifts' && ( +
+ {giftsLoading ? ( +
+
+
+ ) : !giftsData || (giftsData.sent.length === 0 && giftsData.received.length === 0) ? ( +
+ + + +

{t('admin.users.detail.gifts.noGifts')}

+
+ ) : ( + <> + {/* Summary counters */} +
+
+
+ {t('admin.users.detail.gifts.totalSent')} +
+
{giftsData.sent_total}
+
+
+
+ {t('admin.users.detail.gifts.totalReceived')} +
+
+ {giftsData.received_total} +
+
+
+ + {/* Sent Gifts */} +
+

+ + + + {t('admin.users.detail.gifts.sentTitle')} + ({giftsData.sent_total}) +

+ {giftsData.sent.length === 0 ? ( +
+ {t('admin.users.detail.gifts.noSent')} +
+ ) : ( +
+ {giftsData.sent.map((gift) => ( + navigate(`/admin/users/${id}`)} + /> + ))} +
+ )} +
+ + {/* Received Gifts */} +
+

+ + + + {t('admin.users.detail.gifts.receivedTitle')} + ({giftsData.received_total}) +

+ {giftsData.received.length === 0 ? ( +
+ {t('admin.users.detail.gifts.noReceived')} +
+ ) : ( +
+ {giftsData.received.map((gift) => ( + navigate(`/admin/users/${id}`)} + /> + ))} +
+ )} +
+ + )} +
+ )}
); diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index d22e5fe..83a251e 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -334,10 +334,15 @@ export default function Balance() { animate="animate" > {transactions.items.map((tx) => { - const isPositive = tx.amount_rubles >= 0; + const isZero = tx.amount_rubles === 0; + const isPositive = tx.amount_rubles > 0; const displayAmount = Math.abs(tx.amount_rubles); - const sign = isPositive ? '+' : '-'; - const colorClass = isPositive ? 'text-success-400' : 'text-error-400'; + const sign = isZero ? '' : isPositive ? '+' : '-'; + const colorClass = isZero + ? 'text-dark-400' + : isPositive + ? 'text-success-400' + : 'text-error-400'; return ( )} + {/* Promo group banner */} + {config.promo_group_name && ( +
+
+ + + +
+
+
+ {t('subscription.promoGroup.yourGroup', { name: config.promo_group_name })} +
+
+ {t('subscription.promoGroup.personalDiscountsApplied')} +
+
+
+ )} + + {/* Active discount banner */} + {config.active_discount_percent != null && config.active_discount_percent > 0 && ( +
+
+ + + +
+
+ {t('promo.discountApplied')} -{config.active_discount_percent}% +
+
+ )} + {/* Period selection */} {periodsForDisplay.length > 0 && (