feat: multi-select scope for referral network graph

Replace single-select scope with multi-select supporting multiple
campaigns, partners, and users simultaneously. Chips UI with remove
buttons, clear-all, sorted query keys for cache stability.
This commit is contained in:
Fringg
2026-03-20 02:31:11 +03:00
parent a6faf702ec
commit db76cd0c64
8 changed files with 364 additions and 190 deletions

View File

@@ -5,6 +5,7 @@ import type {
NetworkCampaignDetail, NetworkCampaignDetail,
NetworkSearchResult, NetworkSearchResult,
ScopeOptionsData, ScopeOptionsData,
ScopeSelection,
} from '@/types/referralNetwork'; } from '@/types/referralNetwork';
export const referralNetworkApi = { export const referralNetworkApi = {
@@ -13,9 +14,20 @@ export const referralNetworkApi = {
return response.data; return response.data;
}, },
getScopedGraph: async (scope: string, id: number): Promise<NetworkGraphData> => { getScopedGraph: async (selections: ScopeSelection[]): Promise<NetworkGraphData> => {
const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id);
const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id);
const userIds = selections.filter((s) => s.type === 'user').map((s) => s.id);
const response = await apiClient.get('/cabinet/admin/referral-network/scoped', { const response = await apiClient.get('/cabinet/admin/referral-network/scoped', {
params: { scope, id }, params: {
campaign_ids: campaignIds,
partner_ids: partnerIds,
user_ids: userIds,
},
paramsSerializer: {
indexes: null,
},
}); });
return response.data; return response.data;
}, },

View File

@@ -1306,7 +1306,10 @@
"campaigns": "campaigns", "campaigns": "campaigns",
"active": "Active", "active": "Active",
"inactive": "Inactive", "inactive": "Inactive",
"noResults": "Nothing found" "noResults": "Nothing found",
"removeItem": "Remove {{label}}",
"clearAll": "Clear all selections",
"addScope": "Add scope"
}, },
"loading": "Loading graph...", "loading": "Loading graph...",
"error": "Failed to load network data", "error": "Failed to load network data",

View File

@@ -1140,7 +1140,10 @@
"campaigns": "کمپین‌ها", "campaigns": "کمپین‌ها",
"active": "فعال", "active": "فعال",
"inactive": "غیرفعال", "inactive": "غیرفعال",
"noResults": "نتیجه‌ای یافت نشد" "noResults": "نتیجه‌ای یافت نشد",
"removeItem": "حذف {{label}}",
"clearAll": "پاک کردن همه انتخاب‌ها",
"addScope": "افزودن محدوده"
}, },
"loading": "بارگذاری گراف...", "loading": "بارگذاری گراف...",
"error": "خطا در بارگذاری داده‌های شبکه", "error": "خطا در بارگذاری داده‌های شبکه",

View File

@@ -1327,7 +1327,10 @@
"campaigns": "кампаний", "campaigns": "кампаний",
"active": "Активна", "active": "Активна",
"inactive": "Неактивна", "inactive": "Неактивна",
"noResults": "Ничего не найдено" "noResults": "Ничего не найдено",
"removeItem": "Удалить {{label}}",
"clearAll": "Очистить все выбранные",
"addScope": "Добавить область"
}, },
"loading": "Загрузка графа...", "loading": "Загрузка графа...",
"error": "Ошибка загрузки данных сети", "error": "Ошибка загрузки данных сети",

View File

@@ -1140,7 +1140,10 @@
"campaigns": "活动", "campaigns": "活动",
"active": "活跃", "active": "活跃",
"inactive": "非活跃", "inactive": "非活跃",
"noResults": "未找到" "noResults": "未找到",
"removeItem": "移除 {{label}}",
"clearAll": "清除所有选择",
"addScope": "添加范围"
}, },
"loading": "加载图表...", "loading": "加载图表...",
"error": "加载网络数据失败", "error": "加载网络数据失败",

View File

@@ -16,16 +16,20 @@ export function ReferralNetwork() {
const { t } = useTranslation(); const { t } = useTranslation();
const selectedNode = useReferralNetworkStore((s) => s.selectedNode); const selectedNode = useReferralNetworkStore((s) => s.selectedNode);
const scope = useReferralNetworkStore((s) => s.scope); const scope = useReferralNetworkStore((s) => s.scope);
const setScope = useReferralNetworkStore((s) => s.setScope); const addScope = useReferralNetworkStore((s) => s.addScope);
const removeScope = useReferralNetworkStore((s) => s.removeScope);
const clearScope = useReferralNetworkStore((s) => s.clearScope);
const hasScope = scope.length > 0;
const { const {
data: networkData, data: networkData,
isLoading, isLoading,
isError, isError,
} = useQuery({ } = useQuery({
queryKey: ['referral-network', 'scoped', scope?.type, scope?.id], queryKey: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()],
queryFn: () => referralNetworkApi.getScopedGraph(scope!.type, scope!.id), queryFn: () => referralNetworkApi.getScopedGraph(scope),
enabled: scope !== null, enabled: hasScope,
staleTime: 120_000, staleTime: 120_000,
}); });
@@ -42,13 +46,19 @@ export function ReferralNetwork() {
<h1 className="shrink-0 text-sm font-bold text-dark-100 sm:text-base"> <h1 className="shrink-0 text-sm font-bold text-dark-100 sm:text-base">
{t('admin.referralNetwork.title')} {t('admin.referralNetwork.title')}
</h1> </h1>
<ScopeSelector value={scope} onSelect={setScope} className="min-w-0 flex-1 sm:max-w-xl" /> <ScopeSelector
value={scope}
onAdd={addScope}
onRemove={removeScope}
onClear={clearScope}
className="min-w-0 flex-1 sm:max-w-xl"
/>
</div> </div>
</div> </div>
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
{/* Empty state: no scope selected */} {/* Empty state: no scope selected */}
{!scope && ( {!hasScope && (
<div className="absolute inset-0 z-10 flex items-center justify-center"> <div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<svg <svg
@@ -72,7 +82,7 @@ export function ReferralNetwork() {
)} )}
{/* Loading state */} {/* Loading state */}
{scope && isLoading && ( {hasScope && isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center"> <div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" /> <div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
@@ -82,14 +92,14 @@ export function ReferralNetwork() {
)} )}
{/* Error state */} {/* Error state */}
{scope && isError && ( {hasScope && isError && (
<div className="absolute inset-0 z-10 flex items-center justify-center"> <div className="absolute inset-0 z-10 flex items-center justify-center">
<p className="text-sm text-error-400">{t('admin.referralNetwork.error')}</p> <p className="text-sm text-error-400">{t('admin.referralNetwork.error')}</p>
</div> </div>
)} )}
{/* Empty data */} {/* Empty data */}
{scope && {hasScope &&
networkData && networkData &&
networkData.users.length === 0 && networkData.users.length === 0 &&
networkData.campaigns.length === 0 && ( networkData.campaigns.length === 0 && (
@@ -99,7 +109,7 @@ export function ReferralNetwork() {
)} )}
{/* Graph + overlays */} {/* Graph + overlays */}
{scope && {hasScope &&
networkData && networkData &&
(networkData.users.length > 0 || networkData.campaigns.length > 0) && ( (networkData.users.length > 0 || networkData.campaigns.length > 0) && (
<> <>

View File

@@ -5,16 +5,24 @@ import { referralNetworkApi } from '@/api/referralNetwork';
import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork';
interface ScopeSelectorProps { interface ScopeSelectorProps {
value: ScopeSelection | null; value: ScopeSelection[];
onSelect: (selection: ScopeSelection | null) => void; onAdd: (selection: ScopeSelection) => void;
onRemove: (type: ScopeSelection['type'], id: number) => void;
onClear: () => void;
className?: string; className?: string;
} }
const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user']; const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user'];
export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps) { const CHIP_COLORS: Record<ScopeType, string> = {
campaign: 'bg-success-500/20 text-success-400',
partner: 'bg-warning-500/20 text-warning-400',
user: 'bg-accent-500/20 text-accent-400',
};
export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<ScopeType>(value?.type ?? 'campaign'); const [activeTab, setActiveTab] = useState<ScopeType>('campaign');
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [debouncedUserQuery, setDebouncedUserQuery] = useState(''); const [debouncedUserQuery, setDebouncedUserQuery] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@@ -43,17 +51,30 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps
staleTime: 30_000, staleTime: 30_000,
}); });
// Close dropdown on outside click // Close dropdown on outside click or Escape key
useEffect(() => { useEffect(() => {
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);
} }
} }
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') {
setIsDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, []); }, []);
// Check if item is already selected
const isSelected = (type: ScopeType, id: number): boolean =>
value.some((s) => s.type === type && s.id === id);
// Filter campaigns/partners by local search input // Filter campaigns/partners by local search input
const filteredCampaigns = useMemo(() => { const filteredCampaigns = useMemo(() => {
if (!scopeOptions) return []; if (!scopeOptions) return [];
@@ -84,28 +105,29 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps
} }
function handleSelectCampaign(campaign: { id: number; name: string }) { function handleSelectCampaign(campaign: { id: number; name: string }) {
onSelect({ type: 'campaign', id: campaign.id, label: campaign.name }); if (isSelected('campaign', campaign.id)) {
setIsDropdownOpen(false); onRemove('campaign', campaign.id);
setSearchInput(''); } else {
onAdd({ type: 'campaign', id: campaign.id, label: campaign.name });
}
} }
function handleSelectPartner(partner: { id: number; display_name: string }) { function handleSelectPartner(partner: { id: number; display_name: string }) {
onSelect({ type: 'partner', id: partner.id, label: partner.display_name }); if (isSelected('partner', partner.id)) {
setIsDropdownOpen(false); onRemove('partner', partner.id);
setSearchInput(''); } else {
onAdd({ type: 'partner', id: partner.id, label: partner.display_name });
}
} }
function handleSelectUser(user: { id: number; display_name: string }) { function handleSelectUser(user: { id: number; display_name: string }) {
onSelect({ type: 'user', id: user.id, label: user.display_name }); if (isSelected('user', user.id)) {
setIsDropdownOpen(false); onRemove('user', user.id);
setSearchInput(''); } else {
} onAdd({ type: 'user', id: user.id, label: user.display_name });
}
function handleClear() {
onSelect(null);
setSearchInput(''); setSearchInput('');
setDebouncedUserQuery(''); setDebouncedUserQuery('');
setIsDropdownOpen(false);
} }
const tabLabels: Record<ScopeType, string> = { const tabLabels: Record<ScopeType, string> = {
@@ -120,18 +142,69 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps
user: t('admin.referralNetwork.scope.selectUser'), user: t('admin.referralNetwork.scope.selectUser'),
}; };
// When scope is selected, show compact display const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading;
if (value) {
return ( return (
<div className={`flex items-center gap-2 ${className ?? ''}`}> <div ref={containerRef} className={`relative ${className ?? ''}`}>
<span className="rounded-md bg-accent-500/20 px-2 py-1 text-xs font-medium text-accent-400"> {/* Chips row + search trigger */}
{tabLabels[value.type]} <div className="flex items-center gap-1.5">
</span> {/* Selected chips */}
<span className="truncate text-sm font-medium text-dark-100">{value.label}</span> {value.length > 0 && (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{value.map((item) => (
<span
key={`${item.type}:${item.id}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${CHIP_COLORS[item.type]}`}
>
<span className="max-w-[120px] truncate">{item.label}</span>
<button
onClick={() => onRemove(item.type, item.id)}
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"
>
<svg
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>
</span>
))}
{/* Clear all */}
<button
onClick={onClear}
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"
>
<svg
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>
</div>
)}
{/* Add button (always visible) */}
<button <button
onClick={handleClear} onClick={() => {
aria-label={t('admin.referralNetwork.search.clear')} setIsDropdownOpen((prev) => {
className="shrink-0 rounded-md p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300" if (!prev) {
setTimeout(() => inputRef.current?.focus(), 0);
}
return !prev;
});
}}
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"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -140,78 +213,69 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
</button> </button>
</div> </div>
);
}
const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading;
return (
<div ref={containerRef} className={`relative ${className ?? ''}`}>
{/* Tab bar + search input row */}
<div className="flex items-center gap-2">
{/* Tabs */}
<div className="flex shrink-0 rounded-lg border border-dark-700/50 bg-dark-800 p-0.5">
{SCOPE_TABS.map((tab) => (
<button
key={tab}
onClick={() => handleTabChange(tab)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
activeTab === tab
? 'bg-accent-500/20 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{tabLabels[tab]}
</button>
))}
</div>
{/* Search input */}
<div className="relative min-w-0 flex-1">
<svg
className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<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>
<input
ref={inputRef}
type="text"
value={searchInput}
maxLength={200}
onChange={(e) => {
setSearchInput(e.target.value);
if (!isDropdownOpen) setIsDropdownOpen(true);
}}
onFocus={() => setIsDropdownOpen(true)}
placeholder={placeholders[activeTab]}
className="w-full rounded-lg border border-dark-700/50 bg-dark-800/50 py-1.5 pl-8 pr-8 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
/>
{isLoading && (
<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" />
</div>
)}
</div>
</div>
{/* Dropdown */} {/* Dropdown */}
{isDropdownOpen && ( {isDropdownOpen && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-72 overflow-y-auto 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">
{activeTab === 'campaign' && renderCampaignList()} {/* Tab bar + search input */}
{activeTab === 'partner' && renderPartnerList()} <div className="flex items-center gap-2 border-b border-dark-700/50 px-3 py-2">
{activeTab === 'user' && renderUserList()} <div className="flex shrink-0 rounded-lg border border-dark-700/50 bg-dark-900 p-0.5">
{SCOPE_TABS.map((tab) => (
<button
key={tab}
onClick={() => handleTabChange(tab)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
activeTab === tab
? 'bg-accent-500/20 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{tabLabels[tab]}
</button>
))}
</div>
<div className="relative min-w-0 flex-1">
<svg
className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<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>
<input
ref={inputRef}
type="text"
value={searchInput}
maxLength={200}
onChange={(e) => setSearchInput(e.target.value)}
placeholder={placeholders[activeTab]}
className="w-full rounded-lg border border-dark-700/50 bg-dark-900/50 py-1.5 pl-8 pr-8 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
/>
{isLoading && (
<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" />
</div>
)}
</div>
</div>
{/* List */}
<div className="max-h-64 overflow-y-auto">
{activeTab === 'campaign' && renderCampaignList()}
{activeTab === 'partner' && renderPartnerList()}
{activeTab === 'user' && renderUserList()}
</div>
</div> </div>
)} )}
</div> </div>
@@ -234,36 +298,53 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps
); );
} }
return filteredCampaigns.map((campaign) => ( return filteredCampaigns.map((campaign) => {
<button const selected = isSelected('campaign', campaign.id);
key={campaign.id} return (
onClick={() => handleSelectCampaign(campaign)} <button
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50" key={campaign.id}
> onClick={() => handleSelectCampaign(campaign)}
<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"> className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50 ${
C selected ? 'bg-dark-700/30' : ''
</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
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${
campaign.is_active
? 'bg-success-500/20 text-success-400'
: 'bg-dark-700/50 text-dark-400'
}`} }`}
> >
{campaign.is_active <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">
? t('admin.referralNetwork.scope.active') {selected ? (
: t('admin.referralNetwork.scope.inactive')} <svg
</span> className="h-4 w-4"
</button> 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
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${
campaign.is_active
? 'bg-success-500/20 text-success-400'
: 'bg-dark-700/50 text-dark-400'
}`}
>
{campaign.is_active
? t('admin.referralNetwork.scope.active')
: t('admin.referralNetwork.scope.inactive')}
</span>
</button>
);
});
} }
function renderPartnerList() { function renderPartnerList() {
@@ -283,25 +364,42 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps
); );
} }
return filteredPartners.map((partner) => ( return filteredPartners.map((partner) => {
<button const selected = isSelected('partner', partner.id);
key={partner.id} return (
onClick={() => handleSelectPartner(partner)} <button
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50" key={partner.id}
> onClick={() => handleSelectPartner(partner)}
<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"> className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50 ${
P selected ? 'bg-dark-700/30' : ''
</div> }`}
<div className="min-w-0 flex-1"> >
<p className="truncate text-sm font-medium text-dark-100">{partner.display_name}</p> <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">
<p className="truncate text-xs text-dark-500"> {selected ? (
{partner.username ? `@${partner.username}` : ''} <svg
{partner.username ? ' / ' : ''} className="h-4 w-4"
{partner.campaign_count} {t('admin.referralNetwork.scope.campaigns')} fill="none"
</p> viewBox="0 0 24 24"
</div> stroke="currentColor"
</button> 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() {
@@ -331,28 +429,45 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps
); );
} }
return users.map((user) => ( return users.map((user) => {
<button const selected = isSelected('user', user.id);
key={user.id} return (
onClick={() => handleSelectUser(user)} <button
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50" key={user.id}
> onClick={() => handleSelectUser(user)}
<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"> className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50 ${
U selected ? 'bg-dark-700/30' : ''
</div> }`}
<div className="min-w-0 flex-1"> >
<p className="truncate text-sm font-medium text-dark-100">{user.display_name}</p> <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">
<p className="truncate text-xs text-dark-500"> {selected ? (
{user.username ? `@${user.username}` : ''} <svg
{user.tg_id ? ` #${user.tg_id}` : ''} className="h-4 w-4"
</p> fill="none"
</div> viewBox="0 0 24 24"
{user.is_partner && ( stroke="currentColor"
<span className="shrink-0 rounded bg-warning-500/20 px-1.5 py-0.5 text-[10px] font-medium text-warning-400"> strokeWidth={2}
{t('admin.referralNetwork.user.partner')} >
</span> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
)} </svg>
</button> ) : (
)); '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">
{t('admin.referralNetwork.user.partner')}
</span>
)}
</button>
);
});
} }
} }

View File

@@ -1,6 +1,8 @@
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;
const DEFAULT_FILTERS: NetworkFilters = { const DEFAULT_FILTERS: NetworkFilters = {
campaigns: [], campaigns: [],
partnersOnly: false, partnersOnly: false,
@@ -12,14 +14,16 @@ interface ReferralNetworkState {
hoveredNodeId: string | null; hoveredNodeId: string | null;
highlightedNodes: Set<string>; highlightedNodes: Set<string>;
filters: NetworkFilters; filters: NetworkFilters;
scope: ScopeSelection | null; scope: ScopeSelection[];
setSelectedNode: (node: SelectedNode) => void; setSelectedNode: (node: SelectedNode) => void;
setHoveredNode: (id: string | null) => void; setHoveredNode: (id: string | null) => void;
setHighlightedNodes: (nodes: Set<string>) => void; setHighlightedNodes: (nodes: Set<string>) => void;
updateFilters: (filters: Partial<NetworkFilters>) => void; updateFilters: (filters: Partial<NetworkFilters>) => void;
resetFilters: () => void; resetFilters: () => void;
setScope: (scope: ScopeSelection | null) => void; addScope: (item: ScopeSelection) => void;
removeScope: (type: ScopeSelection['type'], id: number) => void;
clearScope: () => void;
} }
export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => ({ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => ({
@@ -27,12 +31,10 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
hoveredNodeId: null, hoveredNodeId: null,
highlightedNodes: new Set<string>(), highlightedNodes: new Set<string>(),
filters: { ...DEFAULT_FILTERS }, filters: { ...DEFAULT_FILTERS },
scope: null, scope: [],
setSelectedNode: (node) => set({ selectedNode: node }), setSelectedNode: (node) => set({ selectedNode: node }),
setHoveredNode: (id) => set({ hoveredNodeId: id }), setHoveredNode: (id) => set({ hoveredNodeId: id }),
setHighlightedNodes: (nodes) => set({ highlightedNodes: nodes }), setHighlightedNodes: (nodes) => set({ highlightedNodes: nodes }),
updateFilters: (partial) => updateFilters: (partial) =>
@@ -42,9 +44,32 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
setScope: (scope) => addScope: (item) =>
set((state) => {
const exists = state.scope.some((s) => s.type === item.type && s.id === item.id);
if (exists) return state;
if (state.scope.length >= MAX_SCOPE_ITEMS) return state;
return {
scope: [...state.scope, item],
selectedNode: null,
hoveredNodeId: null,
highlightedNodes: new Set<string>(),
filters: { ...DEFAULT_FILTERS },
};
}),
removeScope: (type, id) =>
set((state) => ({
scope: state.scope.filter((s) => !(s.type === type && s.id === id)),
selectedNode: null,
hoveredNodeId: null,
highlightedNodes: new Set<string>(),
filters: { ...DEFAULT_FILTERS },
})),
clearScope: () =>
set({ set({
scope, scope: [],
selectedNode: null, selectedNode: null,
hoveredNodeId: null, hoveredNodeId: null,
highlightedNodes: new Set<string>(), highlightedNodes: new Set<string>(),