mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat: add referral network graph visualization page
Interactive Sigma.js + graphology graph with ForceAtlas2 web worker layout, campaign/user node coloring, filters via nodeReducer pattern, search highlighting, user/campaign detail side panels, and 4-locale translations. Dependencies: sigma v3, graphology, graphology-layout-forceatlas2.
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -115,6 +115,7 @@ const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
|
||||
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
|
||||
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
|
||||
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
|
||||
const AdminReferralNetwork = lazy(() => import('./pages/ReferralNetwork'));
|
||||
|
||||
function ProtectedRoute({
|
||||
children,
|
||||
@@ -919,6 +920,16 @@ function App() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/referral-network"
|
||||
element={
|
||||
<PermissionRoute permission="stats:read">
|
||||
<LazyPage>
|
||||
<AdminReferralNetwork />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payment-methods"
|
||||
element={
|
||||
|
||||
31
src/api/referralNetwork.ts
Normal file
31
src/api/referralNetwork.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
NetworkGraphData,
|
||||
NetworkUserDetail,
|
||||
NetworkCampaignDetail,
|
||||
NetworkSearchResult,
|
||||
} from '@/types/referralNetwork';
|
||||
|
||||
export const referralNetworkApi = {
|
||||
getNetworkGraph: async (): Promise<NetworkGraphData> => {
|
||||
const response = await apiClient.get('/cabinet/admin/referral-network/');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUserDetail: async (userId: number): Promise<NetworkUserDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/referral-network/user/${userId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCampaignDetail: async (campaignId: number): Promise<NetworkCampaignDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/referral-network/campaign/${campaignId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
search: async (query: string): Promise<NetworkSearchResult> => {
|
||||
const response = await apiClient.get('/cabinet/admin/referral-network/search', {
|
||||
params: { q: query },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -1106,7 +1106,8 @@
|
||||
"policies": "Access Policies",
|
||||
"auditLog": "Audit Log",
|
||||
"salesStats": "Sales Statistics",
|
||||
"landings": "Landings"
|
||||
"landings": "Landings",
|
||||
"referralNetwork": "Referral Network"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Admin Panel",
|
||||
@@ -1140,7 +1141,8 @@
|
||||
"policiesDesc": "Configure ABAC access policies",
|
||||
"auditLogDesc": "Review system activity and access history",
|
||||
"salesStatsDesc": "Sales analytics and trends",
|
||||
"landingsDesc": "Quick purchase landing pages"
|
||||
"landingsDesc": "Quick purchase landing pages",
|
||||
"referralNetworkDesc": "Referral graph and campaign visualization"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "Sales Statistics",
|
||||
@@ -1222,6 +1224,77 @@
|
||||
},
|
||||
"loadError": "Failed to load statistics"
|
||||
},
|
||||
"referralNetwork": {
|
||||
"title": "Referral Network",
|
||||
"subtitle": "Referral graph and advertising campaign visualization",
|
||||
"search": {
|
||||
"placeholder": "Search by TG ID, @username, email, campaign...",
|
||||
"noResults": "Nothing found",
|
||||
"resultsCount": "Found: {{count}}",
|
||||
"clear": "Clear search"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"campaigns": "Campaigns",
|
||||
"partnersOnly": "Partners only",
|
||||
"minReferrals": "Min referrals",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"stats": {
|
||||
"totalUsers": "Users",
|
||||
"totalReferrers": "Referrers",
|
||||
"totalCampaigns": "Campaigns",
|
||||
"totalEarnings": "Total earnings"
|
||||
},
|
||||
"legend": {
|
||||
"title": "Legend",
|
||||
"regularUser": "Regular user",
|
||||
"activeReferrer": "Active referrer",
|
||||
"partner": "Partner",
|
||||
"topReferrer": "Top referrer (10+)",
|
||||
"campaignUser": "From campaign",
|
||||
"campaignNode": "Ad campaign"
|
||||
},
|
||||
"user": {
|
||||
"tgId": "TG ID",
|
||||
"email": "Email",
|
||||
"partner": "Partner",
|
||||
"subscription": "Subscription",
|
||||
"validUntil": "until {{date}}",
|
||||
"noSubscription": "No subscription",
|
||||
"personalStats": "Personal statistics",
|
||||
"totalSpent": "Total spent",
|
||||
"referralEarnings": "Referral earnings",
|
||||
"referralBranch": "Referral branch",
|
||||
"directReferrals": "Direct referrals",
|
||||
"branchSize": "Total in branch",
|
||||
"branchRevenue": "Branch revenue",
|
||||
"source": "Source",
|
||||
"referredBy": "Referred by",
|
||||
"fromCampaign": "Campaign",
|
||||
"organic": "Organic"
|
||||
},
|
||||
"campaign": {
|
||||
"startParam": "Parameter",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"directUsers": "Direct",
|
||||
"totalNetwork": "Total network",
|
||||
"totalRevenue": "Total revenue",
|
||||
"conversionRate": "Conversion",
|
||||
"avgCheck": "Avg check",
|
||||
"topReferrers": "Top referrers from campaign"
|
||||
},
|
||||
"controls": {
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"resetZoom": "Reset zoom",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
"loading": "Loading graph...",
|
||||
"error": "Failed to load network data",
|
||||
"empty": "No data to display"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Traffic Usage",
|
||||
"subtitle": "Per-user traffic statistics by nodes",
|
||||
|
||||
@@ -940,7 +940,8 @@
|
||||
"policies": "سیاستهای دسترسی",
|
||||
"auditLog": "گزارش بازرسی",
|
||||
"salesStats": "آمار فروش",
|
||||
"landings": "صفحات فرود"
|
||||
"landings": "صفحات فرود",
|
||||
"referralNetwork": "شبکه ارجاع"
|
||||
},
|
||||
"panel": {
|
||||
"title": "پنل مدیریت",
|
||||
@@ -974,7 +975,8 @@
|
||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
||||
"salesStatsDesc": "تحلیل فروش و روندها",
|
||||
"landingsDesc": "صفحات خرید سریع"
|
||||
"landingsDesc": "صفحات خرید سریع",
|
||||
"referralNetworkDesc": "نمایش بصری گراف ارجاعات و کمپینها"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "آمار فروش",
|
||||
@@ -1056,6 +1058,77 @@
|
||||
},
|
||||
"loadError": "بارگذاری آمار ناموفق بود"
|
||||
},
|
||||
"referralNetwork": {
|
||||
"title": "شبکه ارجاع",
|
||||
"subtitle": "نمایش بصری گراف ارجاعات و کمپینهای تبلیغاتی",
|
||||
"search": {
|
||||
"placeholder": "جستجو بر اساس TG ID، @نامکاربری، ایمیل، کمپین...",
|
||||
"noResults": "نتیجهای یافت نشد",
|
||||
"resultsCount": "یافت شد: {{count}}",
|
||||
"clear": "پاک کردن جستجو"
|
||||
},
|
||||
"filters": {
|
||||
"title": "فیلترها",
|
||||
"campaigns": "کمپینها",
|
||||
"partnersOnly": "فقط شرکا",
|
||||
"minReferrals": "حداقل ارجاعات",
|
||||
"reset": "بازنشانی"
|
||||
},
|
||||
"stats": {
|
||||
"totalUsers": "کاربران",
|
||||
"totalReferrers": "ارجاعدهندگان",
|
||||
"totalCampaigns": "کمپینها",
|
||||
"totalEarnings": "درآمد کل"
|
||||
},
|
||||
"legend": {
|
||||
"title": "راهنما",
|
||||
"regularUser": "کاربر عادی",
|
||||
"activeReferrer": "ارجاعدهنده فعال",
|
||||
"partner": "شریک",
|
||||
"topReferrer": "ارجاعدهنده برتر (+۱۰)",
|
||||
"campaignUser": "از کمپین",
|
||||
"campaignNode": "کمپین تبلیغاتی"
|
||||
},
|
||||
"user": {
|
||||
"tgId": "شناسه TG",
|
||||
"email": "ایمیل",
|
||||
"partner": "شریک",
|
||||
"subscription": "اشتراک",
|
||||
"validUntil": "تا {{date}}",
|
||||
"noSubscription": "بدون اشتراک",
|
||||
"personalStats": "آمار شخصی",
|
||||
"totalSpent": "مجموع پرداختی",
|
||||
"referralEarnings": "درآمد ارجاع",
|
||||
"referralBranch": "شاخه ارجاع",
|
||||
"directReferrals": "ارجاعات مستقیم",
|
||||
"branchSize": "کل شاخه",
|
||||
"branchRevenue": "درآمد شاخه",
|
||||
"source": "منبع",
|
||||
"referredBy": "معرفی شده توسط",
|
||||
"fromCampaign": "کمپین",
|
||||
"organic": "طبیعی"
|
||||
},
|
||||
"campaign": {
|
||||
"startParam": "پارامتر",
|
||||
"active": "فعال",
|
||||
"inactive": "غیرفعال",
|
||||
"directUsers": "مستقیم",
|
||||
"totalNetwork": "کل شبکه",
|
||||
"totalRevenue": "درآمد کل",
|
||||
"conversionRate": "نرخ تبدیل",
|
||||
"avgCheck": "میانگین مبلغ",
|
||||
"topReferrers": "ارجاعدهندگان برتر کمپین"
|
||||
},
|
||||
"controls": {
|
||||
"zoomIn": "بزرگنمایی",
|
||||
"zoomOut": "کوچکنمایی",
|
||||
"resetZoom": "بازنشانی زوم",
|
||||
"fullscreen": "تمامصفحه"
|
||||
},
|
||||
"loading": "بارگذاری گراف...",
|
||||
"error": "خطا در بارگذاری دادههای شبکه",
|
||||
"empty": "دادهای برای نمایش وجود ندارد"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "مصرف ترافیک",
|
||||
"subtitle": "آمار ترافیک کاربران بر اساس نودها",
|
||||
|
||||
@@ -1127,7 +1127,8 @@
|
||||
"policies": "Политики доступа",
|
||||
"auditLog": "Журнал аудита",
|
||||
"salesStats": "Статистика продаж",
|
||||
"landings": "Лендинги"
|
||||
"landings": "Лендинги",
|
||||
"referralNetwork": "Реферальная сеть"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -1161,7 +1162,8 @@
|
||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
||||
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
||||
"salesStatsDesc": "Аналитика продаж и тренды",
|
||||
"landingsDesc": "Страницы быстрой покупки"
|
||||
"landingsDesc": "Страницы быстрой покупки",
|
||||
"referralNetworkDesc": "Визуализация графа рефералов и РК"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "Статистика продаж",
|
||||
@@ -1243,6 +1245,77 @@
|
||||
},
|
||||
"loadError": "Не удалось загрузить статистику"
|
||||
},
|
||||
"referralNetwork": {
|
||||
"title": "Реферальная сеть",
|
||||
"subtitle": "Визуализация графа рефералов и рекламных кампаний",
|
||||
"search": {
|
||||
"placeholder": "Поиск по TG ID, @username, email, кампании...",
|
||||
"noResults": "Ничего не найдено",
|
||||
"resultsCount": "Найдено: {{count}}",
|
||||
"clear": "Очистить поиск"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фильтры",
|
||||
"campaigns": "Кампании",
|
||||
"partnersOnly": "Только партнёры",
|
||||
"minReferrals": "Мин. рефералов",
|
||||
"reset": "Сбросить"
|
||||
},
|
||||
"stats": {
|
||||
"totalUsers": "Пользователей",
|
||||
"totalReferrers": "Рефереров",
|
||||
"totalCampaigns": "Кампаний",
|
||||
"totalEarnings": "Общий доход"
|
||||
},
|
||||
"legend": {
|
||||
"title": "Легенда",
|
||||
"regularUser": "Обычный",
|
||||
"activeReferrer": "Активный реферер",
|
||||
"partner": "Партнёр",
|
||||
"topReferrer": "Топ-реферер (10+)",
|
||||
"campaignUser": "Из кампании",
|
||||
"campaignNode": "Рекламная кампания"
|
||||
},
|
||||
"user": {
|
||||
"tgId": "TG ID",
|
||||
"email": "Email",
|
||||
"partner": "Партнёр",
|
||||
"subscription": "Подписка",
|
||||
"validUntil": "до {{date}}",
|
||||
"noSubscription": "Нет подписки",
|
||||
"personalStats": "Личная статистика",
|
||||
"totalSpent": "Оплачено всего",
|
||||
"referralEarnings": "Реф. доход",
|
||||
"referralBranch": "Реферальная ветка",
|
||||
"directReferrals": "Привёл людей",
|
||||
"branchSize": "Всего в ветке",
|
||||
"branchRevenue": "Доход ветки",
|
||||
"source": "Источник",
|
||||
"referredBy": "Пришёл от",
|
||||
"fromCampaign": "РК",
|
||||
"organic": "Органический"
|
||||
},
|
||||
"campaign": {
|
||||
"startParam": "Параметр",
|
||||
"active": "Активна",
|
||||
"inactive": "Неактивна",
|
||||
"directUsers": "Напрямую",
|
||||
"totalNetwork": "Вся сеть",
|
||||
"totalRevenue": "Общий доход",
|
||||
"conversionRate": "Конверсия",
|
||||
"avgCheck": "Средний чек",
|
||||
"topReferrers": "Топ-рефереры из кампании"
|
||||
},
|
||||
"controls": {
|
||||
"zoomIn": "Приблизить",
|
||||
"zoomOut": "Отдалить",
|
||||
"resetZoom": "Сбросить зум",
|
||||
"fullscreen": "Полный экран"
|
||||
},
|
||||
"loading": "Загрузка графа...",
|
||||
"error": "Ошибка загрузки данных сети",
|
||||
"empty": "Нет данных для отображения"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Расход трафика",
|
||||
"subtitle": "Статистика трафика по пользователям и нодам",
|
||||
|
||||
@@ -940,7 +940,8 @@
|
||||
"policies": "访问策略",
|
||||
"auditLog": "审计日志",
|
||||
"salesStats": "销售统计",
|
||||
"landings": "落地页"
|
||||
"landings": "落地页",
|
||||
"referralNetwork": "推荐网络"
|
||||
},
|
||||
"panel": {
|
||||
"title": "管理面板",
|
||||
@@ -974,7 +975,8 @@
|
||||
"policiesDesc": "配置ABAC访问控制策略",
|
||||
"auditLogDesc": "查看系统活动和访问历史",
|
||||
"salesStatsDesc": "销售分析与趋势",
|
||||
"landingsDesc": "快速购买落地页"
|
||||
"landingsDesc": "快速购买落地页",
|
||||
"referralNetworkDesc": "推荐图表和广告活动可视化"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "销售统计",
|
||||
@@ -1056,6 +1058,77 @@
|
||||
},
|
||||
"loadError": "加载统计失败"
|
||||
},
|
||||
"referralNetwork": {
|
||||
"title": "推荐网络",
|
||||
"subtitle": "推荐图表和广告活动可视化",
|
||||
"search": {
|
||||
"placeholder": "按TG ID、@用户名、邮箱、活动搜索...",
|
||||
"noResults": "未找到",
|
||||
"resultsCount": "找到: {{count}}",
|
||||
"clear": "清除搜索"
|
||||
},
|
||||
"filters": {
|
||||
"title": "过滤器",
|
||||
"campaigns": "活动",
|
||||
"partnersOnly": "仅合作伙伴",
|
||||
"minReferrals": "最少推荐数",
|
||||
"reset": "重置"
|
||||
},
|
||||
"stats": {
|
||||
"totalUsers": "用户",
|
||||
"totalReferrers": "推荐人",
|
||||
"totalCampaigns": "活动",
|
||||
"totalEarnings": "总收入"
|
||||
},
|
||||
"legend": {
|
||||
"title": "图例",
|
||||
"regularUser": "普通用户",
|
||||
"activeReferrer": "活跃推荐人",
|
||||
"partner": "合作伙伴",
|
||||
"topReferrer": "顶级推荐人 (10+)",
|
||||
"campaignUser": "来自活动",
|
||||
"campaignNode": "广告活动"
|
||||
},
|
||||
"user": {
|
||||
"tgId": "TG ID",
|
||||
"email": "邮箱",
|
||||
"partner": "合作伙伴",
|
||||
"subscription": "订阅",
|
||||
"validUntil": "有效至 {{date}}",
|
||||
"noSubscription": "无订阅",
|
||||
"personalStats": "个人统计",
|
||||
"totalSpent": "总花费",
|
||||
"referralEarnings": "推荐收入",
|
||||
"referralBranch": "推荐分支",
|
||||
"directReferrals": "直接推荐",
|
||||
"branchSize": "分支总人数",
|
||||
"branchRevenue": "分支收入",
|
||||
"source": "来源",
|
||||
"referredBy": "由...推荐",
|
||||
"fromCampaign": "活动",
|
||||
"organic": "自然流量"
|
||||
},
|
||||
"campaign": {
|
||||
"startParam": "参数",
|
||||
"active": "活跃",
|
||||
"inactive": "非活跃",
|
||||
"directUsers": "直接",
|
||||
"totalNetwork": "总网络",
|
||||
"totalRevenue": "总收入",
|
||||
"conversionRate": "转化率",
|
||||
"avgCheck": "平均金额",
|
||||
"topReferrers": "活动顶级推荐人"
|
||||
},
|
||||
"controls": {
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"resetZoom": "重置缩放",
|
||||
"fullscreen": "全屏"
|
||||
},
|
||||
"loading": "加载图表...",
|
||||
"error": "加载网络数据失败",
|
||||
"empty": "无数据显示"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "流量使用",
|
||||
"subtitle": "按节点的用户流量统计",
|
||||
|
||||
@@ -248,6 +248,17 @@ const ArrowsUpDownIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NetworkGraphIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
@@ -569,6 +580,13 @@ export default function AdminPanel() {
|
||||
description: t('admin.panel.withdrawalsDesc'),
|
||||
permission: 'withdrawals:read',
|
||||
},
|
||||
{
|
||||
to: '/admin/referral-network',
|
||||
icon: <NetworkGraphIcon />,
|
||||
title: t('admin.nav.referralNetwork'),
|
||||
description: t('admin.panel.referralNetworkDesc'),
|
||||
permission: 'stats:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
123
src/pages/ReferralNetwork/ReferralNetwork.tsx
Normal file
123
src/pages/ReferralNetwork/ReferralNetwork.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { referralNetworkApi } from '@/api/referralNetwork';
|
||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||
import { AdminBackButton } from '@/components/admin/AdminBackButton';
|
||||
import { NetworkGraph } from './components/NetworkGraph';
|
||||
import { NetworkSearch } from './components/NetworkSearch';
|
||||
import { NetworkFilters } from './components/NetworkFilters';
|
||||
import { UserDetailPanel } from './components/UserDetailPanel';
|
||||
import { CampaignDetailPanel } from './components/CampaignDetailPanel';
|
||||
import { NetworkStats } from './components/NetworkStats';
|
||||
import { NetworkLegend } from './components/NetworkLegend';
|
||||
import { NetworkControls } from './components/NetworkControls';
|
||||
|
||||
export function ReferralNetwork() {
|
||||
const { t } = useTranslation();
|
||||
const selectedNode = useReferralNetworkStore((s) => s.selectedNode);
|
||||
|
||||
const {
|
||||
data: networkData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['referral-network-graph'],
|
||||
queryFn: referralNetworkApi.getNetworkGraph,
|
||||
staleTime: 120_000,
|
||||
});
|
||||
|
||||
const isPanelOpen = selectedNode !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="referral-network-container"
|
||||
className="relative flex h-[calc(100vh-64px)] flex-col overflow-hidden bg-[#0a0a0f]"
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="relative z-20 flex shrink-0 items-center gap-3 border-b border-dark-700/50 bg-dark-900/90 px-4 py-3 backdrop-blur-md">
|
||||
<AdminBackButton />
|
||||
<div className="mr-2 min-w-0">
|
||||
<h1 className="truncate text-sm font-bold text-dark-100 sm:text-lg">
|
||||
{t('admin.referralNetwork.title')}
|
||||
</h1>
|
||||
<p className="hidden text-xs text-dark-500 sm:block">
|
||||
{t('admin.referralNetwork.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<NetworkSearch className="max-w-md flex-1" />
|
||||
{networkData && <NetworkFilters data={networkData} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-1 items-center justify-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" />
|
||||
<p className="text-sm text-dark-400">{t('admin.referralNetwork.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{isError && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-error-400">{t('admin.referralNetwork.error')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-dark-500">{t('admin.referralNetwork.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph */}
|
||||
{networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && (
|
||||
<>
|
||||
<NetworkGraph data={networkData} className="flex-1" />
|
||||
|
||||
{/* Bottom-left: stats overlay */}
|
||||
<div className="absolute bottom-4 left-4 z-10">
|
||||
<NetworkStats data={networkData} />
|
||||
</div>
|
||||
|
||||
{/* Bottom-right: legend */}
|
||||
<div className="absolute bottom-4 right-4 z-10">
|
||||
<NetworkLegend />
|
||||
</div>
|
||||
|
||||
{/* Bottom-center: controls */}
|
||||
<div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
|
||||
<NetworkControls />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Right side panel */}
|
||||
<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] ${
|
||||
isPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{selectedNode?.type === 'user' && (
|
||||
<UserDetailPanel
|
||||
userId={selectedNode.id}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{selectedNode?.type === 'campaign' && (
|
||||
<CampaignDetailPanel
|
||||
campaignId={selectedNode.id}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx
Normal file
164
src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { referralNetworkApi } from '@/api/referralNetwork';
|
||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||
import { formatKopeksToRubles } from '../utils';
|
||||
|
||||
interface CampaignDetailPanelProps {
|
||||
campaignId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
||||
|
||||
const {
|
||||
data: campaign,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['referral-network-campaign', campaignId],
|
||||
queryFn: () => referralNetworkApi.getCampaignDetail(campaignId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700/50 p-4">
|
||||
<h3 className="text-sm font-semibold text-dark-100">{campaign?.name ?? '...'}</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto p-4">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="py-8 text-center text-sm text-error-400">
|
||||
{t('admin.referralNetwork.error')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{campaign && (
|
||||
<div className="space-y-5">
|
||||
{/* Info */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.referralNetwork.campaign.startParam')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-200">{campaign.start_parameter}</span>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.campaign.directUsers')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-100">{campaign.direct_users}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.campaign.totalNetwork')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-100">{campaign.total_network_users}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.campaign.totalRevenue')}
|
||||
</span>
|
||||
<span className="font-mono text-accent-400">
|
||||
{formatKopeksToRubles(campaign.total_revenue_kopeks)} ₽
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.campaign.conversionRate')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-100">
|
||||
{campaign.conversion_rate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.campaign.avgCheck')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-100">
|
||||
{formatKopeksToRubles(campaign.avg_check_kopeks)} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top referrers */}
|
||||
{campaign.top_referrers.length > 0 && (
|
||||
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.referralNetwork.campaign.topReferrers')}
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{campaign.top_referrers.map((referrer, index) => (
|
||||
<div
|
||||
key={referrer.user_id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-dark-700 text-[10px] font-medium text-dark-300">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{referrer.username ? `@${referrer.username}` : `#${referrer.user_id}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-dark-300">{referrer.referral_count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
src/pages/ReferralNetwork/components/NetworkControls.tsx
Normal file
163
src/pages/ReferralNetwork/components/NetworkControls.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getSigmaInstance } from '../sigmaGlobals';
|
||||
|
||||
interface NetworkControlsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NetworkControls({ className }: NetworkControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Sync fullscreen state when user exits via Escape key or browser UI
|
||||
const handleFullscreenChange = useCallback(() => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, [handleFullscreenChange]);
|
||||
|
||||
function handleZoomIn() {
|
||||
const sigma = getSigmaInstance();
|
||||
if (!sigma) return;
|
||||
const camera = sigma.getCamera();
|
||||
camera.animate({ ratio: camera.ratio / 1.5 }, { duration: 200 });
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
const sigma = getSigmaInstance();
|
||||
if (!sigma) return;
|
||||
const camera = sigma.getCamera();
|
||||
camera.animate({ ratio: camera.ratio * 1.5 }, { duration: 200 });
|
||||
}
|
||||
|
||||
function handleResetZoom() {
|
||||
const sigma = getSigmaInstance();
|
||||
if (!sigma) return;
|
||||
const camera = sigma.getCamera();
|
||||
camera.animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 400 });
|
||||
}
|
||||
|
||||
function handleFullscreen() {
|
||||
const container = document.getElementById('referral-network-container');
|
||||
if (!container) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
container
|
||||
.requestFullscreen()
|
||||
.then(() => setIsFullscreen(true))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
document
|
||||
.exitFullscreen()
|
||||
.then(() => setIsFullscreen(false))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: t('admin.referralNetwork.controls.zoomIn'),
|
||||
onClick: handleZoomIn,
|
||||
icon: (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.referralNetwork.controls.zoomOut'),
|
||||
onClick: handleZoomOut,
|
||||
icon: (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.referralNetwork.controls.resetZoom'),
|
||||
onClick: handleResetZoom,
|
||||
icon: (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('admin.referralNetwork.controls.fullscreen'),
|
||||
onClick: handleFullscreen,
|
||||
icon: isFullscreen ? (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-1 rounded-xl border border-dark-700/50 bg-dark-900/80 p-1.5 backdrop-blur-md ${className ?? ''}`}
|
||||
>
|
||||
{buttons.map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={btn.onClick}
|
||||
title={btn.label}
|
||||
aria-label={btn.label}
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
||||
>
|
||||
{btn.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/pages/ReferralNetwork/components/NetworkFilters.tsx
Normal file
140
src/pages/ReferralNetwork/components/NetworkFilters.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||
import type { NetworkGraphData } from '@/types/referralNetwork';
|
||||
|
||||
interface NetworkFiltersProps {
|
||||
data: NetworkGraphData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NetworkFilters({ data, className }: NetworkFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const filters = useReferralNetworkStore((s) => s.filters);
|
||||
const updateFilters = useReferralNetworkStore((s) => s.updateFilters);
|
||||
const resetFilters = useReferralNetworkStore((s) => s.resetFilters);
|
||||
const isOpen = useReferralNetworkStore((s) => s.isFiltersOpen);
|
||||
const setIsOpen = useReferralNetworkStore((s) => s.setIsFiltersOpen);
|
||||
|
||||
function toggleCampaign(campaignId: number) {
|
||||
const current = filters.campaigns;
|
||||
const next = current.includes(campaignId)
|
||||
? current.filter((id) => id !== campaignId)
|
||||
: [...current, campaignId];
|
||||
updateFilters({ campaigns: next });
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
aria-label={t('admin.referralNetwork.filters.title')}
|
||||
className={`flex items-center gap-2 rounded-lg border border-dark-700/50 bg-dark-800/80 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 ${className ?? ''}`}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
{t('admin.referralNetwork.filters.title')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-dark-700/50 bg-dark-900/90 p-4 backdrop-blur-md ${className ?? ''}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-dark-100">
|
||||
{t('admin.referralNetwork.filters.title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-label={t('common.close')}
|
||||
className="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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Campaigns */}
|
||||
{data.campaigns.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-400">
|
||||
{t('admin.referralNetwork.filters.campaigns')}
|
||||
</label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto">
|
||||
{data.campaigns.map((campaign) => (
|
||||
<label
|
||||
key={campaign.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.campaigns.includes(campaign.id)}
|
||||
onChange={() => toggleCampaign(campaign.id)}
|
||||
className="h-3.5 w-3.5 rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500/30"
|
||||
/>
|
||||
<span className="truncate text-dark-200">{campaign.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Partners only */}
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.partnersOnly}
|
||||
onChange={(e) => updateFilters({ partnersOnly: e.target.checked })}
|
||||
className="h-3.5 w-3.5 rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500/30"
|
||||
/>
|
||||
<span className="text-dark-200">{t('admin.referralNetwork.filters.partnersOnly')}</span>
|
||||
</label>
|
||||
|
||||
{/* Min referrals */}
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center justify-between text-xs font-medium text-dark-400">
|
||||
<span>{t('admin.referralNetwork.filters.minReferrals')}</span>
|
||||
<span className="font-mono text-dark-300">{filters.minReferrals}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={filters.minReferrals}
|
||||
onChange={(e) => updateFilters({ minReferrals: Number(e.target.value) })}
|
||||
className="w-full accent-accent-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset */}
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="w-full rounded-lg border border-dark-700/50 py-1.5 text-xs font-medium text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200"
|
||||
>
|
||||
{t('admin.referralNetwork.filters.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
src/pages/ReferralNetwork/components/NetworkGraph.tsx
Normal file
322
src/pages/ReferralNetwork/components/NetworkGraph.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import Graph from 'graphology';
|
||||
import Sigma from 'sigma';
|
||||
import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker';
|
||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||
import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork';
|
||||
import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils';
|
||||
import { setSigmaInstance, setGraphInstance } from '../sigmaGlobals';
|
||||
|
||||
interface NetworkGraphProps {
|
||||
data: NetworkGraphData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full graph from data (no filter logic).
|
||||
* Stores filter-relevant attributes on nodes so reducers can check them.
|
||||
*/
|
||||
function buildFullGraph(graphData: NetworkGraphData): Graph {
|
||||
const graph = new Graph();
|
||||
|
||||
graphData.campaigns.forEach((campaign, index) => {
|
||||
graph.addNode(`campaign_${campaign.id}`, {
|
||||
label: campaign.name,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: 18,
|
||||
color: getCampaignColor(index),
|
||||
type: 'circle',
|
||||
nodeType: 'campaign',
|
||||
nodeId: campaign.id,
|
||||
});
|
||||
});
|
||||
|
||||
graphData.users.forEach((user) => {
|
||||
const color = getUserNodeColor(user.direct_referrals, user.is_partner, user.campaign_id);
|
||||
const size = getUserNodeSize(user.direct_referrals);
|
||||
|
||||
graph.addNode(`user_${user.id}`, {
|
||||
label: user.display_name,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size,
|
||||
color,
|
||||
type: 'circle',
|
||||
nodeType: 'user',
|
||||
nodeId: user.id,
|
||||
isPartner: user.is_partner,
|
||||
directReferrals: user.direct_referrals,
|
||||
campaignId: user.campaign_id,
|
||||
});
|
||||
});
|
||||
|
||||
graphData.edges.forEach((edge) => {
|
||||
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
|
||||
const edgeKey = `${edge.source}->${edge.target}`;
|
||||
if (!graph.hasEdge(edgeKey)) {
|
||||
graph.addEdgeWithKey(edgeKey, edge.source, edge.target, {
|
||||
color: edge.type === 'campaign' ? 'rgba(77, 217, 192, 0.12)' : 'rgba(255,255,255,0.06)',
|
||||
size: 0.5,
|
||||
edgeType: edge.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute set of node keys that should be hidden based on current filters.
|
||||
*/
|
||||
function computeHiddenNodes(graph: Graph, filters: NetworkFilters): Set<string> {
|
||||
const hidden = new Set<string>();
|
||||
const filterCampaignSet = new Set(filters.campaigns);
|
||||
const hasCampaignFilter = filterCampaignSet.size > 0;
|
||||
|
||||
graph.forEachNode((node, attrs) => {
|
||||
if (attrs.nodeType === 'user') {
|
||||
if (filters.partnersOnly && !attrs.isPartner) {
|
||||
hidden.add(node);
|
||||
} else if ((attrs.directReferrals ?? 0) < filters.minReferrals) {
|
||||
hidden.add(node);
|
||||
} else if (
|
||||
hasCampaignFilter &&
|
||||
attrs.campaignId !== null &&
|
||||
!filterCampaignSet.has(attrs.campaignId)
|
||||
) {
|
||||
hidden.add(node);
|
||||
}
|
||||
} else if (attrs.nodeType === 'campaign') {
|
||||
if (hasCampaignFilter && !filterCampaignSet.has(attrs.nodeId)) {
|
||||
hidden.add(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return hidden;
|
||||
}
|
||||
|
||||
const FA2_DURATION_MS = 5000;
|
||||
|
||||
export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sigmaRef = useRef<Sigma | null>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const hiddenNodesRef = useRef<Set<string>>(new Set());
|
||||
const fa2Ref = useRef<FA2LayoutSupervisor | null>(null);
|
||||
const fa2TimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
||||
const setHoveredNode = useReferralNetworkStore((s) => s.setHoveredNode);
|
||||
const hoveredNodeId = useReferralNetworkStore((s) => s.hoveredNodeId);
|
||||
const highlightedNodes = useReferralNetworkStore((s) => s.highlightedNodes);
|
||||
const filters = useReferralNetworkStore((s) => s.filters);
|
||||
const setNetworkData = useReferralNetworkStore((s) => s.setNetworkData);
|
||||
|
||||
const killFA2 = useCallback(() => {
|
||||
if (fa2TimerRef.current !== null) {
|
||||
clearTimeout(fa2TimerRef.current);
|
||||
fa2TimerRef.current = null;
|
||||
}
|
||||
if (fa2Ref.current) {
|
||||
fa2Ref.current.kill();
|
||||
fa2Ref.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize sigma — only re-runs when data changes (NOT on filter changes)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !data) return;
|
||||
|
||||
// Cleanup previous instance
|
||||
killFA2();
|
||||
if (sigmaRef.current) {
|
||||
sigmaRef.current.kill();
|
||||
sigmaRef.current = null;
|
||||
}
|
||||
|
||||
const graph = buildFullGraph(data);
|
||||
graphRef.current = graph;
|
||||
|
||||
setNetworkData(data);
|
||||
|
||||
// Compute initial hidden set from current filters
|
||||
const initialFilters = useReferralNetworkStore.getState().filters;
|
||||
hiddenNodesRef.current = computeHiddenNodes(graph, initialFilters);
|
||||
|
||||
const sigma = new Sigma(graph, containerRef.current, {
|
||||
renderEdgeLabels: false,
|
||||
labelDensity: 0.5,
|
||||
labelRenderedSizeThreshold: 6,
|
||||
zIndex: true,
|
||||
defaultEdgeColor: '#ffffff10',
|
||||
defaultNodeColor: '#6b7280',
|
||||
labelColor: { color: '#e8e6f0' },
|
||||
labelFont: 'Inter, system-ui, sans-serif',
|
||||
labelSize: 12,
|
||||
stagePadding: 40,
|
||||
nodeReducer: (node, attrs) => {
|
||||
const res = { ...attrs };
|
||||
|
||||
// Filter visibility (read from pre-computed set)
|
||||
if (hiddenNodesRef.current.has(node)) {
|
||||
res.hidden = true;
|
||||
return res;
|
||||
}
|
||||
|
||||
const store = useReferralNetworkStore.getState();
|
||||
const hovered = store.hoveredNodeId;
|
||||
const highlighted = store.highlightedNodes;
|
||||
|
||||
// Search highlighting
|
||||
if (highlighted.size > 0) {
|
||||
if (highlighted.has(node)) {
|
||||
res.highlighted = true;
|
||||
res.zIndex = 2;
|
||||
} else {
|
||||
res.color = `${attrs.color}33`;
|
||||
res.label = '';
|
||||
res.zIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Hover highlighting
|
||||
if (hovered) {
|
||||
if (node === hovered) {
|
||||
res.highlighted = true;
|
||||
res.zIndex = 2;
|
||||
} else if (graph.hasNode(hovered) && graph.areNeighbors(node, hovered)) {
|
||||
res.highlighted = true;
|
||||
res.zIndex = 1;
|
||||
} else if (!highlighted.has(node)) {
|
||||
res.color = `${attrs.color}33`;
|
||||
res.label = '';
|
||||
res.zIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
edgeReducer: (edge, attrs) => {
|
||||
const res = { ...attrs };
|
||||
|
||||
// Hide edges connected to filtered-out nodes
|
||||
const [source, target] = graph.extremities(edge);
|
||||
if (hiddenNodesRef.current.has(source) || hiddenNodesRef.current.has(target)) {
|
||||
res.hidden = true;
|
||||
return res;
|
||||
}
|
||||
|
||||
const store = useReferralNetworkStore.getState();
|
||||
const hovered = store.hoveredNodeId;
|
||||
const highlighted = store.highlightedNodes;
|
||||
|
||||
if (hovered && graph.hasNode(hovered)) {
|
||||
if (!graph.hasExtremity(edge, hovered)) {
|
||||
res.hidden = true;
|
||||
} else {
|
||||
res.color = '#ffffff30';
|
||||
res.size = 1;
|
||||
}
|
||||
} else if (highlighted.size > 0) {
|
||||
if (!highlighted.has(source) && !highlighted.has(target)) {
|
||||
res.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
sigmaRef.current = sigma;
|
||||
|
||||
// Expose to module-level globals for search/controls
|
||||
setSigmaInstance(sigma);
|
||||
setGraphInstance(graph);
|
||||
|
||||
// Start ForceAtlas2 in a web worker (non-blocking)
|
||||
if (graph.order > 0) {
|
||||
const supervisor = new FA2LayoutSupervisor(graph, {
|
||||
settings: {
|
||||
gravity: 1,
|
||||
scalingRatio: 2,
|
||||
barnesHutOptimize: true,
|
||||
},
|
||||
});
|
||||
fa2Ref.current = supervisor;
|
||||
supervisor.start();
|
||||
|
||||
// Kill after a fixed duration to free the web worker thread
|
||||
fa2TimerRef.current = setTimeout(() => {
|
||||
if (fa2Ref.current === supervisor) {
|
||||
supervisor.kill();
|
||||
fa2Ref.current = null;
|
||||
}
|
||||
fa2TimerRef.current = null;
|
||||
}, FA2_DURATION_MS);
|
||||
}
|
||||
|
||||
// Click handler
|
||||
sigma.on('clickNode', ({ node }) => {
|
||||
const attrs = graph.getNodeAttributes(node);
|
||||
const nodeType = attrs.nodeType;
|
||||
const nodeId = attrs.nodeId;
|
||||
|
||||
if (typeof nodeId !== 'number') return;
|
||||
|
||||
if (nodeType === 'user') {
|
||||
setSelectedNode({ type: 'user', id: nodeId });
|
||||
} else if (nodeType === 'campaign') {
|
||||
setSelectedNode({ type: 'campaign', id: nodeId });
|
||||
}
|
||||
});
|
||||
|
||||
// Click on stage deselects
|
||||
sigma.on('clickStage', () => {
|
||||
setSelectedNode(null);
|
||||
});
|
||||
|
||||
// Hover handler
|
||||
sigma.on('enterNode', ({ node }) => {
|
||||
setHoveredNode(node);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = 'pointer';
|
||||
}
|
||||
});
|
||||
|
||||
sigma.on('leaveNode', () => {
|
||||
setHoveredNode(null);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = 'default';
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
killFA2();
|
||||
sigma.kill();
|
||||
sigmaRef.current = null;
|
||||
graphRef.current = null;
|
||||
setSigmaInstance(null);
|
||||
setGraphInstance(null);
|
||||
};
|
||||
}, [data, setSelectedNode, setHoveredNode, setNetworkData, killFA2]);
|
||||
|
||||
// Recompute hidden nodes when filters change, then refresh sigma
|
||||
// (no graph rebuild — positions and Sigma instance preserved)
|
||||
useEffect(() => {
|
||||
if (!graphRef.current || !sigmaRef.current) return;
|
||||
hiddenNodesRef.current = computeHiddenNodes(graphRef.current, filters);
|
||||
sigmaRef.current.refresh();
|
||||
}, [filters]);
|
||||
|
||||
// Refresh sigma on hover/highlight changes
|
||||
useEffect(() => {
|
||||
if (sigmaRef.current) {
|
||||
sigmaRef.current.refresh();
|
||||
}
|
||||
}, [hoveredNodeId, highlightedNodes]);
|
||||
|
||||
return <div ref={containerRef} className={`bg-[#0a0a0f] ${className ?? ''}`} />;
|
||||
}
|
||||
51
src/pages/ReferralNetwork/components/NetworkLegend.tsx
Normal file
51
src/pages/ReferralNetwork/components/NetworkLegend.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface NetworkLegendProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CAMPAIGN_GRADIENT_COLORS = ['#4dd9c0', '#f0c261', '#e85d9a', '#6b9fff', '#b97aff'];
|
||||
|
||||
const USER_LEGEND_ITEMS = [
|
||||
{ colorKey: '#6b7280', labelKey: 'admin.referralNetwork.legend.regularUser' },
|
||||
{ colorKey: '#7c6aef', labelKey: 'admin.referralNetwork.legend.activeReferrer' },
|
||||
{ colorKey: '#f0c261', labelKey: 'admin.referralNetwork.legend.partner' },
|
||||
{ colorKey: '#e85d9a', labelKey: 'admin.referralNetwork.legend.topReferrer' },
|
||||
{ colorKey: '#4dd9c0', labelKey: 'admin.referralNetwork.legend.campaignUser' },
|
||||
];
|
||||
|
||||
export function NetworkLegend({ className }: NetworkLegendProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const gradientStyle = {
|
||||
background: `linear-gradient(135deg, ${CAMPAIGN_GRADIENT_COLORS.join(', ')})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-3 backdrop-blur-md ${className ?? ''}`}
|
||||
>
|
||||
<h4 className="mb-2 text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.referralNetwork.legend.title')}
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{USER_LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.labelKey} className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: item.colorKey }}
|
||||
/>
|
||||
<span className="text-xs text-dark-300">{t(item.labelKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Campaign node with gradient to represent varying colors */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded-full" style={gradientStyle} />
|
||||
<span className="text-xs text-dark-300">
|
||||
{t('admin.referralNetwork.legend.campaignNode')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
src/pages/ReferralNetwork/components/NetworkSearch.tsx
Normal file
233
src/pages/ReferralNetwork/components/NetworkSearch.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
48
src/pages/ReferralNetwork/components/NetworkStats.tsx
Normal file
48
src/pages/ReferralNetwork/components/NetworkStats.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NetworkGraphData } from '@/types/referralNetwork';
|
||||
import { formatKopeksToRubles } from '../utils';
|
||||
|
||||
interface NetworkStatsProps {
|
||||
data: NetworkGraphData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NetworkStats({ data, className }: NetworkStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: t('admin.referralNetwork.stats.totalUsers'),
|
||||
value: data.total_users.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: t('admin.referralNetwork.stats.totalReferrers'),
|
||||
value: data.total_referrers.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: t('admin.referralNetwork.stats.totalCampaigns'),
|
||||
value: data.total_campaigns.toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: t('admin.referralNetwork.stats.totalEarnings'),
|
||||
value: `${formatKopeksToRubles(data.total_earnings_kopeks)} ₽`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-3 backdrop-blur-md ${className ?? ''}`}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="font-mono text-sm font-semibold text-dark-100">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
src/pages/ReferralNetwork/components/UserDetailPanel.tsx
Normal file
203
src/pages/ReferralNetwork/components/UserDetailPanel.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { referralNetworkApi } from '@/api/referralNetwork';
|
||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||
import { formatKopeksToRubles } from '../utils';
|
||||
|
||||
interface UserDetailPanelProps {
|
||||
userId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
||||
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['referral-network-user', userId],
|
||||
queryFn: () => referralNetworkApi.getUserDetail(userId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700/50 p-4">
|
||||
<h3 className="text-sm font-semibold text-dark-100">{user?.display_name ?? '...'}</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto p-4">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="py-8 text-center text-sm text-error-400">
|
||||
{t('admin.referralNetwork.error')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<div className="space-y-5">
|
||||
{/* Identity */}
|
||||
<div className="space-y-2">
|
||||
{user.username && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-500">@</span>
|
||||
<span className="font-mono text-dark-200">{user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
{user.tg_id && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-500">{t('admin.referralNetwork.user.tgId')}</span>
|
||||
<span className="font-mono text-dark-200">{user.tg_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{user.email && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-500">{t('admin.referralNetwork.user.email')}</span>
|
||||
<span className="truncate pl-4 font-mono text-dark-200">{user.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{user.is_partner && (
|
||||
<div className="flex justify-end">
|
||||
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-400">
|
||||
{t('admin.referralNetwork.user.partner')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.referralNetwork.user.subscription')}
|
||||
</h4>
|
||||
{user.subscription_name ? (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-dark-100">{user.subscription_name}</p>
|
||||
{user.subscription_end && (
|
||||
<p className="mt-0.5 text-xs text-dark-400">
|
||||
{t('admin.referralNetwork.user.validUntil', {
|
||||
date: new Date(user.subscription_end).toLocaleDateString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-dark-500">
|
||||
{t('admin.referralNetwork.user.noSubscription')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Personal stats */}
|
||||
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.referralNetwork.user.personalStats')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.user.totalSpent')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-100">
|
||||
{formatKopeksToRubles(user.personal_spent_kopeks)} ₽
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.user.referralEarnings')}
|
||||
</span>
|
||||
<span className="font-mono text-accent-400">
|
||||
{formatKopeksToRubles(user.personal_revenue_kopeks)} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral branch */}
|
||||
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.referralNetwork.user.referralBranch')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.user.directReferrals')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-100">{user.direct_referrals}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.user.branchSize')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-100">{user.total_branch_users}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.user.branchRevenue')}
|
||||
</span>
|
||||
<span className="font-mono text-dark-100">
|
||||
{formatKopeksToRubles(user.branch_revenue_kopeks)} ₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.referralNetwork.user.source')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.user.referredBy')}
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{user.referrer_display_name ?? t('admin.referralNetwork.user.organic')}
|
||||
</span>
|
||||
</div>
|
||||
{user.campaign_name && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.referralNetwork.user.fromCampaign')}
|
||||
</span>
|
||||
<span className="text-dark-200">{user.campaign_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/pages/ReferralNetwork/index.tsx
Normal file
3
src/pages/ReferralNetwork/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ReferralNetwork } from './ReferralNetwork';
|
||||
|
||||
export default ReferralNetwork;
|
||||
25
src/pages/ReferralNetwork/sigmaGlobals.ts
Normal file
25
src/pages/ReferralNetwork/sigmaGlobals.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type Sigma from 'sigma';
|
||||
import type Graph from 'graphology';
|
||||
|
||||
/**
|
||||
* We store sigma and graph instances on a module-level variable
|
||||
* so that search and controls can access them without prop drilling.
|
||||
*/
|
||||
let sigmaInstance: Sigma | null = null;
|
||||
let graphInstance: Graph | null = null;
|
||||
|
||||
export function setSigmaInstance(sigma: Sigma | null): void {
|
||||
sigmaInstance = sigma;
|
||||
}
|
||||
|
||||
export function getSigmaInstance(): Sigma | null {
|
||||
return sigmaInstance;
|
||||
}
|
||||
|
||||
export function setGraphInstance(graph: Graph | null): void {
|
||||
graphInstance = graph;
|
||||
}
|
||||
|
||||
export function getGraphInstance(): Graph | null {
|
||||
return graphInstance;
|
||||
}
|
||||
53
src/pages/ReferralNetwork/utils.ts
Normal file
53
src/pages/ReferralNetwork/utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Format kopeks to a human-readable ruble string.
|
||||
*/
|
||||
export function formatKopeksToRubles(kopeks: number): string {
|
||||
return `${(kopeks / 100).toLocaleString('ru-RU')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Campaign node color palette. Each campaign gets a distinct color
|
||||
* based on its index position.
|
||||
*/
|
||||
const CAMPAIGN_COLORS = [
|
||||
'#4dd9c0',
|
||||
'#f0c261',
|
||||
'#e85d9a',
|
||||
'#6b9fff',
|
||||
'#b97aff',
|
||||
'#ff8a65',
|
||||
'#66d9a0',
|
||||
'#ff6b9d',
|
||||
'#7ec8e3',
|
||||
'#c4b5fd',
|
||||
];
|
||||
|
||||
export function getCampaignColor(index: number): string {
|
||||
return CAMPAIGN_COLORS[index % CAMPAIGN_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the visual color for a user node.
|
||||
*/
|
||||
export function getUserNodeColor(
|
||||
directReferrals: number,
|
||||
isPartner: boolean,
|
||||
campaignId: number | null,
|
||||
): string {
|
||||
if (isPartner) return '#f0c261';
|
||||
if (directReferrals >= 10) return '#e85d9a';
|
||||
if (directReferrals >= 1) return '#7c6aef';
|
||||
if (campaignId !== null) return '#4dd9c0';
|
||||
return '#6b7280';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the visual size for a user node.
|
||||
* Size is proportional to direct_referrals, clamped between min and max.
|
||||
*/
|
||||
export function getUserNodeSize(directReferrals: number): number {
|
||||
const MIN_SIZE = 4;
|
||||
const MAX_SIZE = 30;
|
||||
if (directReferrals === 0) return MIN_SIZE;
|
||||
return Math.min(MAX_SIZE, MIN_SIZE + Math.sqrt(directReferrals) * 4);
|
||||
}
|
||||
70
src/store/referralNetwork.ts
Normal file
70
src/store/referralNetwork.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
NetworkGraphData,
|
||||
SelectedNode,
|
||||
NetworkFilters,
|
||||
NetworkUserNode,
|
||||
NetworkCampaignNode,
|
||||
} from '@/types/referralNetwork';
|
||||
|
||||
const DEFAULT_FILTERS: NetworkFilters = {
|
||||
campaigns: [],
|
||||
partnersOnly: false,
|
||||
minReferrals: 0,
|
||||
};
|
||||
|
||||
interface ReferralNetworkState {
|
||||
networkData: NetworkGraphData | null;
|
||||
selectedNode: SelectedNode;
|
||||
hoveredNodeId: string | null;
|
||||
highlightedNodes: Set<string>;
|
||||
searchQuery: string;
|
||||
searchResults: { users: NetworkUserNode[]; campaigns: NetworkCampaignNode[] };
|
||||
filters: NetworkFilters;
|
||||
isFiltersOpen: boolean;
|
||||
|
||||
setNetworkData: (data: NetworkGraphData) => void;
|
||||
setSelectedNode: (node: SelectedNode) => void;
|
||||
setHoveredNode: (id: string | null) => void;
|
||||
setHighlightedNodes: (nodes: Set<string>) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSearchResults: (results: {
|
||||
users: NetworkUserNode[];
|
||||
campaigns: NetworkCampaignNode[];
|
||||
}) => void;
|
||||
updateFilters: (filters: Partial<NetworkFilters>) => void;
|
||||
resetFilters: () => void;
|
||||
setIsFiltersOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => ({
|
||||
networkData: null,
|
||||
selectedNode: null,
|
||||
hoveredNodeId: null,
|
||||
highlightedNodes: new Set<string>(),
|
||||
searchQuery: '',
|
||||
searchResults: { users: [], campaigns: [] },
|
||||
filters: { ...DEFAULT_FILTERS },
|
||||
isFiltersOpen: false,
|
||||
|
||||
setNetworkData: (data) => set({ networkData: data }),
|
||||
|
||||
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||
|
||||
setHoveredNode: (id) => set({ hoveredNodeId: id }),
|
||||
|
||||
setHighlightedNodes: (nodes) => set({ highlightedNodes: nodes }),
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
setSearchResults: (results) => set({ searchResults: results }),
|
||||
|
||||
updateFilters: (partial) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, ...partial },
|
||||
})),
|
||||
|
||||
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
|
||||
|
||||
setIsFiltersOpen: (open) => set({ isFiltersOpen: open }),
|
||||
}));
|
||||
102
src/types/referralNetwork.ts
Normal file
102
src/types/referralNetwork.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export interface NetworkUserNode {
|
||||
id: number;
|
||||
tg_id: number | null;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
display_name: string;
|
||||
is_partner: boolean;
|
||||
referrer_id: number | null;
|
||||
campaign_id: number | null;
|
||||
direct_referrals: number;
|
||||
total_branch_users: number;
|
||||
branch_revenue_kopeks: number;
|
||||
personal_revenue_kopeks: number;
|
||||
personal_spent_kopeks: number;
|
||||
subscription_name: string | null;
|
||||
subscription_end: string | null;
|
||||
registered_at: string | null;
|
||||
}
|
||||
|
||||
export interface NetworkCampaignNode {
|
||||
id: number;
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
is_active: boolean;
|
||||
direct_users: number;
|
||||
total_network_users: number;
|
||||
total_revenue_kopeks: number;
|
||||
conversion_rate: number;
|
||||
avg_check_kopeks: number;
|
||||
top_referrers: Array<{
|
||||
user_id: number;
|
||||
username: string | null;
|
||||
referral_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NetworkEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'referral' | 'campaign';
|
||||
}
|
||||
|
||||
export interface NetworkGraphData {
|
||||
users: NetworkUserNode[];
|
||||
campaigns: NetworkCampaignNode[];
|
||||
edges: NetworkEdge[];
|
||||
total_users: number;
|
||||
total_referrers: number;
|
||||
total_campaigns: number;
|
||||
total_earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface NetworkUserDetail {
|
||||
id: number;
|
||||
tg_id: number | null;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
display_name: string;
|
||||
is_partner: boolean;
|
||||
referrer_id: number | null;
|
||||
referrer_display_name: string | null;
|
||||
campaign_id: number | null;
|
||||
campaign_name: string | null;
|
||||
direct_referrals: number;
|
||||
total_branch_users: number;
|
||||
branch_revenue_kopeks: number;
|
||||
personal_revenue_kopeks: number;
|
||||
personal_spent_kopeks: number;
|
||||
subscription_name: string | null;
|
||||
subscription_end: string | null;
|
||||
registered_at: string | null;
|
||||
}
|
||||
|
||||
export interface NetworkCampaignDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
is_active: boolean;
|
||||
direct_users: number;
|
||||
total_network_users: number;
|
||||
total_revenue_kopeks: number;
|
||||
conversion_rate: number;
|
||||
avg_check_kopeks: number;
|
||||
top_referrers: Array<{
|
||||
user_id: number;
|
||||
username: string | null;
|
||||
referral_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NetworkSearchResult {
|
||||
users: NetworkUserNode[];
|
||||
campaigns: NetworkCampaignNode[];
|
||||
}
|
||||
|
||||
export type SelectedNode = { type: 'user'; id: number } | { type: 'campaign'; id: number } | null;
|
||||
|
||||
export interface NetworkFilters {
|
||||
campaigns: number[];
|
||||
partnersOnly: boolean;
|
||||
minReferrals: number;
|
||||
}
|
||||
Reference in New Issue
Block a user