feat: add "Add Referral" button to referrals tab

- New search UI in referrals list section to find and bind users
  as this user's referrals (calls assign-referrer on target user)
- Debounced search with cancelled flag and loading state
- Click-outside to close dropdown
- i18n: addReferral, referralAdded keys in all 4 locales
This commit is contained in:
Fringg
2026-03-29 04:02:40 +03:00
parent 63f7fa0a31
commit 222a1239ef
5 changed files with 153 additions and 0 deletions

View File

@@ -3118,6 +3118,8 @@
"noReferrals": "No referrals yet",
"removeReferral": "Remove referral",
"referralRemoved": "Referral removed successfully",
"addReferral": "Add Referral",
"referralAdded": "Referral added successfully",
"searchPlaceholder": "Search by name, email, or Telegram ID...",
"alreadyReferred": "already has referrer",
"noUsersFound": "No users found"

View File

@@ -2736,6 +2736,8 @@
"noReferrals": "هنوز معرفی‌ای نیست",
"removeReferral": "حذف معرفی",
"referralRemoved": "معرفی حذف شد",
"addReferral": "افزودن معرفی",
"referralAdded": "معرفی اضافه شد",
"searchPlaceholder": "جستجو بر اساس نام، ایمیل یا شناسه تلگرام...",
"alreadyReferred": "قبلاً معرفی شده",
"noUsersFound": "کاربری یافت نشد"

View File

@@ -3629,6 +3629,8 @@
"noReferrals": "Рефералов пока нет",
"removeReferral": "Удалить реферала",
"referralRemoved": "Реферал удалён",
"addReferral": "Добавить реферала",
"referralAdded": "Реферал добавлен",
"searchPlaceholder": "Поиск по имени, email или Telegram ID...",
"alreadyReferred": "уже чей-то реферал",
"noUsersFound": "Пользователи не найдены"

View File

@@ -2735,6 +2735,8 @@
"noReferrals": "暂无推荐",
"removeReferral": "移除推荐",
"referralRemoved": "推荐已移除",
"addReferral": "添加推荐",
"referralAdded": "推荐已添加",
"searchPlaceholder": "按名称、邮箱或 Telegram ID 搜索...",
"alreadyReferred": "已有推荐人",
"noUsersFound": "未找到用户"

View File

@@ -315,6 +315,12 @@ export default function AdminUserDetail() {
const [referrerSearchResults, setReferrerSearchResults] = useState<UserListItem[]>([]);
const [referrerSearchLoading, setReferrerSearchLoading] = useState(false);
const referrerSearchRef = useRef<HTMLDivElement>(null);
// Add referral (bind someone as this user's referral)
const [showAddReferral, setShowAddReferral] = useState(false);
const [addReferralSearchQuery, setAddReferralSearchQuery] = useState('');
const [addReferralSearchResults, setAddReferralSearchResults] = useState<UserListItem[]>([]);
const [addReferralSearchLoading, setAddReferralSearchLoading] = useState(false);
const addReferralSearchRef = useRef<HTMLDivElement>(null);
// Panel info & node usage
const [panelInfo, setPanelInfo] = useState<UserPanelInfo | null>(null);
@@ -950,6 +956,26 @@ export default function AdminUserDetail() {
}
};
// Add a referral: assign current user as referrer of selected user
const handleAddReferral = async (targetUserId: number) => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.assignReferrer(targetUserId, userId);
await loadReferralsList();
await loadUser();
setShowAddReferral(false);
setAddReferralSearchQuery('');
setAddReferralSearchResults([]);
notify.success(t('admin.users.detail.referrals.referralAdded'));
} 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) {
@@ -994,6 +1020,53 @@ export default function AdminUserDetail() {
}
}, [showReferrerSearch]);
// Referrals tab: debounced search for adding referral
useEffect(() => {
if (addReferralSearchQuery.length < 2 || !showAddReferral) {
setAddReferralSearchResults([]);
setAddReferralSearchLoading(false);
return;
}
setAddReferralSearchLoading(true);
let cancelled = false;
const timer = setTimeout(async () => {
try {
const data = await adminUsersApi.getUsers({ search: addReferralSearchQuery, limit: 10 });
if (!cancelled) {
setAddReferralSearchResults(data.users || []);
setAddReferralSearchLoading(false);
}
} catch {
if (!cancelled) {
setAddReferralSearchResults([]);
setAddReferralSearchLoading(false);
}
}
}, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [addReferralSearchQuery, showAddReferral]);
// Referrals tab: close add-referral dropdown on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
addReferralSearchRef.current &&
!addReferralSearchRef.current.contains(e.target as Node)
) {
setShowAddReferral(false);
setAddReferralSearchQuery('');
setAddReferralSearchResults([]);
}
};
if (showAddReferral) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showAddReferral]);
const handleResetTrial = async () => {
if (!userId) return;
setActionLoading(true);
@@ -3146,8 +3219,80 @@ export default function AdminUserDetail() {
<h3 className="text-base font-semibold text-dark-100">
{t('admin.users.detail.referrals.referralsList')} ({referralsTotal})
</h3>
{!showAddReferral && (
<button
onClick={() => setShowAddReferral(true)}
className="rounded-lg bg-accent-500/15 px-3 py-2 text-sm text-accent-400 transition-colors hover:bg-accent-500/25"
>
{t('admin.users.detail.referrals.addReferral')}
</button>
)}
</div>
{/* Add referral search */}
{showAddReferral && (
<div ref={addReferralSearchRef} className="relative mb-4">
<div className="flex gap-2">
<input
type="text"
value={addReferralSearchQuery}
onChange={(e) => setAddReferralSearchQuery(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
/>
<button
onClick={() => {
setShowAddReferral(false);
setAddReferralSearchQuery('');
setAddReferralSearchResults([]);
}}
className="rounded-lg bg-dark-700 px-3 py-2.5 text-sm text-dark-400 hover:bg-dark-600"
>
{t('common.cancel')}
</button>
</div>
{addReferralSearchQuery.length >= 2 && addReferralSearchResults.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
{addReferralSearchResults
.filter((u) => u.id !== userId)
.map((u) => (
<button
key={u.id}
onClick={() => handleAddReferral(u.id)}
disabled={actionLoading}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-dark-700/50 disabled:opacity-50"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-600/50 text-xs font-bold text-dark-300">
{(u.full_name || u.username || '?')[0].toUpperCase()}
</div>
<div>
<div className="text-sm text-dark-100">
{u.full_name || u.username || `ID: ${u.id}`}
</div>
<div className="text-xs text-dark-500">
{u.telegram_id ? `TG: ${u.telegram_id}` : `ID: ${u.id}`}
</div>
</div>
</button>
))}
{addReferralSearchResults.filter((u) => u.id !== userId).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-dark-500">
{t('admin.users.detail.referrals.noUsersFound')}
</div>
)}
</div>
)}
{addReferralSearchQuery.length >= 2 &&
!addReferralSearchLoading &&
addReferralSearchResults.length === 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-xl border border-dark-700 bg-dark-800 py-4 text-center text-sm text-dark-500 shadow-xl">
{t('admin.users.detail.referrals.noUsersFound')}
</div>
)}
</div>
)}
{referralsListLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />