feat: add user search autocomplete for promo offer sending

- Replace plain input with search autocomplete
- Search by name, username, or Telegram ID
- Show user info with subscription status
- Filter to show only users with Telegram ID
This commit is contained in:
c0mrade
2026-02-02 07:30:25 +03:00
parent 822b9a6265
commit fc922671d2

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -10,6 +10,7 @@ import {
OFFER_TYPE_CONFIG,
OfferType,
} from '../api/promoOffers';
import { adminUsersApi, UserListItem } from '../api/adminUsers';
import { AdminBackButton } from '../components/admin';
// Icons
@@ -49,6 +50,22 @@ const UserIcon = () => (
</svg>
);
const SearchIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
const CloseIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const getOfferTypeIcon = (offerType: string): string => {
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁';
};
@@ -62,6 +79,10 @@ export default function AdminPromoOfferSend() {
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment');
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active');
const [userId, setUserId] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedUser, setSelectedUser] = useState<UserListItem | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const [result, setResult] = useState<{
title: string;
message: string;
@@ -83,6 +104,43 @@ export default function AdminPromoOfferSend() {
setSelectedTemplateId(activeTemplates[0].id);
}
// User search query with debounce
const { data: searchResults, isFetching: isSearching } = useQuery({
queryKey: ['admin-users-search', searchQuery],
queryFn: () => adminUsersApi.getUsers({ search: searchQuery, limit: 10 }),
enabled: searchQuery.length >= 2 && sendMode === 'user',
staleTime: 30000,
});
// Filter users with telegram_id only
const filteredUsers = (searchResults?.users || []).filter((u) => u.telegram_id);
// Close dropdown on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Handle user selection
const handleSelectUser = (user: UserListItem) => {
setSelectedUser(user);
setUserId(user.telegram_id.toString());
setSearchQuery('');
setShowDropdown(false);
};
// Clear selected user
const handleClearUser = () => {
setSelectedUser(null);
setUserId('');
setSearchQuery('');
};
// Broadcast mutation
const broadcastMutation = useMutation({
mutationFn: promoOffersApi.broadcastOffer,
@@ -327,13 +385,100 @@ export default function AdminPromoOfferSend() {
))}
</select>
) : (
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder={t('admin.promoOffers.send.userIdPlaceholder')}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-dark-100 focus:border-accent-500 focus:outline-none"
/>
<div ref={searchRef} className="relative">
{selectedUser ? (
// Selected user display
<div className="flex items-center justify-between rounded-lg border border-accent-500 bg-accent-500/10 px-3 py-2.5">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-600">
<UserIcon />
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{selectedUser.full_name ||
selectedUser.username ||
`ID: ${selectedUser.telegram_id}`}
</div>
<div className="text-xs text-dark-400">
{selectedUser.username && `@${selectedUser.username} · `}
Telegram: {selectedUser.telegram_id}
</div>
</div>
</div>
<button
onClick={handleClearUser}
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100"
>
<CloseIcon />
</button>
</div>
) : (
// Search input
<>
<div className="relative">
<div className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-dark-400">
<SearchIcon />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowDropdown(true);
}}
onFocus={() => setShowDropdown(true)}
placeholder={t(
'admin.promoOffers.send.searchUserPlaceholder',
'Search by name, username or Telegram ID...',
)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 py-2.5 pl-10 pr-3 text-dark-100 focus:border-accent-500 focus:outline-none"
/>
{isSearching && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
</div>
{/* Dropdown results */}
{showDropdown && searchQuery.length >= 2 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-lg border border-dark-600 bg-dark-800 shadow-xl">
{filteredUsers.length > 0 ? (
filteredUsers.map((user) => (
<button
key={user.id}
onClick={() => handleSelectUser(user)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-dark-700"
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-dark-600">
<UserIcon />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-dark-100">
{user.full_name || user.username || `User #${user.id}`}
</div>
<div className="truncate text-xs text-dark-400">
{user.username && `@${user.username} · `}
Telegram: {user.telegram_id}
</div>
</div>
{user.has_subscription && (
<span className="flex-shrink-0 rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
{t('admin.users.hasSubscription', 'Sub')}
</span>
)}
</button>
))
) : !isSearching ? (
<div className="px-3 py-4 text-center text-sm text-dark-400">
{t('admin.promoOffers.send.noUsersFound', 'No users found')}
</div>
) : null}
</div>
)}
</>
)}
</div>
)}
</div>