From e32663f291bb9e4a374f3edbe3612b2e6a0a929e Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 29 Mar 2026 03:43:47 +0300 Subject: [PATCH] feat: add Referrals tab to admin user detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Рефералы tab with three sections: - Referred By: shows who referred this user, assign/remove referrer - Stats grid: total referrals, earnings, commission, referral code - Referrals list: all referred users with navigate and remove buttons - User search with debounce for assigning referrer - Shows warning when selected user already has a referrer - API methods: assignReferrer, removeReferrer, removeReferral - i18n translations for all 4 locales (en, ru, zh, fa) --- src/api/adminUsers.ts | 28 +++ src/locales/en.json | 23 ++- src/locales/fa.json | 23 ++- src/locales/ru.json | 23 ++- src/locales/zh.json | 23 ++- src/pages/AdminUserDetail.tsx | 330 +++++++++++++++++++++++++++++++++- 6 files changed, 444 insertions(+), 6 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 6a7735f..3a0a23a 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -536,6 +536,34 @@ export const adminUsersApi = { return response.data; }, + // Assign a referrer to this user + assignReferrer: async ( + userId: number, + referrerId: number, + ): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/assign-referrer`, { + referrer_id: referrerId, + }); + return response.data; + }, + + // Remove this user's referrer + removeReferrer: async (userId: number): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.delete(`/cabinet/admin/users/${userId}/referrer`); + return response.data; + }, + + // Remove a specific referral from this user's list + removeReferral: async ( + userId: number, + referralUserId: number, + ): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.delete( + `/cabinet/admin/users/${userId}/referrals/${referralUserId}`, + ); + return response.data; + }, + // Get referrals getReferrals: async (userId: number, offset = 0, limit = 50): Promise => { const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, { diff --git a/src/locales/en.json b/src/locales/en.json index f0f2061..2885ce5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2940,7 +2940,8 @@ "balance": "Balance", "sync": "Synchronization", "tickets": "Tickets", - "gifts": "Gifts" + "gifts": "Gifts", + "referrals": "Referrals" }, "noTickets": "No tickets from this user", "ticketsCount": "tickets", @@ -3100,6 +3101,26 @@ "unknownUser": "Unknown", "totalSent": "Total sent", "totalReceived": "Total received" + }, + "referrals": { + "referredBy": "Referred By", + "noReferrer": "No referrer assigned", + "assignReferrer": "Assign Referrer", + "removeReferrer": "Remove", + "referrerAssigned": "Referrer assigned successfully", + "referrerRemoved": "Referrer removed successfully", + "totalReferrals": "Total Referrals", + "totalEarnings": "Total Earnings", + "commission": "Commission", + "referralCode": "Referral Code", + "default": "default", + "referralsList": "Referrals", + "noReferrals": "No referrals yet", + "removeReferral": "Remove referral", + "referralRemoved": "Referral removed successfully", + "searchPlaceholder": "Search by name, email, or Telegram ID...", + "alreadyReferred": "already has referrer", + "noUsersFound": "No users found" } } }, diff --git a/src/locales/fa.json b/src/locales/fa.json index ad623d6..950ec6a 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2562,7 +2562,8 @@ "balance": "موجودی", "sync": "همگام‌سازی", "tickets": "تیکت‌ها", - "gifts": "هدایا" + "gifts": "هدایا", + "referrals": "معرفی‌ها" }, "noTickets": "این کاربر تیکتی ندارد", "ticketsCount": "تیکت", @@ -2718,6 +2719,26 @@ "unknownUser": "ناشناس", "totalSent": "کل ارسال شده", "totalReceived": "کل دریافت شده" + }, + "referrals": { + "referredBy": "معرفی‌شده توسط", + "noReferrer": "معرفی‌کننده‌ای تعیین نشده", + "assignReferrer": "تعیین معرفی‌کننده", + "removeReferrer": "حذف", + "referrerAssigned": "معرفی‌کننده تعیین شد", + "referrerRemoved": "معرفی‌کننده حذف شد", + "totalReferrals": "کل معرفی‌ها", + "totalEarnings": "کل درآمد", + "commission": "کمیسیون", + "referralCode": "کد معرفی", + "default": "پیش‌فرض", + "referralsList": "لیست معرفی‌ها", + "noReferrals": "هنوز معرفی‌ای نیست", + "removeReferral": "حذف معرفی", + "referralRemoved": "معرفی حذف شد", + "searchPlaceholder": "جستجو بر اساس نام، ایمیل یا شناسه تلگرام...", + "alreadyReferred": "قبلاً معرفی شده", + "noUsersFound": "کاربری یافت نشد" } } }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 5046425..ef17dc3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3452,7 +3452,8 @@ "balance": "Баланс", "sync": "Синхронизация", "tickets": "Тикеты", - "gifts": "Подарки" + "gifts": "Подарки", + "referrals": "Рефералы" }, "noTickets": "У пользователя нет тикетов", "ticketsCount": "тикетов", @@ -3611,6 +3612,26 @@ "unknownUser": "Неизвестный", "totalSent": "Всего отправлено", "totalReceived": "Всего получено" + }, + "referrals": { + "referredBy": "Приглашён", + "noReferrer": "Реферер не назначен", + "assignReferrer": "Назначить реферера", + "removeReferrer": "Удалить", + "referrerAssigned": "Реферер назначен", + "referrerRemoved": "Реферер удалён", + "totalReferrals": "Всего рефералов", + "totalEarnings": "Всего заработано", + "commission": "Комиссия", + "referralCode": "Реферальный код", + "default": "стандарт", + "referralsList": "Рефералы", + "noReferrals": "Рефералов пока нет", + "removeReferral": "Удалить реферала", + "referralRemoved": "Реферал удалён", + "searchPlaceholder": "Поиск по имени, email или Telegram ID...", + "alreadyReferred": "уже чей-то реферал", + "noUsersFound": "Пользователи не найдены" } } }, diff --git a/src/locales/zh.json b/src/locales/zh.json index 478f57b..712540c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2561,7 +2561,8 @@ "balance": "余额", "sync": "同步", "tickets": "工单", - "gifts": "礼物" + "gifts": "礼物", + "referrals": "推荐" }, "noTickets": "该用户没有工单", "ticketsCount": "个工单", @@ -2717,6 +2718,26 @@ "unknownUser": "未知", "totalSent": "已发送总数", "totalReceived": "已收到总数" + }, + "referrals": { + "referredBy": "推荐人", + "noReferrer": "未分配推荐人", + "assignReferrer": "分配推荐人", + "removeReferrer": "移除", + "referrerAssigned": "推荐人已分配", + "referrerRemoved": "推荐人已移除", + "totalReferrals": "总推荐数", + "totalEarnings": "总收益", + "commission": "佣金", + "referralCode": "推荐码", + "default": "默认", + "referralsList": "推荐列表", + "noReferrals": "暂无推荐", + "removeReferral": "移除推荐", + "referralRemoved": "推荐已移除", + "searchPlaceholder": "按名称、邮箱或 Telegram ID 搜索...", + "alreadyReferred": "已有推荐人", + "noUsersFound": "未找到用户" } } }, diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 0202b51..bdbe566 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -296,7 +296,7 @@ export default function AdminUserDetail() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState< - 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' + 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals' >('info'); const [syncStatus, setSyncStatus] = useState(null); const [tariffs, setTariffs] = useState([]); @@ -306,6 +306,15 @@ export default function AdminUserDetail() { const [referrals, setReferrals] = useState([]); const [referralsLoading, setReferralsLoading] = useState(false); + // Referrals tab state + const [referralsList, setReferralsList] = useState([]); + const [referralsTotal, setReferralsTotal] = useState(0); + const [referralsListLoading, setReferralsListLoading] = useState(false); + const [referrerSearchQuery, setReferrerSearchQuery] = useState(''); + const [showReferrerSearch, setShowReferrerSearch] = useState(false); + const [referrerSearchResults, setReferrerSearchResults] = useState([]); + const referrerSearchRef = useRef(null); + // Panel info & node usage const [panelInfo, setPanelInfo] = useState(null); const [panelInfoLoading, setPanelInfoLoading] = useState(false); @@ -441,6 +450,20 @@ export default function AdminUserDetail() { } }, [userId]); + const loadReferralsList = useCallback(async () => { + if (!userId) return; + setReferralsListLoading(true); + try { + const data = await adminUsersApi.getReferrals(userId, 0, 100); + setReferralsList(data.users || []); + setReferralsTotal(data.total || 0); + } catch { + // silent + } finally { + setReferralsListLoading(false); + } + }, [userId]); + const loadPanelInfo = useCallback(async () => { if (!userId) return; try { @@ -559,6 +582,7 @@ export default function AdminUserDetail() { } if (activeTab === 'tickets') loadTickets(); if (activeTab === 'gifts') loadGifts(); + if (activeTab === 'referrals') loadReferralsList(); }, [ activeTab, loadSyncStatus, @@ -568,6 +592,7 @@ export default function AdminUserDetail() { loadSubscriptionData, loadPromoGroups, loadGifts, + loadReferralsList, hasPermission, ]); @@ -872,6 +897,90 @@ export default function AdminUserDetail() { } }; + // Referrals tab: assign referrer + const handleAssignReferrer = async (referrerId: number) => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.assignReferrer(userId, referrerId); + await loadUser(); + setShowReferrerSearch(false); + setReferrerSearchQuery(''); + setReferrerSearchResults([]); + notify.success(t('admin.users.detail.referrals.referrerAssigned')); + } catch (error: unknown) { + const axiosErr = error as { response?: { data?: { detail?: string } } }; + notify.error(axiosErr?.response?.data?.detail || t('common.error')); + } finally { + setActionLoading(false); + } + }; + + // Referrals tab: remove referrer + const handleRemoveReferrer = async () => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.removeReferrer(userId); + await loadUser(); + notify.success(t('admin.users.detail.referrals.referrerRemoved')); + } catch (error: unknown) { + const axiosErr = error as { response?: { data?: { detail?: string } } }; + notify.error(axiosErr?.response?.data?.detail || t('common.error')); + } finally { + setActionLoading(false); + } + }; + + // Referrals tab: remove a referral + const handleRemoveReferral = async (referralUserId: number) => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.removeReferral(userId, referralUserId); + await loadReferralsList(); + await loadUser(); + notify.success(t('admin.users.detail.referrals.referralRemoved')); + } catch (error: unknown) { + const axiosErr = error as { response?: { data?: { detail?: string } } }; + notify.error(axiosErr?.response?.data?.detail || t('common.error')); + } finally { + setActionLoading(false); + } + }; + + // Referrals tab: debounced user search for referrer assignment + useEffect(() => { + if (referrerSearchQuery.length < 2 || !showReferrerSearch) { + setReferrerSearchResults([]); + return; + } + const timer = setTimeout(async () => { + try { + const data = await adminUsersApi.getUsers({ search: referrerSearchQuery, limit: 10 }); + setReferrerSearchResults(data.users || []); + } catch { + setReferrerSearchResults([]); + } + }, 300); + return () => clearTimeout(timer); + }, [referrerSearchQuery, showReferrerSearch]); + + // Referrals tab: close search dropdown on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (referrerSearchRef.current && !referrerSearchRef.current.contains(e.target as Node)) { + setShowReferrerSearch(false); + setReferrerSearchQuery(''); + setReferrerSearchResults([]); + } + }; + if (showReferrerSearch) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showReferrerSearch]); + const handleResetTrial = async () => { if (!userId) return; setActionLoading(true); @@ -1033,7 +1142,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', 'gifts'] as const) + {(['info', 'subscription', 'balance', 'sync', 'tickets', 'gifts', 'referrals'] as const) .filter((tab) => tab !== 'sync' || hasPermission('users:sync')) .map((tab) => ( + + + ) : ( +
+ {showReferrerSearch ? ( +
+
+ setReferrerSearchQuery(e.target.value)} + placeholder={t('admin.users.detail.referrals.searchPlaceholder')} + className="flex-1 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none" + autoFocus + /> + +
+ {referrerSearchQuery.length >= 2 && referrerSearchResults.length > 0 && ( +
+ {referrerSearchResults + .filter((u) => u.id !== userId) + .map((u) => ( + + ))} + {referrerSearchResults.filter((u) => u.id !== userId).length === 0 && ( +
+ {t('admin.users.detail.referrals.noUsersFound')} +
+ )} +
+ )} + {referrerSearchQuery.length >= 2 && referrerSearchResults.length === 0 && ( +
+ {t('admin.users.detail.referrals.noUsersFound')} +
+ )} +
+ ) : ( +
+ + {t('admin.users.detail.referrals.noReferrer')} + + +
+ )} +
+ )} + + + {/* Section 2: Referral stats */} +
+
+
+ {t('admin.users.detail.referrals.totalReferrals')} +
+
+ {user.referral.referrals_count} +
+
+
+
+ {t('admin.users.detail.referrals.totalEarnings')} +
+
+ {formatWithCurrency(user.referral.total_earnings_kopeks / 100)} +
+
+
+
+ {t('admin.users.detail.referrals.commission')} +
+
+ {user.referral.commission_percent ?? t('admin.users.detail.referrals.default')}% +
+
+
+
+ {t('admin.users.detail.referrals.referralCode')} +
+
+ {user.referral.referral_code} +
+
+
+ + {/* Section 3: Referrals list */} +
+
+

+ {t('admin.users.detail.referrals.referralsList')} ({referralsTotal}) +

+
+ + {referralsListLoading ? ( +
+
+
+ ) : referralsList.length === 0 ? ( +
+ {t('admin.users.detail.referrals.noReferrals')} +
+ ) : ( +
+ {referralsList.map((ref) => ( +
+ + +
+ ))} +
+ )} +
+
+ )} );