mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
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:
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1140,7 +1140,10 @@
|
|||||||
"campaigns": "کمپینها",
|
"campaigns": "کمپینها",
|
||||||
"active": "فعال",
|
"active": "فعال",
|
||||||
"inactive": "غیرفعال",
|
"inactive": "غیرفعال",
|
||||||
"noResults": "نتیجهای یافت نشد"
|
"noResults": "نتیجهای یافت نشد",
|
||||||
|
"removeItem": "حذف {{label}}",
|
||||||
|
"clearAll": "پاک کردن همه انتخابها",
|
||||||
|
"addScope": "افزودن محدوده"
|
||||||
},
|
},
|
||||||
"loading": "بارگذاری گراف...",
|
"loading": "بارگذاری گراف...",
|
||||||
"error": "خطا در بارگذاری دادههای شبکه",
|
"error": "خطا در بارگذاری دادههای شبکه",
|
||||||
|
|||||||
@@ -1327,7 +1327,10 @@
|
|||||||
"campaigns": "кампаний",
|
"campaigns": "кампаний",
|
||||||
"active": "Активна",
|
"active": "Активна",
|
||||||
"inactive": "Неактивна",
|
"inactive": "Неактивна",
|
||||||
"noResults": "Ничего не найдено"
|
"noResults": "Ничего не найдено",
|
||||||
|
"removeItem": "Удалить {{label}}",
|
||||||
|
"clearAll": "Очистить все выбранные",
|
||||||
|
"addScope": "Добавить область"
|
||||||
},
|
},
|
||||||
"loading": "Загрузка графа...",
|
"loading": "Загрузка графа...",
|
||||||
"error": "Ошибка загрузки данных сети",
|
"error": "Ошибка загрузки данных сети",
|
||||||
|
|||||||
@@ -1140,7 +1140,10 @@
|
|||||||
"campaigns": "活动",
|
"campaigns": "活动",
|
||||||
"active": "活跃",
|
"active": "活跃",
|
||||||
"inactive": "非活跃",
|
"inactive": "非活跃",
|
||||||
"noResults": "未找到"
|
"noResults": "未找到",
|
||||||
|
"removeItem": "移除 {{label}}",
|
||||||
|
"clearAll": "清除所有选择",
|
||||||
|
"addScope": "添加范围"
|
||||||
},
|
},
|
||||||
"loading": "加载图表...",
|
"loading": "加载图表...",
|
||||||
"error": "加载网络数据失败",
|
"error": "加载网络数据失败",
|
||||||
|
|||||||
@@ -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) && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(),
|
||||||
|
|||||||
Reference in New Issue
Block a user