mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user