mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 10:03:46 +00:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -1143,7 +1143,8 @@
|
|||||||
"noResults": "نتیجهای یافت نشد",
|
"noResults": "نتیجهای یافت نشد",
|
||||||
"removeItem": "حذف {{label}}",
|
"removeItem": "حذف {{label}}",
|
||||||
"clearAll": "پاک کردن همه انتخابها",
|
"clearAll": "پاک کردن همه انتخابها",
|
||||||
"addScope": "افزودن محدوده"
|
"addScope": "افزودن محدوده",
|
||||||
|
"maxReached": "حداکثر {{max}} مورد"
|
||||||
},
|
},
|
||||||
"loading": "بارگذاری گراف...",
|
"loading": "بارگذاری گراف...",
|
||||||
"error": "خطا در بارگذاری دادههای شبکه",
|
"error": "خطا در بارگذاری دادههای شبکه",
|
||||||
|
|||||||
@@ -1330,7 +1330,8 @@
|
|||||||
"noResults": "Ничего не найдено",
|
"noResults": "Ничего не найдено",
|
||||||
"removeItem": "Удалить {{label}}",
|
"removeItem": "Удалить {{label}}",
|
||||||
"clearAll": "Очистить все выбранные",
|
"clearAll": "Очистить все выбранные",
|
||||||
"addScope": "Добавить область"
|
"addScope": "Добавить область",
|
||||||
|
"maxReached": "Максимум {{max}} элементов"
|
||||||
},
|
},
|
||||||
"loading": "Загрузка графа...",
|
"loading": "Загрузка графа...",
|
||||||
"error": "Ошибка загрузки данных сети",
|
"error": "Ошибка загрузки данных сети",
|
||||||
|
|||||||
@@ -1143,7 +1143,8 @@
|
|||||||
"noResults": "未找到",
|
"noResults": "未找到",
|
||||||
"removeItem": "移除 {{label}}",
|
"removeItem": "移除 {{label}}",
|
||||||
"clearAll": "清除所有选择",
|
"clearAll": "清除所有选择",
|
||||||
"addScope": "添加范围"
|
"addScope": "添加范围",
|
||||||
|
"maxReached": "最多 {{max}} 个项目"
|
||||||
},
|
},
|
||||||
"loading": "加载图表...",
|
"loading": "加载图表...",
|
||||||
"error": "加载网络数据失败",
|
"error": "加载网络数据失败",
|
||||||
|
|||||||
@@ -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>
|
}
|
||||||
);
|
/>
|
||||||
});
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user