diff --git a/package-lock.json b/package-lock.json
index 0a93d77..6ba763d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
+ "@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.8.0",
"@tanstack/react-table": "^8.21.3",
"@telegram-apps/sdk-react": "^3.3.9",
@@ -2643,6 +2644,15 @@
"win32"
]
},
+ "node_modules/@sigma/node-border": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@sigma/node-border/-/node-border-3.0.0.tgz",
+ "integrity": "sha512-mE3zUfjvJVuAMhSjiP/zdlkqe0OVTETxd04XHUwof01YqdzTk0OB4ACJIhWrwgsBXl7tTd9lPuKoroafLh8MtQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "sigma": ">=3.0.0-beta.17"
+ }
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
diff --git a/package.json b/package.json
index 23494b9..d33cf80 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
+ "@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.8.0",
"@tanstack/react-table": "^8.21.3",
"@telegram-apps/sdk-react": "^3.3.9",
diff --git a/src/locales/en.json b/src/locales/en.json
index 1728e45..420e20f 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1247,17 +1247,24 @@
"totalUsers": "Users",
"totalReferrers": "Referrers",
"totalCampaigns": "Campaigns",
- "totalEarnings": "Total earnings"
+ "subscriptionRevenue": "Subscription revenue",
+ "totalEarnings": "Ref. earnings"
},
"legend": {
"title": "Legend",
- "regularUser": "Regular user",
+ "fillTitle": "Subscription",
+ "borderTitle": "Referral role",
+ "regularUser": "No subscription",
"activeReferrer": "Active referrer",
"partner": "Partner",
"topReferrer": "Top referrer (10+)",
"campaignUser": "From campaign",
"partnerCampaignEdge": "Partner → Campaign",
- "campaignNode": "Ad campaign"
+ "campaignNode": "Ad campaign",
+ "paidActive": "Paid subscription",
+ "trialActive": "Trial",
+ "paidExpired": "Paid expired",
+ "trialExpired": "Trial expired"
},
"user": {
"tgId": "TG ID",
@@ -1276,7 +1283,13 @@
"source": "Source",
"referredBy": "Referred by",
"fromCampaign": "Campaign",
- "organic": "Organic"
+ "organic": "Organic",
+ "subscriptionStatus": {
+ "trial_active": "Trial",
+ "paid_active": "Paid",
+ "trial_expired": "Trial expired",
+ "paid_expired": "Paid expired"
+ }
},
"campaign": {
"startParam": "Parameter",
@@ -3227,6 +3240,7 @@
"system": "System",
"permanent": "Permanent",
"expired": "expired",
+ "envManaged": "Managed via ADMIN_IDS / ADMIN_EMAILS in env",
"revoke": "Revoke",
"revokeAriaLabel": "Revoke role {{role}} from {{user}}",
"table": {
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 467e54d..3c06fe1 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -1081,17 +1081,24 @@
"totalUsers": "کاربران",
"totalReferrers": "ارجاعدهندگان",
"totalCampaigns": "کمپینها",
- "totalEarnings": "درآمد کل"
+ "subscriptionRevenue": "درآمد اشتراک",
+ "totalEarnings": "درآمد ارجاع"
},
"legend": {
"title": "راهنما",
- "regularUser": "کاربر عادی",
+ "fillTitle": "اشتراک",
+ "borderTitle": "نقش ارجاعدهنده",
+ "regularUser": "بدون اشتراک",
"activeReferrer": "ارجاعدهنده فعال",
"partner": "شریک",
"topReferrer": "ارجاعدهنده برتر (+۱۰)",
"campaignUser": "از کمپین",
"partnerCampaignEdge": "شریک → کمپین",
- "campaignNode": "کمپین تبلیغاتی"
+ "campaignNode": "کمپین تبلیغاتی",
+ "paidActive": "اشتراک پرداختی",
+ "trialActive": "آزمایشی",
+ "paidExpired": "اشتراک پرداختی منقضی",
+ "trialExpired": "آزمایشی منقضی"
},
"user": {
"tgId": "شناسه TG",
@@ -1110,7 +1117,13 @@
"source": "منبع",
"referredBy": "معرفی شده توسط",
"fromCampaign": "کمپین",
- "organic": "طبیعی"
+ "organic": "طبیعی",
+ "subscriptionStatus": {
+ "trial_active": "آزمایشی",
+ "paid_active": "پرداختی",
+ "trial_expired": "آزمایشی منقضی",
+ "paid_expired": "پرداختی منقضی"
+ }
},
"campaign": {
"startParam": "پارامتر",
@@ -2968,6 +2981,7 @@
"system": "سیستم",
"permanent": "دائمی",
"expired": "منقضی شده",
+ "envManaged": "مدیریت از طریق ADMIN_IDS / ADMIN_EMAILS در env",
"revoke": "لغو",
"revokeAriaLabel": "لغو نقش {{role}} از {{user}}",
"table": {
diff --git a/src/locales/ru.json b/src/locales/ru.json
index c361682..6327cb6 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -1268,17 +1268,24 @@
"totalUsers": "Пользователей",
"totalReferrers": "Рефереров",
"totalCampaigns": "Кампаний",
- "totalEarnings": "Общий доход"
+ "subscriptionRevenue": "Доход с подписок",
+ "totalEarnings": "Реф. доход"
},
"legend": {
"title": "Легенда",
- "regularUser": "Обычный",
+ "fillTitle": "Подписка",
+ "borderTitle": "Роль реферера",
+ "regularUser": "Без подписки",
"activeReferrer": "Активный реферер",
"partner": "Партнёр",
"topReferrer": "Топ-реферер (10+)",
"campaignUser": "Из кампании",
"partnerCampaignEdge": "Партнёр → РК",
- "campaignNode": "Рекламная кампания"
+ "campaignNode": "Рекламная кампания",
+ "paidActive": "Платная подписка",
+ "trialActive": "Триал",
+ "paidExpired": "Платная истекла",
+ "trialExpired": "Триал истёк"
},
"user": {
"tgId": "TG ID",
@@ -1297,7 +1304,13 @@
"source": "Источник",
"referredBy": "Пришёл от",
"fromCampaign": "РК",
- "organic": "Органический"
+ "organic": "Органический",
+ "subscriptionStatus": {
+ "trial_active": "Триал",
+ "paid_active": "Платная",
+ "trial_expired": "Триал истёк",
+ "paid_expired": "Платная истекла"
+ }
},
"campaign": {
"startParam": "Параметр",
@@ -3773,6 +3786,7 @@
"system": "Система",
"permanent": "Бессрочно",
"expired": "истекло",
+ "envManaged": "Управляется через ADMIN_IDS / ADMIN_EMAILS в env",
"revoke": "Отозвать",
"revokeAriaLabel": "Отозвать роль {{role}} у {{user}}",
"table": {
diff --git a/src/locales/zh.json b/src/locales/zh.json
index b6c525c..453b418 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -1081,17 +1081,24 @@
"totalUsers": "用户",
"totalReferrers": "推荐人",
"totalCampaigns": "活动",
- "totalEarnings": "总收入"
+ "subscriptionRevenue": "订阅收入",
+ "totalEarnings": "推荐收入"
},
"legend": {
"title": "图例",
- "regularUser": "普通用户",
+ "fillTitle": "订阅状态",
+ "borderTitle": "推荐角色",
+ "regularUser": "无订阅",
"activeReferrer": "活跃推荐人",
"partner": "合作伙伴",
"topReferrer": "顶级推荐人 (10+)",
"campaignUser": "来自活动",
"partnerCampaignEdge": "合作伙伴 → 活动",
- "campaignNode": "广告活动"
+ "campaignNode": "广告活动",
+ "paidActive": "付费订阅",
+ "trialActive": "试用",
+ "paidExpired": "付费已过期",
+ "trialExpired": "试用已过期"
},
"user": {
"tgId": "TG ID",
@@ -1110,7 +1117,13 @@
"source": "来源",
"referredBy": "由...推荐",
"fromCampaign": "活动",
- "organic": "自然流量"
+ "organic": "自然流量",
+ "subscriptionStatus": {
+ "trial_active": "试用",
+ "paid_active": "付费",
+ "trial_expired": "试用已过期",
+ "paid_expired": "付费已过期"
+ }
},
"campaign": {
"startParam": "参数",
@@ -2967,6 +2980,7 @@
"system": "系统",
"permanent": "永久",
"expired": "已过期",
+ "envManaged": "通过 ADMIN_IDS / ADMIN_EMAILS 环境变量管理",
"revoke": "撤销",
"revokeAriaLabel": "撤销 {{user}} 的角色 {{role}}",
"table": {
diff --git a/src/pages/AdminRoleAssign.tsx b/src/pages/AdminRoleAssign.tsx
index 51b483d..e099336 100644
--- a/src/pages/AdminRoleAssign.tsx
+++ b/src/pages/AdminRoleAssign.tsx
@@ -79,6 +79,7 @@ const UserPlusIcon = () => (
// === Constants ===
const PAGE_SIZE = 10;
+const SUPERADMIN_LEVEL = 999;
// === Sub-components ===
@@ -278,10 +279,10 @@ export default function AdminRoleAssign() {
enabled: !!roles && roles.length > 0,
});
- // Available roles filtered by canManageRole
+ // Available roles filtered by canManageRole (superadmin excluded — env-only)
const assignableRoles = useMemo(() => {
if (!roles) return [];
- return roles.filter((r) => r.is_active && canManageRole(r.level));
+ return roles.filter((r) => r.is_active && r.level < SUPERADMIN_LEVEL && canManageRole(r.level));
}, [roles, canManageRole]);
// Roles map for quick lookup
@@ -627,20 +628,29 @@ export default function AdminRoleAssign() {
{/* Actions */}
-
-
-
+ ENV
+
+ ) : (
+
+
+
+ )}
);
diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx
index 1671e64..7b3dafb 100644
--- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx
+++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx
@@ -5,7 +5,14 @@ import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker';
import { inferSettings } from 'graphology-layout-forceatlas2';
import { useReferralNetworkStore } from '@/store/referralNetwork';
import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork';
-import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils';
+import { createNodeBorderProgram } from '@sigma/node-border';
+import {
+ getNodeFillColor,
+ getNodeBorderColor,
+ getUserNodeSize,
+ getCampaignColor,
+ NODE_COLORS,
+} from '../utils';
import { setSigmaInstance } from '../sigmaGlobals';
interface NetworkGraphProps {
@@ -13,38 +20,177 @@ interface NetworkGraphProps {
className?: string;
}
+/**
+ * Compute initial positions using radial layout based on referral depth.
+ * Campaign nodes at center, direct users in first ring, their referrals further out.
+ */
+function computeRadialPositions(
+ graphData: NetworkGraphData,
+): Map {
+ const positions = new Map();
+
+ // Build adjacency: referrer_id -> [user_ids]
+ const childrenOf = new Map();
+ const userMap = new Map();
+ for (const user of graphData.users) {
+ userMap.set(user.id, user);
+ if (user.referrer_id !== null) {
+ const children = childrenOf.get(user.referrer_id) ?? [];
+ children.push(user.id);
+ childrenOf.set(user.referrer_id, children);
+ }
+ }
+
+ // Place campaign nodes at center
+ const campaignCount = graphData.campaigns.length;
+ graphData.campaigns.forEach((campaign, i) => {
+ const angle = (2 * Math.PI * i) / Math.max(campaignCount, 1);
+ positions.set(`campaign_${campaign.id}`, {
+ x: Math.cos(angle) * 5,
+ y: Math.sin(angle) * 5,
+ });
+ });
+
+ // BFS from campaign users outward
+ const placed = new Set();
+ const queue: Array<{ userId: number; depth: number; parentAngle: number }> = [];
+
+ // Seed: campaign users at depth 1
+ const campaignUserIds = new Set(
+ graphData.users.filter((u) => u.campaign_id !== null).map((u) => u.id),
+ );
+ const campaignUsers = [...campaignUserIds];
+ campaignUsers.forEach((userId, i) => {
+ const angle = (2 * Math.PI * i) / Math.max(campaignUsers.length, 1);
+ const radius = 30 + Math.random() * 10;
+ positions.set(`user_${userId}`, {
+ x: Math.cos(angle) * radius,
+ y: Math.sin(angle) * radius,
+ });
+ placed.add(userId);
+ queue.push({ userId, depth: 1, parentAngle: angle });
+ });
+
+ // Also seed root referrers (users who refer others but have no referrer and no campaign)
+ for (const user of graphData.users) {
+ if (
+ !placed.has(user.id) &&
+ user.referrer_id === null &&
+ (childrenOf.get(user.id)?.length ?? 0) > 0
+ ) {
+ const angle = Math.random() * 2 * Math.PI;
+ const radius = 20 + Math.random() * 10;
+ positions.set(`user_${user.id}`, {
+ x: Math.cos(angle) * radius,
+ y: Math.sin(angle) * radius,
+ });
+ placed.add(user.id);
+ queue.push({ userId: user.id, depth: 1, parentAngle: angle });
+ }
+ }
+
+ // BFS: place children at increasing radius
+ while (queue.length > 0) {
+ const { userId, depth, parentAngle } = queue.shift()!;
+ const children = childrenOf.get(userId) ?? [];
+ const childCount = children.length;
+ if (childCount === 0) continue;
+
+ const spreadAngle = Math.min(Math.PI / 2, (Math.PI / 3) * (1 / Math.max(depth, 1)));
+
+ children.forEach((childId, i) => {
+ if (placed.has(childId)) return;
+ const offset = childCount === 1 ? 0 : (i / (childCount - 1) - 0.5) * spreadAngle;
+ const angle = parentAngle + offset + (Math.random() - 0.5) * 0.1;
+ const radius = 30 + depth * 25 + Math.random() * 10;
+ positions.set(`user_${childId}`, {
+ x: Math.cos(angle) * radius,
+ y: Math.sin(angle) * radius,
+ });
+ placed.add(childId);
+ queue.push({ userId: childId, depth: depth + 1, parentAngle: angle });
+ });
+ }
+
+ // Place remaining unplaced users at the outer edge
+ for (const user of graphData.users) {
+ if (!placed.has(user.id)) {
+ const angle = Math.random() * 2 * Math.PI;
+ const radius = 60 + Math.random() * 40;
+ positions.set(`user_${user.id}`, {
+ x: Math.cos(angle) * radius,
+ y: Math.sin(angle) * radius,
+ });
+ }
+ }
+
+ return positions;
+}
+
+const BorderedNodeProgram = createNodeBorderProgram({
+ borders: [
+ {
+ size: { attribute: 'borderSize', defaultValue: 0 },
+ color: { attribute: 'borderColor' },
+ },
+ {
+ size: { fill: true },
+ color: { attribute: 'color' },
+ },
+ ],
+});
+
function buildFullGraph(graphData: NetworkGraphData): Graph {
const graph = new Graph();
+ const positions = computeRadialPositions(graphData);
graphData.campaigns.forEach((campaign, index) => {
- graph.addNode(`campaign_${campaign.id}`, {
- label: campaign.name,
+ const pos = positions.get(`campaign_${campaign.id}`) ?? {
x: Math.random() * 100,
y: Math.random() * 100,
+ };
+ graph.addNode(`campaign_${campaign.id}`, {
+ label: campaign.name,
+ x: pos.x,
+ y: pos.y,
size: 16,
color: getCampaignColor(index),
- type: 'circle',
+ type: 'bordered',
nodeType: 'campaign',
nodeId: campaign.id,
+ borderSize: 0,
+ borderColor: getCampaignColor(index),
});
});
graphData.users.forEach((user) => {
- const color = getUserNodeColor(user.direct_referrals, user.is_partner, user.campaign_id);
+ const fillColor = getNodeFillColor(
+ user.subscription_status,
+ user.direct_referrals,
+ user.is_partner,
+ );
+ const borderColor = getNodeBorderColor(user.direct_referrals, user.is_partner);
const size = getUserNodeSize(user.direct_referrals);
+ const pos = positions.get(`user_${user.id}`) ?? {
+ x: Math.random() * 100,
+ y: Math.random() * 100,
+ };
graph.addNode(`user_${user.id}`, {
label: user.display_name,
- x: Math.random() * 100,
- y: Math.random() * 100,
+ x: pos.x,
+ y: pos.y,
size,
- color,
- type: 'circle',
+ color: fillColor,
+ type: 'bordered',
nodeType: 'user',
nodeId: user.id,
isPartner: user.is_partner,
directReferrals: user.direct_referrals,
campaignId: user.campaign_id,
+ subscriptionStatus: user.subscription_status,
+ borderColor: borderColor ?? fillColor,
+ borderSize: borderColor ? 0.35 : 0,
});
});
@@ -57,7 +203,7 @@ function buildFullGraph(graphData: NetworkGraphData): Graph {
edge.type === 'partner_campaign'
? 'rgba(255, 138, 101, 0.5)'
: edge.type === 'campaign'
- ? 'rgba(77, 217, 192, 0.06)'
+ ? 'rgba(255, 255, 255, 0.06)'
: 'rgba(255,255,255,0.03)',
size: edge.type === 'partner_campaign' ? 1.5 : 0.3,
edgeType: edge.type,
@@ -193,8 +339,10 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
labelRenderedSizeThreshold: 14,
zIndex: true,
defaultEdgeColor: '#ffffff06',
- defaultNodeColor: '#6b7280',
- labelColor: { color: '#e8e6f0' },
+ defaultNodeColor: NODE_COLORS.regular,
+ nodeProgramClasses: { bordered: BorderedNodeProgram },
+ defaultNodeType: 'bordered',
+ labelColor: { attribute: 'labelColor', color: '#e5e7eb' },
labelFont: 'Inter, system-ui, sans-serif',
labelSize: 11,
stagePadding: 40,
@@ -213,6 +361,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
if (highlighted.size > 0) {
if (highlighted.has(node)) {
res.highlighted = true;
+ res.labelColor = '#111827';
res.zIndex = 2;
} else {
res.color = `${attrs.color}33`;
@@ -224,9 +373,11 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
if (hovered) {
if (node === hovered) {
res.highlighted = true;
+ res.labelColor = '#111827';
res.zIndex = 2;
} else if (graph.hasNode(hovered) && graph.areNeighbors(node, hovered)) {
res.highlighted = true;
+ res.labelColor = '#111827';
res.zIndex = 1;
} else if (!highlighted.has(node)) {
res.color = `${attrs.color}33`;
@@ -278,8 +429,10 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
linLogMode: true,
adjustSizes: true,
barnesHutOptimize: graph.order > 100,
- gravity: 1,
- slowDown: 2,
+ gravity: 0.05,
+ scalingRatio: 10,
+ slowDown: 5,
+ strongGravityMode: false,
},
});
fa2Ref.current = supervisor;
diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx
index 4bd766a..b6f837d 100644
--- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx
+++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
+import { NODE_COLORS } from '../utils';
interface NetworkLegendProps {
className?: string;
@@ -6,12 +7,15 @@ interface NetworkLegendProps {
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' },
+const FILL_ITEMS = [
+ { color: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' },
+ { color: NODE_COLORS.trialActive, labelKey: 'admin.referralNetwork.legend.trialActive' },
+ { color: NODE_COLORS.paidExpired, labelKey: 'admin.referralNetwork.legend.paidExpired' },
+ { color: NODE_COLORS.trialExpired, labelKey: 'admin.referralNetwork.legend.trialExpired' },
+ { color: NODE_COLORS.partner, labelKey: 'admin.referralNetwork.legend.partner' },
+ { color: NODE_COLORS.topReferrer, labelKey: 'admin.referralNetwork.legend.topReferrer' },
+ { color: NODE_COLORS.activeReferrer, labelKey: 'admin.referralNetwork.legend.activeReferrer' },
+ { color: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' },
];
export function NetworkLegend({ className }: NetworkLegendProps) {
@@ -25,27 +29,30 @@ export function NetworkLegend({ className }: NetworkLegendProps) {
-
- {t('admin.referralNetwork.legend.title')}
+ {/* Fill = Subscription status */}
+
+ {t('admin.referralNetwork.legend.fillTitle')}
-
- {USER_LEGEND_ITEMS.map((item) => (
+
+ {FILL_ITEMS.map((item) => (
))}
- {/* Partner → Campaign edge */}
+
+
+ {/* Edges */}
+
{t('admin.referralNetwork.legend.partnerCampaignEdge')}
- {/* Campaign node with gradient to represent varying colors */}
diff --git a/src/pages/ReferralNetwork/components/NetworkStats.tsx b/src/pages/ReferralNetwork/components/NetworkStats.tsx
index e715bd2..cbc5a25 100644
--- a/src/pages/ReferralNetwork/components/NetworkStats.tsx
+++ b/src/pages/ReferralNetwork/components/NetworkStats.tsx
@@ -10,38 +10,51 @@ interface NetworkStatsProps {
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}
-
- ))}
+
+
+
+ {t('admin.referralNetwork.stats.totalUsers')}
+
+
+ {data.total_users.toLocaleString()}
+
+
+
+
+ {t('admin.referralNetwork.stats.totalReferrers')}
+
+
+ {data.total_referrers.toLocaleString()}
+
+
+
+
+ {t('admin.referralNetwork.stats.totalCampaigns')}
+
+
+ {data.total_campaigns.toLocaleString()}
+
+
+
+
+ {t('admin.referralNetwork.stats.subscriptionRevenue')}
+
+
+ {formatKopeksToRubles(data.total_subscription_revenue_kopeks)} ₽
+
+
+
+
+ {t('admin.referralNetwork.stats.totalEarnings')}
+
+
+ {formatKopeksToRubles(data.total_earnings_kopeks)} ₽
+
+
);
diff --git a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx
index 653a4ed..0b626d7 100644
--- a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx
+++ b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx
@@ -2,7 +2,7 @@ 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';
+import { formatKopeksToRubles, getSubscriptionStatusColor } from '../utils';
interface UserDetailPanelProps {
userId: number;
@@ -101,7 +101,24 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
{user.subscription_name ? (
-
{user.subscription_name}
+
+
{user.subscription_name}
+ {user.subscription_status && (
+
+
+
+ {t(
+ `admin.referralNetwork.user.subscriptionStatus.${user.subscription_status}`,
+ )}
+
+
+ )}
+
{user.subscription_end && (
{t('admin.referralNetwork.user.validUntil', {
diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts
index f1a89ae..c7b5e1d 100644
--- a/src/pages/ReferralNetwork/utils.ts
+++ b/src/pages/ReferralNetwork/utils.ts
@@ -1,3 +1,5 @@
+import type { SubscriptionStatus } from '@/types/referralNetwork';
+
/**
* Format kopeks to a human-readable ruble string.
*/
@@ -5,6 +7,63 @@ export function formatKopeksToRubles(kopeks: number): string {
return `${(kopeks / 100).toLocaleString('ru-RU')}`;
}
+/**
+ * Single source of truth for user node colors.
+ */
+export const NODE_COLORS = {
+ partner: '#f0c261',
+ topReferrer: '#e85d9a',
+ activeReferrer: '#7c6aef',
+ paidActive: '#22c55e',
+ trialActive: '#38bdf8',
+ paidExpired: '#ef4444',
+ trialExpired: '#fb923c',
+ campaignUser: '#fbbf24',
+ regular: '#94a3b8',
+} as const;
+
+/**
+ * Map subscription status to its node color.
+ */
+const SUBSCRIPTION_STATUS_COLOR: Record = {
+ paid_active: NODE_COLORS.paidActive,
+ trial_active: NODE_COLORS.trialActive,
+ paid_expired: NODE_COLORS.paidExpired,
+ trial_expired: NODE_COLORS.trialExpired,
+};
+
+export function getSubscriptionStatusColor(status: SubscriptionStatus): string {
+ return SUBSCRIPTION_STATUS_COLOR[status];
+}
+
+/**
+ * Fill color priority:
+ * 1. Subscription status (when available)
+ * 2. Referral role (when no subscription data — keeps visual differentiation)
+ * 3. Regular fallback (campaign membership visible via edges, not fill)
+ */
+export function getNodeFillColor(
+ subscriptionStatus: SubscriptionStatus | null,
+ directReferrals: number = 0,
+ isPartner: boolean = false,
+): string {
+ if (subscriptionStatus) return SUBSCRIPTION_STATUS_COLOR[subscriptionStatus];
+ if (isPartner) return NODE_COLORS.partner;
+ if (directReferrals >= 10) return NODE_COLORS.topReferrer;
+ if (directReferrals >= 1) return NODE_COLORS.activeReferrer;
+ return NODE_COLORS.regular;
+}
+
+/**
+ * Border color = referral role. Returns null if no special role.
+ */
+export function getNodeBorderColor(directReferrals: number, isPartner: boolean): string | null {
+ if (isPartner) return NODE_COLORS.partner;
+ if (directReferrals >= 10) return NODE_COLORS.topReferrer;
+ if (directReferrals >= 1) return NODE_COLORS.activeReferrer;
+ return null;
+}
+
/**
* Campaign node color palette. Each campaign gets a distinct color
* based on its index position.
@@ -26,21 +85,6 @@ 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.
diff --git a/src/types/referralNetwork.ts b/src/types/referralNetwork.ts
index 10802b4..9dffb8f 100644
--- a/src/types/referralNetwork.ts
+++ b/src/types/referralNetwork.ts
@@ -1,3 +1,5 @@
+export type SubscriptionStatus = 'trial_active' | 'paid_active' | 'trial_expired' | 'paid_expired';
+
export interface NetworkUserNode {
id: number;
tg_id: number | null;
@@ -14,6 +16,7 @@ export interface NetworkUserNode {
personal_spent_kopeks: number;
subscription_name: string | null;
subscription_end: string | null;
+ subscription_status: SubscriptionStatus | null;
registered_at: string | null;
}
@@ -48,6 +51,7 @@ export interface NetworkGraphData {
total_referrers: number;
total_campaigns: number;
total_earnings_kopeks: number;
+ total_subscription_revenue_kopeks: number;
}
export interface NetworkUserDetail {
@@ -68,6 +72,7 @@ export interface NetworkUserDetail {
personal_spent_kopeks: number;
subscription_name: string | null;
subscription_end: string | null;
+ subscription_status: SubscriptionStatus | null;
registered_at: string | null;
}