diff --git a/src/locales/en.json b/src/locales/en.json index 80d79fa..6e338c3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1309,7 +1309,8 @@ "noResults": "Nothing found", "removeItem": "Remove {{label}}", "clearAll": "Clear all selections", - "addScope": "Add scope" + "addScope": "Add scope", + "maxReached": "Maximum {{max}} items" }, "loading": "Loading graph...", "error": "Failed to load network data", diff --git a/src/locales/fa.json b/src/locales/fa.json index 47f419e..797c3e9 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1143,7 +1143,8 @@ "noResults": "نتیجه‌ای یافت نشد", "removeItem": "حذف {{label}}", "clearAll": "پاک کردن همه انتخاب‌ها", - "addScope": "افزودن محدوده" + "addScope": "افزودن محدوده", + "maxReached": "حداکثر {{max}} مورد" }, "loading": "بارگذاری گراف...", "error": "خطا در بارگذاری داده‌های شبکه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 11dbf8f..5137d97 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1330,7 +1330,8 @@ "noResults": "Ничего не найдено", "removeItem": "Удалить {{label}}", "clearAll": "Очистить все выбранные", - "addScope": "Добавить область" + "addScope": "Добавить область", + "maxReached": "Максимум {{max}} элементов" }, "loading": "Загрузка графа...", "error": "Ошибка загрузки данных сети", diff --git a/src/locales/zh.json b/src/locales/zh.json index e7543c6..734a8bb 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1143,7 +1143,8 @@ "noResults": "未找到", "removeItem": "移除 {{label}}", "clearAll": "清除所有选择", - "addScope": "添加范围" + "addScope": "添加范围", + "maxReached": "最多 {{max}} 个项目" }, "loading": "加载图表...", "error": "加载网络数据失败", diff --git a/src/pages/ReferralNetwork/components/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx index 02c00a8..e35f9e0 100644 --- a/src/pages/ReferralNetwork/components/ScopeSelector.tsx +++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { referralNetworkApi } from '@/api/referralNetwork'; +import { MAX_SCOPE_ITEMS } from '@/store/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; interface ScopeSelectorProps { @@ -20,6 +21,82 @@ const CHIP_COLORS: Record = { user: 'bg-accent-500/20 text-accent-400', }; +// Reuse CHIP_COLORS for avatar backgrounds (same palette) +const AVATAR_COLORS = CHIP_COLORS; + +const AVATAR_LETTERS: Record = { + campaign: 'C', + partner: 'P', + user: 'U', +}; + +function CheckIcon() { + return ( + + + + ); +} + +function CloseIcon({ className = 'h-3 w-3' }: { className?: string }) { + return ( + + + + ); +} + +function Spinner({ size = 'h-5 w-5' }: { size?: string }) { + return ( +
+ ); +} + +function EmptyMessage({ text }: { text: string }) { + return
{text}
; +} + +interface ScopeListItemProps { + type: ScopeType; + selected: boolean; + onClick: () => void; + title: string; + subtitle: string; + badge?: React.ReactNode; +} + +function ScopeListItem({ type, selected, onClick, title, subtitle, badge }: ScopeListItemProps) { + return ( + + ); +} + export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('campaign'); @@ -29,6 +106,8 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc const containerRef = useRef(null); const inputRef = useRef(null); + const isMaxReached = value.length >= MAX_SCOPE_ITEMS; + const { data: scopeOptions, isLoading: isScopeLoading } = useQuery({ queryKey: ['referral-network', 'scope-options'], queryFn: referralNetworkApi.getScopeOptions, @@ -51,8 +130,16 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc staleTime: 30_000, }); - // Close dropdown on outside click or Escape key + // Focus input when dropdown opens useEffect(() => { + if (isDropdownOpen) { + inputRef.current?.focus(); + } + }, [isDropdownOpen]); + + // Close dropdown on outside click or Escape key (only when open) + useEffect(() => { + if (!isDropdownOpen) return; function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsDropdownOpen(false); @@ -69,11 +156,14 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); }; - }, []); + }, [isDropdownOpen]); - // Check if item is already selected - const isSelected = (type: ScopeType, id: number): boolean => - value.some((s) => s.type === type && s.id === id); + const selectedSet = useMemo(() => new Set(value.map((s) => `${s.type}:${s.id}`)), [value]); + + const isSelected = useCallback( + (type: ScopeType, id: number): boolean => selectedSet.has(`${type}:${id}`), + [selectedSet], + ); // Filter campaigns/partners by local search input const filteredCampaigns = useMemo(() => { @@ -104,49 +194,42 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc inputRef.current?.focus(); } - function handleSelectCampaign(campaign: { id: number; name: string }) { - if (isSelected('campaign', campaign.id)) { - onRemove('campaign', campaign.id); + function handleToggle(type: ScopeType, id: number, label: string) { + if (isSelected(type, id)) { + onRemove(type, id); } else { - onAdd({ type: 'campaign', id: campaign.id, label: campaign.name }); + if (isMaxReached) return; + onAdd({ type, id, label }); + if (type === 'user') { + setSearchInput(''); + setDebouncedUserQuery(''); + } } } - function handleSelectPartner(partner: { id: number; display_name: string }) { - if (isSelected('partner', partner.id)) { - onRemove('partner', partner.id); - } else { - onAdd({ type: 'partner', id: partner.id, label: partner.display_name }); - } - } + const tabLabels = useMemo>( + () => ({ + campaign: t('admin.referralNetwork.scope.campaign'), + partner: t('admin.referralNetwork.scope.partner'), + user: t('admin.referralNetwork.scope.user'), + }), + [t], + ); - function handleSelectUser(user: { id: number; display_name: string }) { - if (isSelected('user', user.id)) { - onRemove('user', user.id); - } else { - onAdd({ type: 'user', id: user.id, label: user.display_name }); - } - setSearchInput(''); - setDebouncedUserQuery(''); - } - - const tabLabels: Record = { - campaign: t('admin.referralNetwork.scope.campaign'), - partner: t('admin.referralNetwork.scope.partner'), - user: t('admin.referralNetwork.scope.user'), - }; - - const placeholders: Record = { - campaign: t('admin.referralNetwork.scope.selectCampaign'), - partner: t('admin.referralNetwork.scope.selectPartner'), - user: t('admin.referralNetwork.scope.selectUser'), - }; + const placeholders = useMemo>( + () => ({ + campaign: t('admin.referralNetwork.scope.selectCampaign'), + partner: t('admin.referralNetwork.scope.selectPartner'), + user: t('admin.referralNetwork.scope.selectUser'), + }), + [t], + ); const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading; return (
- {/* Chips row + search trigger */} + {/* Chips row + add trigger */}
{/* Selected chips */} {value.length > 0 && ( @@ -162,49 +245,33 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc aria-label={t('admin.referralNetwork.scope.removeItem', { label: item.label })} className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-white/10" > - - - + ))} - {/* Clear all */} - + + + )}
)} - {/* Add button (always visible) */} + {/* Add button */}
{/* List */} -
+
{activeTab === 'campaign' && renderCampaignList()} {activeTab === 'partner' && renderPartnerList()} {activeTab === 'user' && renderUserList()} @@ -285,52 +368,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc if (isScopeLoading) { return (
-
+
); } if (filteredCampaigns.length === 0) { - return ( -
- {t('admin.referralNetwork.scope.noResults')} -
- ); + return ; } - return filteredCampaigns.map((campaign) => { - const selected = isSelected('campaign', campaign.id); - return ( - - ); - }); + } + /> + )); } function renderPartnerList() { if (isScopeLoading) { return (
-
+
); } if (filteredPartners.length === 0) { - return ( -
- {t('admin.referralNetwork.scope.noResults')} -
- ); + return ; } - return filteredPartners.map((partner) => { - const selected = isSelected('partner', partner.id); - return ( - - ); - }); + return filteredPartners.map((partner) => ( + handleToggle('partner', partner.id, partner.display_name)} + title={partner.display_name} + subtitle={`${partner.username ? `@${partner.username} / ` : ''}${partner.campaign_count} ${t('admin.referralNetwork.scope.campaigns')}`} + /> + )); } function renderUserList() { if (debouncedUserQuery.length < 2) { - return ( -
- {t('admin.referralNetwork.scope.selectUser')} -
- ); + return ; } if (isUserSearching) { return (
-
+
); } @@ -422,52 +443,25 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc const users = userSearchResults?.users ?? []; if (users.length === 0) { - return ( -
- {t('admin.referralNetwork.scope.noResults')} -
- ); + return ; } - return users.map((user) => { - const selected = isSelected('user', user.id); - return ( - - ); - }); + ) : undefined + } + /> + )); } } diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts index 14ffa09..8501fdc 100644 --- a/src/store/referralNetwork.ts +++ b/src/store/referralNetwork.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork'; -const MAX_SCOPE_ITEMS = 50; +export const MAX_SCOPE_ITEMS = 50; const DEFAULT_FILTERS: NetworkFilters = { campaigns: [], @@ -9,6 +9,15 @@ const DEFAULT_FILTERS: NetworkFilters = { minReferrals: 0, }; +function resetGraphState() { + return { + selectedNode: null as SelectedNode, + hoveredNodeId: null as string | null, + highlightedNodes: new Set(), + filters: { ...DEFAULT_FILTERS }, + }; +} + interface ReferralNetworkState { selectedNode: SelectedNode; hoveredNodeId: string | null; @@ -51,28 +60,22 @@ export const useReferralNetworkStore = create()((set) => ( if (state.scope.length >= MAX_SCOPE_ITEMS) return state; return { scope: [...state.scope, item], - selectedNode: null, - hoveredNodeId: null, - highlightedNodes: new Set(), - filters: { ...DEFAULT_FILTERS }, + ...resetGraphState(), }; }), removeScope: (type, id) => - set((state) => ({ - scope: state.scope.filter((s) => !(s.type === type && s.id === id)), - selectedNode: null, - hoveredNodeId: null, - highlightedNodes: new Set(), - filters: { ...DEFAULT_FILTERS }, - })), + set((state) => { + const next = state.scope.filter((s) => !(s.type === type && s.id === id)); + if (next.length === 0) { + return { scope: next, ...resetGraphState() }; + } + return { scope: next }; + }), clearScope: () => set({ scope: [], - selectedNode: null, - hoveredNodeId: null, - highlightedNodes: new Set(), - filters: { ...DEFAULT_FILTERS }, + ...resetGraphState(), }), }));