From 235eaec85f46e46b4b588f325811a448a02dbcc3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 07:56:01 +0300 Subject: [PATCH] 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. --- package-lock.json | 62 ++++ package.json | 4 + src/App.tsx | 11 + src/api/referralNetwork.ts | 31 ++ src/locales/en.json | 77 ++++- src/locales/fa.json | 77 ++++- src/locales/ru.json | 77 ++++- src/locales/zh.json | 77 ++++- src/pages/AdminPanel.tsx | 18 + src/pages/ReferralNetwork/ReferralNetwork.tsx | 123 +++++++ .../components/CampaignDetailPanel.tsx | 164 +++++++++ .../components/NetworkControls.tsx | 163 +++++++++ .../components/NetworkFilters.tsx | 140 ++++++++ .../components/NetworkGraph.tsx | 322 ++++++++++++++++++ .../components/NetworkLegend.tsx | 51 +++ .../components/NetworkSearch.tsx | 233 +++++++++++++ .../components/NetworkStats.tsx | 48 +++ .../components/UserDetailPanel.tsx | 203 +++++++++++ src/pages/ReferralNetwork/index.tsx | 3 + src/pages/ReferralNetwork/sigmaGlobals.ts | 25 ++ src/pages/ReferralNetwork/utils.ts | 53 +++ src/store/referralNetwork.ts | 70 ++++ src/types/referralNetwork.ts | 102 ++++++ 23 files changed, 2126 insertions(+), 8 deletions(-) create mode 100644 src/api/referralNetwork.ts create mode 100644 src/pages/ReferralNetwork/ReferralNetwork.tsx create mode 100644 src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx create mode 100644 src/pages/ReferralNetwork/components/NetworkControls.tsx create mode 100644 src/pages/ReferralNetwork/components/NetworkFilters.tsx create mode 100644 src/pages/ReferralNetwork/components/NetworkGraph.tsx create mode 100644 src/pages/ReferralNetwork/components/NetworkLegend.tsx create mode 100644 src/pages/ReferralNetwork/components/NetworkSearch.tsx create mode 100644 src/pages/ReferralNetwork/components/NetworkStats.tsx create mode 100644 src/pages/ReferralNetwork/components/UserDetailPanel.tsx create mode 100644 src/pages/ReferralNetwork/index.tsx create mode 100644 src/pages/ReferralNetwork/sigmaGlobals.ts create mode 100644 src/pages/ReferralNetwork/utils.ts create mode 100644 src/store/referralNetwork.ts create mode 100644 src/types/referralNetwork.ts diff --git a/package-lock.json b/package-lock.json index f994451..252b8ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,9 @@ "cmdk": "^1.1.1", "dompurify": "^3.3.1", "framer-motion": "^12.29.2", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-types": "^0.24.8", "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", @@ -43,6 +46,7 @@ "react-router": "^7.13.0", "react-twemoji": "^0.7.2", "recharts": "^3.7.0", + "sigma": "^3.0.2", "simplex-noise": "^4.0.3", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" @@ -4445,6 +4449,15 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4806,6 +4819,45 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-layout-forceatlas2": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz", + "integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.1.0" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT" + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6427,6 +6479,16 @@ "node": ">=8" } }, + "node_modules/sigma": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz", + "integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "graphology-utils": "^2.5.2" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", diff --git a/package.json b/package.json index 3cc4c24..16cb7d0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "cmdk": "^1.1.1", "dompurify": "^3.3.1", "framer-motion": "^12.29.2", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-types": "^0.24.8", "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", @@ -51,6 +54,7 @@ "react-router": "^7.13.0", "react-twemoji": "^0.7.2", "recharts": "^3.7.0", + "sigma": "^3.0.2", "simplex-noise": "^4.0.3", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" diff --git a/src/App.tsx b/src/App.tsx index 295b137..80df1fb 100644 --- a/src/App.tsx +++ b/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() { } /> + + + + + + } + /> => { + const response = await apiClient.get('/cabinet/admin/referral-network/'); + return response.data; + }, + + getUserDetail: async (userId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/referral-network/user/${userId}`); + return response.data; + }, + + getCampaignDetail: async (campaignId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/referral-network/campaign/${campaignId}`); + return response.data; + }, + + search: async (query: string): Promise => { + const response = await apiClient.get('/cabinet/admin/referral-network/search', { + params: { q: query }, + }); + return response.data; + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index 3c2fcc8..33690fa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 5ab243d..377b65b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "آمار ترافیک کاربران بر اساس نودها", diff --git a/src/locales/ru.json b/src/locales/ru.json index 77a3a6b..e43d71e 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Статистика трафика по пользователям и нодам", diff --git a/src/locales/zh.json b/src/locales/zh.json index 05003f7..60545e9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "按节点的用户流量统计", diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index c95f1bd..f3fc46f 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -248,6 +248,17 @@ const ArrowsUpDownIcon = () => ( ); +const NetworkGraphIcon = () => ( + + + + +); + const ChevronRightIcon = () => ( @@ -569,6 +580,13 @@ export default function AdminPanel() { description: t('admin.panel.withdrawalsDesc'), permission: 'withdrawals:read', }, + { + to: '/admin/referral-network', + icon: , + title: t('admin.nav.referralNetwork'), + description: t('admin.panel.referralNetworkDesc'), + permission: 'stats:read', + }, ], }, { diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx new file mode 100644 index 0000000..4356e38 --- /dev/null +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -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 ( +
+ {/* Top bar */} +
+ +
+

+ {t('admin.referralNetwork.title')} +

+

+ {t('admin.referralNetwork.subtitle')} +

+
+
+ + {networkData && } +
+
+ + {/* Main content area */} +
+ {/* Loading state */} + {isLoading && ( +
+
+
+

{t('admin.referralNetwork.loading')}

+
+
+ )} + + {/* Error state */} + {isError && ( +
+

{t('admin.referralNetwork.error')}

+
+ )} + + {/* Empty state */} + {networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && ( +
+

{t('admin.referralNetwork.empty')}

+
+ )} + + {/* Graph */} + {networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( + <> + + + {/* Bottom-left: stats overlay */} +
+ +
+ + {/* Bottom-right: legend */} +
+ +
+ + {/* Bottom-center: controls */} +
+ +
+ + )} + + {/* Right side panel */} +
+ {selectedNode?.type === 'user' && ( + + )} + {selectedNode?.type === 'campaign' && ( + + )} +
+
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx b/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx new file mode 100644 index 0000000..6ac5327 --- /dev/null +++ b/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx @@ -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 ( +
+ {/* Header */} +
+

{campaign?.name ?? '...'}

+ +
+ + {/* Content */} +
+ {isLoading && ( +
+
+
+ )} + + {isError && ( +
+ {t('admin.referralNetwork.error')} +
+ )} + + {campaign && ( +
+ {/* Info */} +
+
+ + {t('admin.referralNetwork.campaign.startParam')} + + {campaign.start_parameter} +
+
+ + {campaign.is_active + ? t('admin.referralNetwork.campaign.active') + : t('admin.referralNetwork.campaign.inactive')} + +
+
+ + {/* Stats */} +
+
+
+ + {t('admin.referralNetwork.campaign.directUsers')} + + {campaign.direct_users} +
+
+ + {t('admin.referralNetwork.campaign.totalNetwork')} + + {campaign.total_network_users} +
+
+ + {t('admin.referralNetwork.campaign.totalRevenue')} + + + {formatKopeksToRubles(campaign.total_revenue_kopeks)} ₽ + +
+
+ + {t('admin.referralNetwork.campaign.conversionRate')} + + + {campaign.conversion_rate.toFixed(1)}% + +
+
+ + {t('admin.referralNetwork.campaign.avgCheck')} + + + {formatKopeksToRubles(campaign.avg_check_kopeks)} ₽ + +
+
+
+ + {/* Top referrers */} + {campaign.top_referrers.length > 0 && ( +
+

+ {t('admin.referralNetwork.campaign.topReferrers')} +

+
+ {campaign.top_referrers.map((referrer, index) => ( +
+
+ + {index + 1} + + + {referrer.username ? `@${referrer.username}` : `#${referrer.user_id}`} + +
+ {referrer.referral_count} +
+ ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkControls.tsx b/src/pages/ReferralNetwork/components/NetworkControls.tsx new file mode 100644 index 0000000..fbd4478 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkControls.tsx @@ -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: ( + + + + ), + }, + { + label: t('admin.referralNetwork.controls.zoomOut'), + onClick: handleZoomOut, + icon: ( + + + + ), + }, + { + label: t('admin.referralNetwork.controls.resetZoom'), + onClick: handleResetZoom, + icon: ( + + + + ), + }, + { + label: t('admin.referralNetwork.controls.fullscreen'), + onClick: handleFullscreen, + icon: isFullscreen ? ( + + + + ) : ( + + + + ), + }, + ]; + + return ( +
+ {buttons.map((btn) => ( + + ))} +
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkFilters.tsx b/src/pages/ReferralNetwork/components/NetworkFilters.tsx new file mode 100644 index 0000000..4e2ff6d --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkFilters.tsx @@ -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 ( + + ); + } + + return ( +
+
+

+ {t('admin.referralNetwork.filters.title')} +

+ +
+ +
+ {/* Campaigns */} + {data.campaigns.length > 0 && ( +
+ +
+ {data.campaigns.map((campaign) => ( + + ))} +
+
+ )} + + {/* Partners only */} + + + {/* Min referrals */} +
+ + updateFilters({ minReferrals: Number(e.target.value) })} + className="w-full accent-accent-500" + /> +
+ + {/* Reset */} + +
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx new file mode 100644 index 0000000..371cb25 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -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 { + const hidden = new Set(); + 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(null); + const sigmaRef = useRef(null); + const graphRef = useRef(null); + const hiddenNodesRef = useRef>(new Set()); + const fa2Ref = useRef(null); + const fa2TimerRef = useRef | 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
; +} diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx new file mode 100644 index 0000000..05944c9 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -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 ( +
+

+ {t('admin.referralNetwork.legend.title')} +

+
+ {USER_LEGEND_ITEMS.map((item) => ( +
+
+ {t(item.labelKey)} +
+ ))} + {/* Campaign node with gradient to represent varying colors */} +
+
+ + {t('admin.referralNetwork.legend.campaignNode')} + +
+
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkSearch.tsx b/src/pages/ReferralNetwork/components/NetworkSearch.tsx new file mode 100644 index 0000000..5f30d36 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkSearch.tsx @@ -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(null); + const inputRef = useRef(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 ( +
+
+ + + + 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 && ( + + )} + {isFetching && ( +
+
+
+ )} +
+ + {/* Dropdown */} + {isDropdownOpen && debouncedQuery.length >= 2 && ( +
+ {totalResults === 0 && !isFetching && ( +
+ {t('admin.referralNetwork.search.noResults')} +
+ )} + + {searchResults?.users && searchResults.users.length > 0 && ( +
+ {searchResults.users.map((user) => ( + + ))} +
+ )} + + {searchResults?.campaigns && searchResults.campaigns.length > 0 && ( +
+ {searchResults.users && searchResults.users.length > 0 && ( +
+ )} + {searchResults.campaigns.map((campaign) => ( + + ))} +
+ )} + + {totalResults > 0 && ( +
+ {t('admin.referralNetwork.search.resultsCount', { count: totalResults })} +
+ )} +
+ )} +
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkStats.tsx b/src/pages/ReferralNetwork/components/NetworkStats.tsx new file mode 100644 index 0000000..a7303e0 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkStats.tsx @@ -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 ( +
+
+ {stats.map((stat) => ( +
+

+ {stat.label} +

+

{stat.value}

+
+ ))} +
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx new file mode 100644 index 0000000..3ec117f --- /dev/null +++ b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx @@ -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 ( +
+ {/* Header */} +
+

{user?.display_name ?? '...'}

+ +
+ + {/* Content */} +
+ {isLoading && ( +
+
+
+ )} + + {isError && ( +
+ {t('admin.referralNetwork.error')} +
+ )} + + {user && ( +
+ {/* Identity */} +
+ {user.username && ( +
+ @ + {user.username} +
+ )} + {user.tg_id && ( +
+ {t('admin.referralNetwork.user.tgId')} + {user.tg_id} +
+ )} + {user.email && ( +
+ {t('admin.referralNetwork.user.email')} + {user.email} +
+ )} + {user.is_partner && ( +
+ + {t('admin.referralNetwork.user.partner')} + +
+ )} +
+ + {/* Subscription */} +
+

+ {t('admin.referralNetwork.user.subscription')} +

+ {user.subscription_name ? ( +
+

{user.subscription_name}

+ {user.subscription_end && ( +

+ {t('admin.referralNetwork.user.validUntil', { + date: new Date(user.subscription_end).toLocaleDateString(), + })} +

+ )} +
+ ) : ( +

+ {t('admin.referralNetwork.user.noSubscription')} +

+ )} +
+ + {/* Personal stats */} +
+

+ {t('admin.referralNetwork.user.personalStats')} +

+
+
+ + {t('admin.referralNetwork.user.totalSpent')} + + + {formatKopeksToRubles(user.personal_spent_kopeks)} ₽ + +
+
+ + {t('admin.referralNetwork.user.referralEarnings')} + + + {formatKopeksToRubles(user.personal_revenue_kopeks)} ₽ + +
+
+
+ + {/* Referral branch */} +
+

+ {t('admin.referralNetwork.user.referralBranch')} +

+
+
+ + {t('admin.referralNetwork.user.directReferrals')} + + {user.direct_referrals} +
+
+ + {t('admin.referralNetwork.user.branchSize')} + + {user.total_branch_users} +
+
+ + {t('admin.referralNetwork.user.branchRevenue')} + + + {formatKopeksToRubles(user.branch_revenue_kopeks)} ₽ + +
+
+
+ + {/* Source */} +
+

+ {t('admin.referralNetwork.user.source')} +

+
+
+ + {t('admin.referralNetwork.user.referredBy')} + + + {user.referrer_display_name ?? t('admin.referralNetwork.user.organic')} + +
+ {user.campaign_name && ( +
+ + {t('admin.referralNetwork.user.fromCampaign')} + + {user.campaign_name} +
+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/src/pages/ReferralNetwork/index.tsx b/src/pages/ReferralNetwork/index.tsx new file mode 100644 index 0000000..94debef --- /dev/null +++ b/src/pages/ReferralNetwork/index.tsx @@ -0,0 +1,3 @@ +import { ReferralNetwork } from './ReferralNetwork'; + +export default ReferralNetwork; diff --git a/src/pages/ReferralNetwork/sigmaGlobals.ts b/src/pages/ReferralNetwork/sigmaGlobals.ts new file mode 100644 index 0000000..b702492 --- /dev/null +++ b/src/pages/ReferralNetwork/sigmaGlobals.ts @@ -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; +} diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts new file mode 100644 index 0000000..661e966 --- /dev/null +++ b/src/pages/ReferralNetwork/utils.ts @@ -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); +} diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts new file mode 100644 index 0000000..db12e20 --- /dev/null +++ b/src/store/referralNetwork.ts @@ -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; + 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) => void; + setSearchQuery: (query: string) => void; + setSearchResults: (results: { + users: NetworkUserNode[]; + campaigns: NetworkCampaignNode[]; + }) => void; + updateFilters: (filters: Partial) => void; + resetFilters: () => void; + setIsFiltersOpen: (open: boolean) => void; +} + +export const useReferralNetworkStore = create()((set) => ({ + networkData: null, + selectedNode: null, + hoveredNodeId: null, + highlightedNodes: new Set(), + 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 }), +})); diff --git a/src/types/referralNetwork.ts b/src/types/referralNetwork.ts new file mode 100644 index 0000000..a66f3b7 --- /dev/null +++ b/src/types/referralNetwork.ts @@ -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; +}