fix: scope selector UX, ARIA, and code quality improvements

- removeScope preserves graph state when items remain (only resets on empty)
- Extract shared CheckIcon, CloseIcon, Spinner, ScopeListItem components
- Unify three handleSelect* into single handleToggle
- Fix shared mutable Set in reset state (factory function)
- Add MAX_SCOPE_ITEMS UI feedback (disabled button, warning banner)
- Correct ARIA: dialog > tablist + listbox > option structure
- Event listeners only attached when dropdown is open
- isSelected uses Set lookup via useMemo instead of O(n) scan
- Clear user search only on add, not on deselect
This commit is contained in:
Fringg
2026-03-20 02:44:29 +03:00
parent db76cd0c64
commit 2780898d1c
6 changed files with 230 additions and 229 deletions

View File

@@ -1309,7 +1309,8 @@
"noResults": "Nothing found", "noResults": "Nothing found",
"removeItem": "Remove {{label}}", "removeItem": "Remove {{label}}",
"clearAll": "Clear all selections", "clearAll": "Clear all selections",
"addScope": "Add scope" "addScope": "Add scope",
"maxReached": "Maximum {{max}} items"
}, },
"loading": "Loading graph...", "loading": "Loading graph...",
"error": "Failed to load network data", "error": "Failed to load network data",

View File

@@ -1143,7 +1143,8 @@
"noResults": "نتیجه‌ای یافت نشد", "noResults": "نتیجه‌ای یافت نشد",
"removeItem": "حذف {{label}}", "removeItem": "حذف {{label}}",
"clearAll": "پاک کردن همه انتخاب‌ها", "clearAll": "پاک کردن همه انتخاب‌ها",
"addScope": "افزودن محدوده" "addScope": "افزودن محدوده",
"maxReached": "حداکثر {{max}} مورد"
}, },
"loading": "بارگذاری گراف...", "loading": "بارگذاری گراف...",
"error": "خطا در بارگذاری داده‌های شبکه", "error": "خطا در بارگذاری داده‌های شبکه",

View File

@@ -1330,7 +1330,8 @@
"noResults": "Ничего не найдено", "noResults": "Ничего не найдено",
"removeItem": "Удалить {{label}}", "removeItem": "Удалить {{label}}",
"clearAll": "Очистить все выбранные", "clearAll": "Очистить все выбранные",
"addScope": "Добавить область" "addScope": "Добавить область",
"maxReached": "Максимум {{max}} элементов"
}, },
"loading": "Загрузка графа...", "loading": "Загрузка графа...",
"error": "Ошибка загрузки данных сети", "error": "Ошибка загрузки данных сети",

View File

@@ -1143,7 +1143,8 @@
"noResults": "未找到", "noResults": "未找到",
"removeItem": "移除 {{label}}", "removeItem": "移除 {{label}}",
"clearAll": "清除所有选择", "clearAll": "清除所有选择",
"addScope": "添加范围" "addScope": "添加范围",
"maxReached": "最多 {{max}} 个项目"
}, },
"loading": "加载图表...", "loading": "加载图表...",
"error": "加载网络数据失败", "error": "加载网络数据失败",

View File

@@ -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 { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { referralNetworkApi } from '@/api/referralNetwork'; import { referralNetworkApi } from '@/api/referralNetwork';
import { MAX_SCOPE_ITEMS } from '@/store/referralNetwork';
import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork';
interface ScopeSelectorProps { interface ScopeSelectorProps {
@@ -20,6 +21,82 @@ const CHIP_COLORS: Record<ScopeType, string> = {
user: 'bg-accent-500/20 text-accent-400', 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<ScopeType, string> = {
campaign: 'C',
partner: 'P',
user: 'U',
};
function CheckIcon() {
return (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
}
function CloseIcon({ className = 'h-3 w-3' }: { className?: string }) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
function Spinner({ size = 'h-5 w-5' }: { size?: string }) {
return (
<div
className={`${size} animate-spin rounded-full border-2 border-dark-600 border-t-accent-400`}
/>
);
}
function EmptyMessage({ text }: { text: string }) {
return <div className="px-4 py-3 text-center text-sm text-dark-500">{text}</div>;
}
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 (
<button
role="option"
aria-selected={selected}
onClick={onClick}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50 focus-visible:bg-dark-700/50 focus-visible:outline-none ${
selected ? 'bg-dark-700/30' : ''
}`}
>
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${AVATAR_COLORS[type]}`}
>
{selected ? <CheckIcon /> : AVATAR_LETTERS[type]}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{title}</p>
<p className="truncate text-xs text-dark-500">{subtitle}</p>
</div>
{badge}
</button>
);
}
export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) { export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<ScopeType>('campaign'); const [activeTab, setActiveTab] = useState<ScopeType>('campaign');
@@ -29,6 +106,8 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const isMaxReached = value.length >= MAX_SCOPE_ITEMS;
const { data: scopeOptions, isLoading: isScopeLoading } = useQuery({ const { data: scopeOptions, isLoading: isScopeLoading } = useQuery({
queryKey: ['referral-network', 'scope-options'], queryKey: ['referral-network', 'scope-options'],
queryFn: referralNetworkApi.getScopeOptions, queryFn: referralNetworkApi.getScopeOptions,
@@ -51,8 +130,16 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
staleTime: 30_000, staleTime: 30_000,
}); });
// Close dropdown on outside click or Escape key // Focus input when dropdown opens
useEffect(() => { 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) { function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false); setIsDropdownOpen(false);
@@ -69,11 +156,14 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape); document.removeEventListener('keydown', handleEscape);
}; };
}, []); }, [isDropdownOpen]);
// Check if item is already selected const selectedSet = useMemo(() => new Set(value.map((s) => `${s.type}:${s.id}`)), [value]);
const isSelected = (type: ScopeType, id: number): boolean =>
value.some((s) => s.type === type && s.id === id); const isSelected = useCallback(
(type: ScopeType, id: number): boolean => selectedSet.has(`${type}:${id}`),
[selectedSet],
);
// Filter campaigns/partners by local search input // Filter campaigns/partners by local search input
const filteredCampaigns = useMemo(() => { const filteredCampaigns = useMemo(() => {
@@ -104,49 +194,42 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
inputRef.current?.focus(); inputRef.current?.focus();
} }
function handleSelectCampaign(campaign: { id: number; name: string }) { function handleToggle(type: ScopeType, id: number, label: string) {
if (isSelected('campaign', campaign.id)) { if (isSelected(type, id)) {
onRemove('campaign', campaign.id); onRemove(type, id);
} else { } else {
onAdd({ type: 'campaign', id: campaign.id, label: campaign.name }); if (isMaxReached) return;
} onAdd({ type, id, label });
} if (type === 'user') {
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 });
}
}
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(''); setSearchInput('');
setDebouncedUserQuery(''); setDebouncedUserQuery('');
} }
}
}
const tabLabels: Record<ScopeType, string> = { const tabLabels = useMemo<Record<ScopeType, string>>(
() => ({
campaign: t('admin.referralNetwork.scope.campaign'), campaign: t('admin.referralNetwork.scope.campaign'),
partner: t('admin.referralNetwork.scope.partner'), partner: t('admin.referralNetwork.scope.partner'),
user: t('admin.referralNetwork.scope.user'), user: t('admin.referralNetwork.scope.user'),
}; }),
[t],
);
const placeholders: Record<ScopeType, string> = { const placeholders = useMemo<Record<ScopeType, string>>(
() => ({
campaign: t('admin.referralNetwork.scope.selectCampaign'), campaign: t('admin.referralNetwork.scope.selectCampaign'),
partner: t('admin.referralNetwork.scope.selectPartner'), partner: t('admin.referralNetwork.scope.selectPartner'),
user: t('admin.referralNetwork.scope.selectUser'), user: t('admin.referralNetwork.scope.selectUser'),
}; }),
[t],
);
const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading; const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading;
return ( return (
<div ref={containerRef} className={`relative ${className ?? ''}`}> <div ref={containerRef} className={`relative ${className ?? ''}`}>
{/* Chips row + search trigger */} {/* Chips row + add trigger */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* Selected chips */} {/* Selected chips */}
{value.length > 0 && ( {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 })} 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" className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-white/10"
> >
<svg <CloseIcon />
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</span> </span>
))} ))}
{/* Clear all */} {/* Clear all (only when 2+ items) */}
{value.length > 1 && (
<button <button
onClick={onClear} onClick={onClear}
aria-label={t('admin.referralNetwork.scope.clearAll')} aria-label={t('admin.referralNetwork.scope.clearAll')}
className="shrink-0 rounded-md p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300" className="shrink-0 rounded-md p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
> >
<svg <CloseIcon className="h-3.5 w-3.5" />
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
)}
</div> </div>
)} )}
{/* Add button (always visible) */} {/* Add button */}
<button <button
onClick={() => { onClick={() => setIsDropdownOpen((prev) => !prev)}
setIsDropdownOpen((prev) => {
if (!prev) {
setTimeout(() => inputRef.current?.focus(), 0);
}
return !prev;
});
}}
aria-label={t('admin.referralNetwork.scope.addScope')} aria-label={t('admin.referralNetwork.scope.addScope')}
className="shrink-0 rounded-lg border border-dark-700/50 bg-dark-800 p-1.5 text-dark-400 transition-colors hover:border-accent-500/50 hover:text-accent-400" aria-expanded={isDropdownOpen}
aria-haspopup="listbox"
className={`shrink-0 rounded-lg border border-dark-700/50 bg-dark-800 p-1.5 transition-colors hover:border-accent-500/50 hover:text-accent-400 ${
isMaxReached ? 'cursor-not-allowed text-dark-600 opacity-50' : 'text-dark-400'
}`}
disabled={isMaxReached && !isDropdownOpen}
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -220,13 +287,29 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
{/* Dropdown */} {/* Dropdown */}
{isDropdownOpen && ( {isDropdownOpen && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl backdrop-blur-md"> <div
className="absolute left-0 right-0 top-full z-50 mt-1 rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl backdrop-blur-md"
role="dialog"
aria-label={t('admin.referralNetwork.scope.addScope')}
>
{/* Max reached banner */}
{isMaxReached && (
<div className="border-b border-dark-700/50 px-3 py-1.5 text-center text-xs text-warning-400">
{t('admin.referralNetwork.scope.maxReached', { max: MAX_SCOPE_ITEMS })}
</div>
)}
{/* Tab bar + search input */} {/* Tab bar + search input */}
<div className="flex items-center gap-2 border-b border-dark-700/50 px-3 py-2"> <div className="flex items-center gap-2 border-b border-dark-700/50 px-3 py-2">
<div className="flex shrink-0 rounded-lg border border-dark-700/50 bg-dark-900 p-0.5"> <div
className="flex shrink-0 rounded-lg border border-dark-700/50 bg-dark-900 p-0.5"
role="tablist"
>
{SCOPE_TABS.map((tab) => ( {SCOPE_TABS.map((tab) => (
<button <button
key={tab} key={tab}
role="tab"
aria-selected={activeTab === tab}
onClick={() => handleTabChange(tab)} onClick={() => handleTabChange(tab)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${ className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
activeTab === tab activeTab === tab
@@ -264,14 +347,14 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
/> />
{isLoading && ( {isLoading && (
<div className="absolute right-2.5 top-1/2 -translate-y-1/2"> <div className="absolute right-2.5 top-1/2 -translate-y-1/2">
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" /> <Spinner size="h-3.5 w-3.5" />
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* List */} {/* List */}
<div className="max-h-64 overflow-y-auto"> <div className="max-h-64 overflow-y-auto" role="listbox" aria-multiselectable="true">
{activeTab === 'campaign' && renderCampaignList()} {activeTab === 'campaign' && renderCampaignList()}
{activeTab === 'partner' && renderPartnerList()} {activeTab === 'partner' && renderPartnerList()}
{activeTab === 'user' && renderUserList()} {activeTab === 'user' && renderUserList()}
@@ -285,52 +368,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
if (isScopeLoading) { if (isScopeLoading) {
return ( return (
<div className="flex items-center justify-center px-4 py-6"> <div className="flex items-center justify-center px-4 py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" /> <Spinner />
</div> </div>
); );
} }
if (filteredCampaigns.length === 0) { if (filteredCampaigns.length === 0) {
return ( return <EmptyMessage text={t('admin.referralNetwork.scope.noResults')} />;
<div className="px-4 py-3 text-center text-sm text-dark-500">
{t('admin.referralNetwork.scope.noResults')}
</div>
);
} }
return filteredCampaigns.map((campaign) => { return filteredCampaigns.map((campaign) => (
const selected = isSelected('campaign', campaign.id); <ScopeListItem
return (
<button
key={campaign.id} key={campaign.id}
onClick={() => handleSelectCampaign(campaign)} type="campaign"
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50 ${ selected={isSelected('campaign', campaign.id)}
selected ? 'bg-dark-700/30' : '' onClick={() => handleToggle('campaign', campaign.id, campaign.name)}
}`} title={campaign.name}
> subtitle={`${campaign.start_parameter} / ${campaign.direct_users} ${t('admin.referralNetwork.scope.users')}`}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-success-500/20 text-xs font-medium text-success-400"> badge={
{selected ? (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : (
'C'
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{campaign.name}</p>
<p className="truncate text-xs text-dark-500">
{campaign.start_parameter}
{' / '}
{campaign.direct_users} {t('admin.referralNetwork.scope.users')}
</p>
</div>
<span <span
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${ className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${
campaign.is_active campaign.is_active
@@ -342,79 +397,45 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
? t('admin.referralNetwork.scope.active') ? t('admin.referralNetwork.scope.active')
: t('admin.referralNetwork.scope.inactive')} : t('admin.referralNetwork.scope.inactive')}
</span> </span>
</button> }
); />
}); ));
} }
function renderPartnerList() { function renderPartnerList() {
if (isScopeLoading) { if (isScopeLoading) {
return ( return (
<div className="flex items-center justify-center px-4 py-6"> <div className="flex items-center justify-center px-4 py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" /> <Spinner />
</div> </div>
); );
} }
if (filteredPartners.length === 0) { if (filteredPartners.length === 0) {
return ( return <EmptyMessage text={t('admin.referralNetwork.scope.noResults')} />;
<div className="px-4 py-3 text-center text-sm text-dark-500">
{t('admin.referralNetwork.scope.noResults')}
</div>
);
} }
return filteredPartners.map((partner) => { return filteredPartners.map((partner) => (
const selected = isSelected('partner', partner.id); <ScopeListItem
return (
<button
key={partner.id} key={partner.id}
onClick={() => handleSelectPartner(partner)} type="partner"
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50 ${ selected={isSelected('partner', partner.id)}
selected ? 'bg-dark-700/30' : '' onClick={() => handleToggle('partner', partner.id, partner.display_name)}
}`} title={partner.display_name}
> subtitle={`${partner.username ? `@${partner.username} / ` : ''}${partner.campaign_count} ${t('admin.referralNetwork.scope.campaigns')}`}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-warning-500/20 text-xs font-medium text-warning-400"> />
{selected ? ( ));
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : (
'P'
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{partner.display_name}</p>
<p className="truncate text-xs text-dark-500">
{partner.username ? `@${partner.username}` : ''}
{partner.username ? ' / ' : ''}
{partner.campaign_count} {t('admin.referralNetwork.scope.campaigns')}
</p>
</div>
</button>
);
});
} }
function renderUserList() { function renderUserList() {
if (debouncedUserQuery.length < 2) { if (debouncedUserQuery.length < 2) {
return ( return <EmptyMessage text={t('admin.referralNetwork.scope.selectUser')} />;
<div className="px-4 py-3 text-center text-sm text-dark-500">
{t('admin.referralNetwork.scope.selectUser')}
</div>
);
} }
if (isUserSearching) { if (isUserSearching) {
return ( return (
<div className="flex items-center justify-center px-4 py-6"> <div className="flex items-center justify-center px-4 py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" /> <Spinner />
</div> </div>
); );
} }
@@ -422,52 +443,25 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
const users = userSearchResults?.users ?? []; const users = userSearchResults?.users ?? [];
if (users.length === 0) { if (users.length === 0) {
return ( return <EmptyMessage text={t('admin.referralNetwork.scope.noResults')} />;
<div className="px-4 py-3 text-center text-sm text-dark-500">
{t('admin.referralNetwork.scope.noResults')}
</div>
);
} }
return users.map((user) => { return users.map((user) => (
const selected = isSelected('user', user.id); <ScopeListItem
return (
<button
key={user.id} key={user.id}
onClick={() => handleSelectUser(user)} type="user"
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50 ${ selected={isSelected('user', user.id)}
selected ? 'bg-dark-700/30' : '' onClick={() => handleToggle('user', user.id, user.display_name)}
}`} title={user.display_name}
> subtitle={`${user.username ? `@${user.username}` : ''}${user.tg_id ? ` #${user.tg_id}` : ''}`}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent-500/20 text-xs font-medium text-accent-400"> badge={
{selected ? ( user.is_partner ? (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : (
'U'
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{user.display_name}</p>
<p className="truncate text-xs text-dark-500">
{user.username ? `@${user.username}` : ''}
{user.tg_id ? ` #${user.tg_id}` : ''}
</p>
</div>
{user.is_partner && (
<span className="shrink-0 rounded bg-warning-500/20 px-1.5 py-0.5 text-[10px] font-medium text-warning-400"> <span className="shrink-0 rounded bg-warning-500/20 px-1.5 py-0.5 text-[10px] font-medium text-warning-400">
{t('admin.referralNetwork.user.partner')} {t('admin.referralNetwork.user.partner')}
</span> </span>
)} ) : undefined
</button> }
); />
}); ));
} }
} }

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork'; import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork';
const MAX_SCOPE_ITEMS = 50; export const MAX_SCOPE_ITEMS = 50;
const DEFAULT_FILTERS: NetworkFilters = { const DEFAULT_FILTERS: NetworkFilters = {
campaigns: [], campaigns: [],
@@ -9,6 +9,15 @@ const DEFAULT_FILTERS: NetworkFilters = {
minReferrals: 0, minReferrals: 0,
}; };
function resetGraphState() {
return {
selectedNode: null as SelectedNode,
hoveredNodeId: null as string | null,
highlightedNodes: new Set<string>(),
filters: { ...DEFAULT_FILTERS },
};
}
interface ReferralNetworkState { interface ReferralNetworkState {
selectedNode: SelectedNode; selectedNode: SelectedNode;
hoveredNodeId: string | null; hoveredNodeId: string | null;
@@ -51,28 +60,22 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
if (state.scope.length >= MAX_SCOPE_ITEMS) return state; if (state.scope.length >= MAX_SCOPE_ITEMS) return state;
return { return {
scope: [...state.scope, item], scope: [...state.scope, item],
selectedNode: null, ...resetGraphState(),
hoveredNodeId: null,
highlightedNodes: new Set<string>(),
filters: { ...DEFAULT_FILTERS },
}; };
}), }),
removeScope: (type, id) => removeScope: (type, id) =>
set((state) => ({ set((state) => {
scope: state.scope.filter((s) => !(s.type === type && s.id === id)), const next = state.scope.filter((s) => !(s.type === type && s.id === id));
selectedNode: null, if (next.length === 0) {
hoveredNodeId: null, return { scope: next, ...resetGraphState() };
highlightedNodes: new Set<string>(), }
filters: { ...DEFAULT_FILTERS }, return { scope: next };
})), }),
clearScope: () => clearScope: () =>
set({ set({
scope: [], scope: [],
selectedNode: null, ...resetGraphState(),
hoveredNodeId: null,
highlightedNodes: new Set<string>(),
filters: { ...DEFAULT_FILTERS },
}), }),
})); }));