mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
feat: redesign referral network with scope selector
- Replace full graph with scoped view (campaign/partner/user) - ScopeSelector component with tabs and searchable dropdown - Store resets all state on scope change (selection, hover, filters) - Remove dead NetworkSearch component and graphInstance globals - i18n keys for scope selector in all 4 locales
This commit is contained in:
@@ -4,11 +4,19 @@ import type {
|
|||||||
NetworkUserDetail,
|
NetworkUserDetail,
|
||||||
NetworkCampaignDetail,
|
NetworkCampaignDetail,
|
||||||
NetworkSearchResult,
|
NetworkSearchResult,
|
||||||
|
ScopeOptionsData,
|
||||||
} from '@/types/referralNetwork';
|
} from '@/types/referralNetwork';
|
||||||
|
|
||||||
export const referralNetworkApi = {
|
export const referralNetworkApi = {
|
||||||
getNetworkGraph: async (): Promise<NetworkGraphData> => {
|
getScopeOptions: async (): Promise<ScopeOptionsData> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/referral-network/');
|
const response = await apiClient.get('/cabinet/admin/referral-network/scope-options');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getScopedGraph: async (scope: string, id: number): Promise<NetworkGraphData> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/referral-network/scoped', {
|
||||||
|
params: { scope, id },
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1292,6 +1292,22 @@
|
|||||||
"resetZoom": "Reset zoom",
|
"resetZoom": "Reset zoom",
|
||||||
"fullscreen": "Fullscreen"
|
"fullscreen": "Fullscreen"
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"title": "Scope",
|
||||||
|
"campaign": "Campaign",
|
||||||
|
"partner": "Partner",
|
||||||
|
"user": "User",
|
||||||
|
"searchPlaceholder": "Search...",
|
||||||
|
"selectCampaign": "Select campaign",
|
||||||
|
"selectPartner": "Select partner",
|
||||||
|
"selectUser": "Search user",
|
||||||
|
"emptyState": "Select a campaign, partner, or user to view their network",
|
||||||
|
"users": "users",
|
||||||
|
"campaigns": "campaigns",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"noResults": "Nothing found"
|
||||||
|
},
|
||||||
"loading": "Loading graph...",
|
"loading": "Loading graph...",
|
||||||
"error": "Failed to load network data",
|
"error": "Failed to load network data",
|
||||||
"empty": "No data to display"
|
"empty": "No data to display"
|
||||||
|
|||||||
@@ -1126,6 +1126,22 @@
|
|||||||
"resetZoom": "بازنشانی زوم",
|
"resetZoom": "بازنشانی زوم",
|
||||||
"fullscreen": "تمامصفحه"
|
"fullscreen": "تمامصفحه"
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"title": "محدوده",
|
||||||
|
"campaign": "کمپین",
|
||||||
|
"partner": "شریک",
|
||||||
|
"user": "کاربر",
|
||||||
|
"searchPlaceholder": "جستجو...",
|
||||||
|
"selectCampaign": "انتخاب کمپین",
|
||||||
|
"selectPartner": "انتخاب شریک",
|
||||||
|
"selectUser": "جستجوی کاربر",
|
||||||
|
"emptyState": "یک کمپین، شریک یا کاربر را برای مشاهده شبکه انتخاب کنید",
|
||||||
|
"users": "کاربران",
|
||||||
|
"campaigns": "کمپینها",
|
||||||
|
"active": "فعال",
|
||||||
|
"inactive": "غیرفعال",
|
||||||
|
"noResults": "نتیجهای یافت نشد"
|
||||||
|
},
|
||||||
"loading": "بارگذاری گراف...",
|
"loading": "بارگذاری گراف...",
|
||||||
"error": "خطا در بارگذاری دادههای شبکه",
|
"error": "خطا در بارگذاری دادههای شبکه",
|
||||||
"empty": "دادهای برای نمایش وجود ندارد"
|
"empty": "دادهای برای نمایش وجود ندارد"
|
||||||
|
|||||||
@@ -1313,6 +1313,22 @@
|
|||||||
"resetZoom": "Сбросить зум",
|
"resetZoom": "Сбросить зум",
|
||||||
"fullscreen": "Полный экран"
|
"fullscreen": "Полный экран"
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"title": "Область",
|
||||||
|
"campaign": "Кампания",
|
||||||
|
"partner": "Партнёр",
|
||||||
|
"user": "Пользователь",
|
||||||
|
"searchPlaceholder": "Поиск...",
|
||||||
|
"selectCampaign": "Выберите кампанию",
|
||||||
|
"selectPartner": "Выберите партнёра",
|
||||||
|
"selectUser": "Найти пользователя",
|
||||||
|
"emptyState": "Выберите кампанию, партнёра или пользователя для просмотра сети",
|
||||||
|
"users": "пользователей",
|
||||||
|
"campaigns": "кампаний",
|
||||||
|
"active": "Активна",
|
||||||
|
"inactive": "Неактивна",
|
||||||
|
"noResults": "Ничего не найдено"
|
||||||
|
},
|
||||||
"loading": "Загрузка графа...",
|
"loading": "Загрузка графа...",
|
||||||
"error": "Ошибка загрузки данных сети",
|
"error": "Ошибка загрузки данных сети",
|
||||||
"empty": "Нет данных для отображения"
|
"empty": "Нет данных для отображения"
|
||||||
|
|||||||
@@ -1126,6 +1126,22 @@
|
|||||||
"resetZoom": "重置缩放",
|
"resetZoom": "重置缩放",
|
||||||
"fullscreen": "全屏"
|
"fullscreen": "全屏"
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"title": "范围",
|
||||||
|
"campaign": "活动",
|
||||||
|
"partner": "合作伙伴",
|
||||||
|
"user": "用户",
|
||||||
|
"searchPlaceholder": "搜索...",
|
||||||
|
"selectCampaign": "选择活动",
|
||||||
|
"selectPartner": "选择合作伙伴",
|
||||||
|
"selectUser": "搜索用户",
|
||||||
|
"emptyState": "选择活动、合作伙伴或用户以查看其网络",
|
||||||
|
"users": "用户",
|
||||||
|
"campaigns": "活动",
|
||||||
|
"active": "活跃",
|
||||||
|
"inactive": "非活跃",
|
||||||
|
"noResults": "未找到"
|
||||||
|
},
|
||||||
"loading": "加载图表...",
|
"loading": "加载图表...",
|
||||||
"error": "加载网络数据失败",
|
"error": "加载网络数据失败",
|
||||||
"empty": "无数据显示"
|
"empty": "无数据显示"
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { referralNetworkApi } from '@/api/referralNetwork';
|
|||||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||||
import { AdminBackButton } from '@/components/admin/AdminBackButton';
|
import { AdminBackButton } from '@/components/admin/AdminBackButton';
|
||||||
import { NetworkGraph } from './components/NetworkGraph';
|
import { NetworkGraph } from './components/NetworkGraph';
|
||||||
import { NetworkSearch } from './components/NetworkSearch';
|
import { ScopeSelector } from './components/ScopeSelector';
|
||||||
import { NetworkFilters } from './components/NetworkFilters';
|
|
||||||
import { UserDetailPanel } from './components/UserDetailPanel';
|
import { UserDetailPanel } from './components/UserDetailPanel';
|
||||||
import { CampaignDetailPanel } from './components/CampaignDetailPanel';
|
import { CampaignDetailPanel } from './components/CampaignDetailPanel';
|
||||||
import { NetworkStats } from './components/NetworkStats';
|
import { NetworkStats } from './components/NetworkStats';
|
||||||
@@ -16,14 +15,17 @@ import { NetworkControls } from './components/NetworkControls';
|
|||||||
export function ReferralNetwork() {
|
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 setScope = useReferralNetworkStore((s) => s.setScope);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: networkData,
|
data: networkData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['referral-network-graph'],
|
queryKey: ['referral-network', 'scoped', scope?.type, scope?.id],
|
||||||
queryFn: referralNetworkApi.getNetworkGraph,
|
queryFn: () => referralNetworkApi.getScopedGraph(scope!.type, scope!.id),
|
||||||
|
enabled: scope !== null,
|
||||||
staleTime: 120_000,
|
staleTime: 120_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,13 +42,37 @@ 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>
|
||||||
<NetworkSearch className="min-w-0 flex-1 sm:max-w-md" />
|
<ScopeSelector value={scope} onSelect={setScope} className="min-w-0 flex-1 sm:max-w-xl" />
|
||||||
{networkData && <NetworkFilters data={networkData} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
{isLoading && (
|
{/* Empty state: no scope selected */}
|
||||||
|
{!scope && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="mx-auto mb-4 h-12 w-12 text-dark-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="max-w-xs text-sm text-dark-500">
|
||||||
|
{t('admin.referralNetwork.scope.emptyState')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{scope && 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" />
|
||||||
@@ -55,36 +81,45 @@ export function ReferralNetwork() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isError && (
|
{/* Error state */}
|
||||||
|
{scope && 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && (
|
{/* Empty data */}
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
{scope &&
|
||||||
<p className="text-sm text-dark-500">{t('admin.referralNetwork.empty')}</p>
|
networkData &&
|
||||||
</div>
|
networkData.users.length === 0 &&
|
||||||
)}
|
networkData.campaigns.length === 0 && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
{networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && (
|
<p className="text-sm text-dark-500">{t('admin.referralNetwork.empty')}</p>
|
||||||
<>
|
|
||||||
<NetworkGraph data={networkData} className="absolute inset-0 h-full w-full" />
|
|
||||||
|
|
||||||
<div className="absolute bottom-3 left-3 z-10 sm:bottom-4 sm:left-4">
|
|
||||||
<NetworkStats data={networkData} />
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="absolute bottom-3 right-3 z-10 hidden sm:bottom-4 sm:right-4 sm:block">
|
{/* Graph + overlays */}
|
||||||
<NetworkLegend />
|
{scope &&
|
||||||
</div>
|
networkData &&
|
||||||
|
(networkData.users.length > 0 || networkData.campaigns.length > 0) && (
|
||||||
|
<>
|
||||||
|
<NetworkGraph data={networkData} className="absolute inset-0 h-full w-full" />
|
||||||
|
|
||||||
<div className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 sm:bottom-4">
|
<div className="absolute bottom-3 left-3 z-10 sm:bottom-4 sm:left-4">
|
||||||
<NetworkControls />
|
<NetworkStats data={networkData} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
<div className="absolute bottom-3 right-3 z-10 hidden sm:bottom-4 sm:right-4 sm:block">
|
||||||
|
<NetworkLegend />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 sm:bottom-4">
|
||||||
|
<NetworkControls />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail panel (slide-in from right) */}
|
||||||
<div
|
<div
|
||||||
className={`absolute right-0 top-0 z-30 flex h-full w-full flex-col border-l border-dark-700/50 bg-dark-900/95 backdrop-blur-md transition-transform duration-300 ease-in-out sm:w-[400px] ${
|
className={`absolute right-0 top-0 z-30 flex h-full w-full flex-col border-l border-dark-700/50 bg-dark-900/95 backdrop-blur-md transition-transform duration-300 ease-in-out sm:w-[400px] ${
|
||||||
isPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
isPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||||
import type { NetworkGraphData } from '@/types/referralNetwork';
|
import type { NetworkGraphData } from '@/types/referralNetwork';
|
||||||
@@ -8,6 +8,10 @@ interface NetworkFiltersProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated No longer used in the main page — replaced by ScopeSelector.
|
||||||
|
* Kept for potential future reuse.
|
||||||
|
*/
|
||||||
export function NetworkFilters({ data, className }: NetworkFiltersProps) {
|
export function NetworkFilters({ data, className }: NetworkFiltersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -15,8 +19,7 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) {
|
|||||||
const filters = useReferralNetworkStore((s) => s.filters);
|
const filters = useReferralNetworkStore((s) => s.filters);
|
||||||
const updateFilters = useReferralNetworkStore((s) => s.updateFilters);
|
const updateFilters = useReferralNetworkStore((s) => s.updateFilters);
|
||||||
const resetFilters = useReferralNetworkStore((s) => s.resetFilters);
|
const resetFilters = useReferralNetworkStore((s) => s.resetFilters);
|
||||||
const isOpen = useReferralNetworkStore((s) => s.isFiltersOpen);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const setIsOpen = useReferralNetworkStore((s) => s.setIsFiltersOpen);
|
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
filters.campaigns.length > 0 || filters.partnersOnly || filters.minReferrals > 0;
|
filters.campaigns.length > 0 || filters.partnersOnly || filters.minReferrals > 0;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { inferSettings } from 'graphology-layout-forceatlas2';
|
|||||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||||
import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork';
|
import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork';
|
||||||
import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils';
|
import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils';
|
||||||
import { setSigmaInstance, setGraphInstance } from '../sigmaGlobals';
|
import { setSigmaInstance } from '../sigmaGlobals';
|
||||||
|
|
||||||
interface NetworkGraphProps {
|
interface NetworkGraphProps {
|
||||||
data: NetworkGraphData;
|
data: NetworkGraphData;
|
||||||
@@ -145,6 +145,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
|||||||
|
|
||||||
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
||||||
const setHoveredNode = useReferralNetworkStore((s) => s.setHoveredNode);
|
const setHoveredNode = useReferralNetworkStore((s) => s.setHoveredNode);
|
||||||
|
const setHighlightedNodes = useReferralNetworkStore((s) => s.setHighlightedNodes);
|
||||||
const hoveredNodeId = useReferralNetworkStore((s) => s.hoveredNodeId);
|
const hoveredNodeId = useReferralNetworkStore((s) => s.hoveredNodeId);
|
||||||
const highlightedNodes = useReferralNetworkStore((s) => s.highlightedNodes);
|
const highlightedNodes = useReferralNetworkStore((s) => s.highlightedNodes);
|
||||||
const filters = useReferralNetworkStore((s) => s.filters);
|
const filters = useReferralNetworkStore((s) => s.filters);
|
||||||
@@ -268,7 +269,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
|||||||
|
|
||||||
sigmaRef.current = sigma;
|
sigmaRef.current = sigma;
|
||||||
setSigmaInstance(sigma);
|
setSigmaInstance(sigma);
|
||||||
setGraphInstance(graph);
|
|
||||||
|
|
||||||
if (graph.order > 0) {
|
if (graph.order > 0) {
|
||||||
const inferred = inferSettings(graph);
|
const inferred = inferSettings(graph);
|
||||||
@@ -348,9 +348,11 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
|||||||
}
|
}
|
||||||
graphRef.current = null;
|
graphRef.current = null;
|
||||||
setSigmaInstance(null);
|
setSigmaInstance(null);
|
||||||
setGraphInstance(null);
|
|
||||||
|
setHoveredNode(null);
|
||||||
|
setHighlightedNodes(new Set());
|
||||||
};
|
};
|
||||||
}, [data, setSelectedNode, setHoveredNode, killFA2]);
|
}, [data, setSelectedNode, setHoveredNode, setHighlightedNodes, killFA2]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { referralNetworkApi } from '@/api/referralNetwork';
|
|
||||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
|
||||||
import { getSigmaInstance, getGraphInstance } from '../sigmaGlobals';
|
|
||||||
|
|
||||||
interface NetworkSearchProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NetworkSearch({ className }: NetworkSearchProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const setHighlightedNodes = useReferralNetworkStore((s) => s.setHighlightedNodes);
|
|
||||||
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
|
||||||
|
|
||||||
// Debounce
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setDebouncedQuery(inputValue.trim());
|
|
||||||
}, 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [inputValue]);
|
|
||||||
|
|
||||||
const { data: searchResults, isFetching } = useQuery({
|
|
||||||
queryKey: ['referral-network-search', debouncedQuery],
|
|
||||||
queryFn: () => referralNetworkApi.search(debouncedQuery),
|
|
||||||
enabled: debouncedQuery.length >= 2,
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open dropdown when results arrive
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchResults && debouncedQuery.length >= 2) {
|
|
||||||
setIsDropdownOpen(true);
|
|
||||||
}
|
|
||||||
}, [searchResults, debouncedQuery]);
|
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function navigateToNode(nodeId: string) {
|
|
||||||
const sigma = getSigmaInstance();
|
|
||||||
const graph = getGraphInstance();
|
|
||||||
|
|
||||||
if (sigma && graph && graph.hasNode(nodeId)) {
|
|
||||||
// getNodeDisplayData returns viewport (pixel) coordinates.
|
|
||||||
// Convert to framed graph coordinates that the camera operates in.
|
|
||||||
const displayData = sigma.getNodeDisplayData(nodeId);
|
|
||||||
if (displayData) {
|
|
||||||
const camera = sigma.getCamera();
|
|
||||||
const framedCoords = sigma.viewportToFramedGraph({
|
|
||||||
x: displayData.x,
|
|
||||||
y: displayData.y,
|
|
||||||
});
|
|
||||||
camera.animate({ ...framedCoords, ratio: 0.15 }, { duration: 600 });
|
|
||||||
}
|
|
||||||
|
|
||||||
setHighlightedNodes(new Set([nodeId]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectUser(userId: number) {
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
setSelectedNode({ type: 'user', id: userId });
|
|
||||||
navigateToNode(`user_${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectCampaign(campaignId: number) {
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
setSelectedNode({ type: 'campaign', id: campaignId });
|
|
||||||
navigateToNode(`campaign_${campaignId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
setInputValue('');
|
|
||||||
setDebouncedQuery('');
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
setHighlightedNodes(new Set());
|
|
||||||
setSelectedNode(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalResults =
|
|
||||||
(searchResults?.users?.length ?? 0) + (searchResults?.campaigns?.length ?? 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={dropdownRef} className={`relative ${className ?? ''}`}>
|
|
||||||
<div className="relative">
|
|
||||||
<svg
|
|
||||||
className="absolute left-3 top-1/2 h-4 w-4 -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={inputValue}
|
|
||||||
maxLength={200}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (searchResults && debouncedQuery.length >= 2) setIsDropdownOpen(true);
|
|
||||||
}}
|
|
||||||
placeholder={t('admin.referralNetwork.search.placeholder')}
|
|
||||||
className="w-full rounded-lg border border-dark-700/50 bg-dark-800/80 py-2 pl-10 pr-10 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"
|
|
||||||
/>
|
|
||||||
{inputValue && (
|
|
||||||
<button
|
|
||||||
onClick={handleClear}
|
|
||||||
aria-label={t('admin.referralNetwork.search.clear')}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isFetching && (
|
|
||||||
<div className="absolute right-10 top-1/2 -translate-y-1/2">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dropdown */}
|
|
||||||
{isDropdownOpen && debouncedQuery.length >= 2 && (
|
|
||||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-y-auto rounded-lg border border-dark-700/50 bg-dark-900/95 shadow-xl backdrop-blur-md">
|
|
||||||
{totalResults === 0 && !isFetching && (
|
|
||||||
<div className="px-4 py-3 text-center text-sm text-dark-500">
|
|
||||||
{t('admin.referralNetwork.search.noResults')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchResults?.users && searchResults.users.length > 0 && (
|
|
||||||
<div>
|
|
||||||
{searchResults.users.map((user) => (
|
|
||||||
<button
|
|
||||||
key={`u-${user.id}`}
|
|
||||||
onClick={() => handleSelectUser(user.id)}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-800/80"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchResults?.campaigns && searchResults.campaigns.length > 0 && (
|
|
||||||
<div>
|
|
||||||
{searchResults.users && searchResults.users.length > 0 && (
|
|
||||||
<div className="mx-4 border-t border-dark-700/50" />
|
|
||||||
)}
|
|
||||||
{searchResults.campaigns.map((campaign) => (
|
|
||||||
<button
|
|
||||||
key={`c-${campaign.id}`}
|
|
||||||
onClick={() => handleSelectCampaign(campaign.id)}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-800/80"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
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}</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.campaign.active')
|
|
||||||
: t('admin.referralNetwork.campaign.inactive')}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalResults > 0 && (
|
|
||||||
<div className="border-t border-dark-700/50 px-4 py-2 text-center text-xs text-dark-500">
|
|
||||||
{t('admin.referralNetwork.search.resultsCount', { count: totalResults })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
358
src/pages/ReferralNetwork/components/ScopeSelector.tsx
Normal file
358
src/pages/ReferralNetwork/components/ScopeSelector.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { referralNetworkApi } from '@/api/referralNetwork';
|
||||||
|
import type { ScopeSelection, ScopeType } from '@/types/referralNetwork';
|
||||||
|
|
||||||
|
interface ScopeSelectorProps {
|
||||||
|
value: ScopeSelection | null;
|
||||||
|
onSelect: (selection: ScopeSelection | null) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user'];
|
||||||
|
|
||||||
|
export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState<ScopeType>(value?.type ?? 'campaign');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [debouncedUserQuery, setDebouncedUserQuery] = useState('');
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data: scopeOptions, isLoading: isScopeLoading } = useQuery({
|
||||||
|
queryKey: ['referral-network', 'scope-options'],
|
||||||
|
queryFn: referralNetworkApi.getScopeOptions,
|
||||||
|
staleTime: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounce user search
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'user') return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedUserQuery(searchInput.trim());
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput, activeTab]);
|
||||||
|
|
||||||
|
const { data: userSearchResults, isFetching: isUserSearching } = useQuery({
|
||||||
|
queryKey: ['referral-network-search', debouncedUserQuery],
|
||||||
|
queryFn: () => referralNetworkApi.search(debouncedUserQuery),
|
||||||
|
enabled: activeTab === 'user' && debouncedUserQuery.length >= 2,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter campaigns/partners by local search input
|
||||||
|
const filteredCampaigns = useMemo(() => {
|
||||||
|
if (!scopeOptions) return [];
|
||||||
|
const q = searchInput.toLowerCase().trim();
|
||||||
|
if (!q) return scopeOptions.campaigns;
|
||||||
|
return scopeOptions.campaigns.filter(
|
||||||
|
(c) => c.name.toLowerCase().includes(q) || c.start_parameter.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [scopeOptions, searchInput]);
|
||||||
|
|
||||||
|
const filteredPartners = useMemo(() => {
|
||||||
|
if (!scopeOptions) return [];
|
||||||
|
const q = searchInput.toLowerCase().trim();
|
||||||
|
if (!q) return scopeOptions.partners;
|
||||||
|
return scopeOptions.partners.filter(
|
||||||
|
(p) =>
|
||||||
|
p.display_name.toLowerCase().includes(q) ||
|
||||||
|
(p.username && p.username.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}, [scopeOptions, searchInput]);
|
||||||
|
|
||||||
|
function handleTabChange(tab: ScopeType) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setSearchInput('');
|
||||||
|
setDebouncedUserQuery('');
|
||||||
|
setIsDropdownOpen(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectCampaign(campaign: { id: number; name: string }) {
|
||||||
|
onSelect({ type: 'campaign', id: campaign.id, label: campaign.name });
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
setSearchInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectPartner(partner: { id: number; display_name: string }) {
|
||||||
|
onSelect({ type: 'partner', id: partner.id, label: partner.display_name });
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
setSearchInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectUser(user: { id: number; display_name: string }) {
|
||||||
|
onSelect({ type: 'user', id: user.id, label: user.display_name });
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
setSearchInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
onSelect(null);
|
||||||
|
setSearchInput('');
|
||||||
|
setDebouncedUserQuery('');
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabLabels: Record<ScopeType, string> = {
|
||||||
|
campaign: t('admin.referralNetwork.scope.campaign'),
|
||||||
|
partner: t('admin.referralNetwork.scope.partner'),
|
||||||
|
user: t('admin.referralNetwork.scope.user'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholders: Record<ScopeType, string> = {
|
||||||
|
campaign: t('admin.referralNetwork.scope.selectCampaign'),
|
||||||
|
partner: t('admin.referralNetwork.scope.selectPartner'),
|
||||||
|
user: t('admin.referralNetwork.scope.selectUser'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// When scope is selected, show compact display
|
||||||
|
if (value) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${className ?? ''}`}>
|
||||||
|
<span className="rounded-md bg-accent-500/20 px-2 py-1 text-xs font-medium text-accent-400">
|
||||||
|
{tabLabels[value.type]}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-medium text-dark-100">{value.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
aria-label={t('admin.referralNetwork.search.clear')}
|
||||||
|
className="shrink-0 rounded-md p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
{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">
|
||||||
|
{activeTab === 'campaign' && renderCampaignList()}
|
||||||
|
{activeTab === 'partner' && renderPartnerList()}
|
||||||
|
{activeTab === 'user' && renderUserList()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderCampaignList() {
|
||||||
|
if (isScopeLoading) {
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredCampaigns.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.referralNetwork.scope.noResults')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredCampaigns.map((campaign) => (
|
||||||
|
<button
|
||||||
|
key={campaign.id}
|
||||||
|
onClick={() => handleSelectCampaign(campaign)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
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() {
|
||||||
|
if (isScopeLoading) {
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredPartners.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.referralNetwork.scope.noResults')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredPartners.map((partner) => (
|
||||||
|
<button
|
||||||
|
key={partner.id}
|
||||||
|
onClick={() => handleSelectPartner(partner)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
</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() {
|
||||||
|
if (debouncedUserQuery.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.referralNetwork.scope.selectUser')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUserSearching) {
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = userSearchResults?.users ?? [];
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.referralNetwork.scope.noResults')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
onClick={() => handleSelectUser(user)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
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,12 +1,10 @@
|
|||||||
import type Sigma from 'sigma';
|
import type Sigma from 'sigma';
|
||||||
import type Graph from 'graphology';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We store sigma and graph instances on a module-level variable
|
* We store the sigma instance on a module-level variable
|
||||||
* so that search and controls can access them without prop drilling.
|
* so that controls can access it without prop drilling.
|
||||||
*/
|
*/
|
||||||
let sigmaInstance: Sigma | null = null;
|
let sigmaInstance: Sigma | null = null;
|
||||||
let graphInstance: Graph | null = null;
|
|
||||||
|
|
||||||
export function setSigmaInstance(sigma: Sigma | null): void {
|
export function setSigmaInstance(sigma: Sigma | null): void {
|
||||||
sigmaInstance = sigma;
|
sigmaInstance = sigma;
|
||||||
@@ -15,11 +13,3 @@ export function setSigmaInstance(sigma: Sigma | null): void {
|
|||||||
export function getSigmaInstance(): Sigma | null {
|
export function getSigmaInstance(): Sigma | null {
|
||||||
return sigmaInstance;
|
return sigmaInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setGraphInstance(graph: Graph | null): void {
|
|
||||||
graphInstance = graph;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGraphInstance(): Graph | null {
|
|
||||||
return graphInstance;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { SelectedNode, NetworkFilters } from '@/types/referralNetwork';
|
import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork';
|
||||||
|
|
||||||
const DEFAULT_FILTERS: NetworkFilters = {
|
const DEFAULT_FILTERS: NetworkFilters = {
|
||||||
campaigns: [],
|
campaigns: [],
|
||||||
@@ -12,14 +12,14 @@ interface ReferralNetworkState {
|
|||||||
hoveredNodeId: string | null;
|
hoveredNodeId: string | null;
|
||||||
highlightedNodes: Set<string>;
|
highlightedNodes: Set<string>;
|
||||||
filters: NetworkFilters;
|
filters: NetworkFilters;
|
||||||
isFiltersOpen: boolean;
|
scope: ScopeSelection | null;
|
||||||
|
|
||||||
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;
|
||||||
setIsFiltersOpen: (open: boolean) => void;
|
setScope: (scope: ScopeSelection | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => ({
|
export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => ({
|
||||||
@@ -27,7 +27,7 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
|
|||||||
hoveredNodeId: null,
|
hoveredNodeId: null,
|
||||||
highlightedNodes: new Set<string>(),
|
highlightedNodes: new Set<string>(),
|
||||||
filters: { ...DEFAULT_FILTERS },
|
filters: { ...DEFAULT_FILTERS },
|
||||||
isFiltersOpen: false,
|
scope: null,
|
||||||
|
|
||||||
setSelectedNode: (node) => set({ selectedNode: node }),
|
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||||
|
|
||||||
@@ -42,5 +42,12 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
|
|||||||
|
|
||||||
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
|
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
|
||||||
|
|
||||||
setIsFiltersOpen: (open) => set({ isFiltersOpen: open }),
|
setScope: (scope) =>
|
||||||
|
set({
|
||||||
|
scope,
|
||||||
|
selectedNode: null,
|
||||||
|
hoveredNodeId: null,
|
||||||
|
highlightedNodes: new Set<string>(),
|
||||||
|
filters: { ...DEFAULT_FILTERS },
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -100,3 +100,31 @@ export interface NetworkFilters {
|
|||||||
partnersOnly: boolean;
|
partnersOnly: boolean;
|
||||||
minReferrals: number;
|
minReferrals: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CampaignOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
start_parameter: string;
|
||||||
|
is_active: boolean;
|
||||||
|
direct_users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerOption {
|
||||||
|
id: number;
|
||||||
|
display_name: string;
|
||||||
|
username: string | null;
|
||||||
|
campaign_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopeOptionsData {
|
||||||
|
campaigns: CampaignOption[];
|
||||||
|
partners: PartnerOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScopeType = 'campaign' | 'partner' | 'user';
|
||||||
|
|
||||||
|
export interface ScopeSelection {
|
||||||
|
type: ScopeType;
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user