From 8e59af96c57ec69b9fd2e64a703699f04348d900 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 08:41:41 +0300 Subject: [PATCH 01/12] fix: superadmin assignments show ENV badge, block UI revoke Superadmin role (level 999) is managed via env config only. Hide X button for superadmin assignments and show ENV badge instead. Filter superadmin from assignable roles dropdown. Add envManaged translation to all 4 locales. --- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/AdminRoleAssign.tsx | 40 ++++++++++++++++++++++------------- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 1728e45..fc992cc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3227,6 +3227,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..3bb8ee0 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2968,6 +2968,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..9fe64e0 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3773,6 +3773,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..d0b045a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2967,6 +2967,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 + + ) : ( + + + + )}
); From b289ea9c2315d4cb15aeb76da04cc0220fd16cbe Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 09:07:32 +0300 Subject: [PATCH 02/12] feat: color-code referral network nodes by subscription status Add subscription status colors: green (paid active), blue (trial active), red (paid expired), orange (trial expired). Extract NODE_COLORS constant as single source of truth. Add SubscriptionStatus union type. Show status badge in user detail panel. Add i18n for all 4 locales. --- src/locales/en.json | 14 +++++- src/locales/fa.json | 14 +++++- src/locales/ru.json | 14 +++++- src/locales/zh.json | 14 +++++- .../components/NetworkGraph.tsx | 8 +++- .../components/NetworkLegend.tsx | 23 +++++++--- .../components/UserDetailPanel.tsx | 21 ++++++++- src/pages/ReferralNetwork/utils.ts | 44 ++++++++++++++++--- src/types/referralNetwork.ts | 4 ++ 9 files changed, 134 insertions(+), 22 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index fc992cc..1d68520 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1257,7 +1257,11 @@ "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 +1280,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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 3bb8ee0..87854ae 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1091,7 +1091,11 @@ "topReferrer": "ارجاع‌دهنده برتر (+۱۰)", "campaignUser": "از کمپین", "partnerCampaignEdge": "شریک → کمپین", - "campaignNode": "کمپین تبلیغاتی" + "campaignNode": "کمپین تبلیغاتی", + "paidActive": "اشتراک پرداختی", + "trialActive": "آزمایشی", + "paidExpired": "اشتراک پرداختی منقضی", + "trialExpired": "آزمایشی منقضی" }, "user": { "tgId": "شناسه TG", @@ -1110,7 +1114,13 @@ "source": "منبع", "referredBy": "معرفی شده توسط", "fromCampaign": "کمپین", - "organic": "طبیعی" + "organic": "طبیعی", + "subscriptionStatus": { + "trial_active": "آزمایشی", + "paid_active": "پرداختی", + "trial_expired": "آزمایشی منقضی", + "paid_expired": "پرداختی منقضی" + } }, "campaign": { "startParam": "پارامتر", diff --git a/src/locales/ru.json b/src/locales/ru.json index 9fe64e0..46967d8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1278,7 +1278,11 @@ "topReferrer": "Топ-реферер (10+)", "campaignUser": "Из кампании", "partnerCampaignEdge": "Партнёр → РК", - "campaignNode": "Рекламная кампания" + "campaignNode": "Рекламная кампания", + "paidActive": "Платная подписка", + "trialActive": "Триал", + "paidExpired": "Платная истекла", + "trialExpired": "Триал истёк" }, "user": { "tgId": "TG ID", @@ -1297,7 +1301,13 @@ "source": "Источник", "referredBy": "Пришёл от", "fromCampaign": "РК", - "organic": "Органический" + "organic": "Органический", + "subscriptionStatus": { + "trial_active": "Триал", + "paid_active": "Платная", + "trial_expired": "Триал истёк", + "paid_expired": "Платная истекла" + } }, "campaign": { "startParam": "Параметр", diff --git a/src/locales/zh.json b/src/locales/zh.json index d0b045a..068e449 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1091,7 +1091,11 @@ "topReferrer": "顶级推荐人 (10+)", "campaignUser": "来自活动", "partnerCampaignEdge": "合作伙伴 → 活动", - "campaignNode": "广告活动" + "campaignNode": "广告活动", + "paidActive": "付费订阅", + "trialActive": "试用", + "paidExpired": "付费已过期", + "trialExpired": "试用已过期" }, "user": { "tgId": "TG ID", @@ -1110,7 +1114,13 @@ "source": "来源", "referredBy": "由...推荐", "fromCampaign": "活动", - "organic": "自然流量" + "organic": "自然流量", + "subscriptionStatus": { + "trial_active": "试用", + "paid_active": "付费", + "trial_expired": "试用已过期", + "paid_expired": "付费已过期" + } }, "campaign": { "startParam": "参数", diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 1671e64..0adce54 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -30,7 +30,12 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { }); graphData.users.forEach((user) => { - const color = getUserNodeColor(user.direct_referrals, user.is_partner, user.campaign_id); + const color = getUserNodeColor( + user.direct_referrals, + user.is_partner, + user.campaign_id, + user.subscription_status, + ); const size = getUserNodeSize(user.direct_referrals); graph.addNode(`user_${user.id}`, { @@ -45,6 +50,7 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { isPartner: user.is_partner, directReferrals: user.direct_referrals, campaignId: user.campaign_id, + subscriptionStatus: user.subscription_status, }); }); diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx index 4bd766a..af622f8 100644 --- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -1,17 +1,28 @@ import { useTranslation } from 'react-i18next'; +import { NODE_COLORS } from '../utils'; interface NetworkLegendProps { className?: string; } -const CAMPAIGN_GRADIENT_COLORS = ['#4dd9c0', '#f0c261', '#e85d9a', '#6b9fff', '#b97aff']; +const CAMPAIGN_GRADIENT_COLORS = [ + NODE_COLORS.campaignUser, + NODE_COLORS.partner, + NODE_COLORS.topReferrer, + '#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' }, + { colorKey: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' }, + { colorKey: NODE_COLORS.activeReferrer, labelKey: 'admin.referralNetwork.legend.activeReferrer' }, + { colorKey: NODE_COLORS.partner, labelKey: 'admin.referralNetwork.legend.partner' }, + { colorKey: NODE_COLORS.topReferrer, labelKey: 'admin.referralNetwork.legend.topReferrer' }, + { colorKey: NODE_COLORS.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' }, + { colorKey: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' }, + { colorKey: NODE_COLORS.trialActive, labelKey: 'admin.referralNetwork.legend.trialActive' }, + { colorKey: NODE_COLORS.paidExpired, labelKey: 'admin.referralNetwork.legend.paidExpired' }, + { colorKey: NODE_COLORS.trialExpired, labelKey: 'admin.referralNetwork.legend.trialExpired' }, ]; export function NetworkLegend({ className }: NetworkLegendProps) { 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..5af8ddf 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,35 @@ 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: '#4dd9c0', + regular: '#6b7280', +} 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]; +} + /** * Campaign node color palette. Each campaign gets a distinct color * based on its index position. @@ -28,17 +59,20 @@ export function getCampaignColor(index: number): string { /** * Determine the visual color for a user node. + * Priority: partner > top referrer > active referrer > subscription status > campaign > regular. */ export function getUserNodeColor( directReferrals: number, isPartner: boolean, campaignId: number | null, + subscriptionStatus: SubscriptionStatus | null, ): string { - if (isPartner) return '#f0c261'; - if (directReferrals >= 10) return '#e85d9a'; - if (directReferrals >= 1) return '#7c6aef'; - if (campaignId !== null) return '#4dd9c0'; - return '#6b7280'; + if (isPartner) return NODE_COLORS.partner; + if (directReferrals >= 10) return NODE_COLORS.topReferrer; + if (directReferrals >= 1) return NODE_COLORS.activeReferrer; + if (subscriptionStatus) return SUBSCRIPTION_STATUS_COLOR[subscriptionStatus]; + if (campaignId !== null) return NODE_COLORS.campaignUser; + return NODE_COLORS.regular; } /** diff --git a/src/types/referralNetwork.ts b/src/types/referralNetwork.ts index 10802b4..7456103 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; } @@ -68,6 +71,7 @@ export interface NetworkUserDetail { personal_spent_kopeks: number; subscription_name: string | null; subscription_end: string | null; + subscription_status: SubscriptionStatus | null; registered_at: string | null; } From b18f3fb211112c3a72db49a4b682e29257dd28ae Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 09:25:23 +0300 Subject: [PATCH 03/12] feat: add fill+border dual-color nodes, radial layout, and dark labels Node fill color now encodes subscription status (paid/trial active/expired), while border color indicates referral role (partner/top/active referrer). Added BFS-based radial initial positioning to spread referral branches outward. Updated legend with two sections and adjusted FA2 gravity for less clustering. --- package-lock.json | 10 ++ package.json | 1 + src/locales/en.json | 2 + src/locales/fa.json | 2 + src/locales/ru.json | 2 + src/locales/zh.json | 2 + .../components/NetworkGraph.tsx | 164 ++++++++++++++++-- .../components/NetworkLegend.tsx | 56 ++++-- src/pages/ReferralNetwork/utils.ts | 40 +++-- 9 files changed, 227 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index 625c0d9..a8bccf5 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 6e3d6d6..28ce37d 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 1d68520..c19ecca 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1251,6 +1251,8 @@ }, "legend": { "title": "Legend", + "fillTitle": "Subscription", + "borderTitle": "Referral role", "regularUser": "Regular user", "activeReferrer": "Active referrer", "partner": "Partner", diff --git a/src/locales/fa.json b/src/locales/fa.json index 87854ae..02580f0 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1085,6 +1085,8 @@ }, "legend": { "title": "راهنما", + "fillTitle": "اشتراک", + "borderTitle": "نقش ارجاع‌دهنده", "regularUser": "کاربر عادی", "activeReferrer": "ارجاع‌دهنده فعال", "partner": "شریک", diff --git a/src/locales/ru.json b/src/locales/ru.json index 46967d8..cda1bfc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1272,6 +1272,8 @@ }, "legend": { "title": "Легенда", + "fillTitle": "Подписка", + "borderTitle": "Роль реферера", "regularUser": "Обычный", "activeReferrer": "Активный реферер", "partner": "Партнёр", diff --git a/src/locales/zh.json b/src/locales/zh.json index 068e449..3d7fd84 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1085,6 +1085,8 @@ }, "legend": { "title": "图例", + "fillTitle": "订阅状态", + "borderTitle": "推荐角色", "regularUser": "普通用户", "activeReferrer": "活跃推荐人", "partner": "合作伙伴", diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 0adce54..9d2477c 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -5,7 +5,8 @@ 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 } from '../utils'; import { setSigmaInstance } from '../sigmaGlobals'; interface NetworkGraphProps { @@ -13,44 +14,169 @@ 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' }, + }, + ], +}); + 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, - user.subscription_status, - ); + const fillColor = getNodeFillColor(user.subscription_status, user.campaign_id); + 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.25 : 0, }); }); @@ -200,7 +326,9 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { zIndex: true, defaultEdgeColor: '#ffffff06', defaultNodeColor: '#6b7280', - labelColor: { color: '#e8e6f0' }, + nodeProgramClasses: { bordered: BorderedNodeProgram }, + defaultNodeType: 'bordered', + labelColor: { color: '#111827' }, labelFont: 'Inter, system-ui, sans-serif', labelSize: 11, stagePadding: 40, @@ -284,8 +412,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 af622f8..a9f0ec0 100644 --- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -13,16 +13,19 @@ const CAMPAIGN_GRADIENT_COLORS = [ '#b97aff', ]; -const USER_LEGEND_ITEMS = [ - { colorKey: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' }, - { colorKey: NODE_COLORS.activeReferrer, labelKey: 'admin.referralNetwork.legend.activeReferrer' }, - { colorKey: NODE_COLORS.partner, labelKey: 'admin.referralNetwork.legend.partner' }, - { colorKey: NODE_COLORS.topReferrer, labelKey: 'admin.referralNetwork.legend.topReferrer' }, - { colorKey: NODE_COLORS.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' }, - { colorKey: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' }, - { colorKey: NODE_COLORS.trialActive, labelKey: 'admin.referralNetwork.legend.trialActive' }, - { colorKey: NODE_COLORS.paidExpired, labelKey: 'admin.referralNetwork.legend.paidExpired' }, - { colorKey: NODE_COLORS.trialExpired, labelKey: 'admin.referralNetwork.legend.trialExpired' }, +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.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' }, + { color: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' }, +]; + +const BORDER_ITEMS = [ + { 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' }, ]; export function NetworkLegend({ className }: NetworkLegendProps) { @@ -36,27 +39,46 @@ 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) => (
{t(item.labelKey)}
))} - {/* Partner → Campaign edge */} +
+ + {/* Border = Referral role */} +

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

+
+ {BORDER_ITEMS.map((item) => ( +
+
+ {t(item.labelKey)} +
+ ))} +
+ + {/* Edges */} +
{t('admin.referralNetwork.legend.partnerCampaignEdge')}
- {/* Campaign node with gradient to represent varying colors */}
diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index 5af8ddf..47f0630 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -36,6 +36,28 @@ export function getSubscriptionStatusColor(status: SubscriptionStatus): string { return SUBSCRIPTION_STATUS_COLOR[status]; } +/** + * Fill color = subscription status (primary concern for campaign evaluation). + */ +export function getNodeFillColor( + subscriptionStatus: SubscriptionStatus | null, + campaignId: number | null, +): string { + if (subscriptionStatus) return SUBSCRIPTION_STATUS_COLOR[subscriptionStatus]; + if (campaignId !== null) return NODE_COLORS.campaignUser; + 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. @@ -57,24 +79,6 @@ export function getCampaignColor(index: number): string { return CAMPAIGN_COLORS[index % CAMPAIGN_COLORS.length]; } -/** - * Determine the visual color for a user node. - * Priority: partner > top referrer > active referrer > subscription status > campaign > regular. - */ -export function getUserNodeColor( - directReferrals: number, - isPartner: boolean, - campaignId: number | null, - subscriptionStatus: SubscriptionStatus | null, -): string { - if (isPartner) return NODE_COLORS.partner; - if (directReferrals >= 10) return NODE_COLORS.topReferrer; - if (directReferrals >= 1) return NODE_COLORS.activeReferrer; - if (subscriptionStatus) return SUBSCRIPTION_STATUS_COLOR[subscriptionStatus]; - if (campaignId !== null) return NODE_COLORS.campaignUser; - return NODE_COLORS.regular; -} - /** * Determine the visual size for a user node. * Size is proportional to direct_referrals, clamped between min and max. From 61ca5cc53dfe1a1cf064461007c81a496bc9c234 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 09:32:53 +0300 Subject: [PATCH 04/12] fix: fallback to referral role fill color when subscription_status is null When subscription_status is not available (backend not deployed or user has no subscription), fill color now falls through to referral role (partner/topReferrer/activeReferrer) before defaulting to campaign/regular. Increases border size from 0.25 to 0.35 and merges legend sections. --- .../components/NetworkGraph.tsx | 9 ++++++-- .../components/NetworkLegend.tsx | 23 ++----------------- src/pages/ReferralNetwork/utils.ts | 10 +++++++- 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 9d2477c..e7ce30e 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -154,7 +154,12 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { }); graphData.users.forEach((user) => { - const fillColor = getNodeFillColor(user.subscription_status, user.campaign_id); + const fillColor = getNodeFillColor( + user.subscription_status, + user.campaign_id, + 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}`) ?? { @@ -176,7 +181,7 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { campaignId: user.campaign_id, subscriptionStatus: user.subscription_status, borderColor: borderColor ?? fillColor, - borderSize: borderColor ? 0.25 : 0, + borderSize: borderColor ? 0.35 : 0, }); }); diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx index a9f0ec0..bd31523 100644 --- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -18,14 +18,11 @@ const FILL_ITEMS = [ { 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.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' }, - { color: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' }, -]; - -const BORDER_ITEMS = [ { 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.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' }, + { color: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' }, ]; export function NetworkLegend({ className }: NetworkLegendProps) { @@ -55,22 +52,6 @@ export function NetworkLegend({ className }: NetworkLegendProps) { ))}
- {/* Border = Referral role */} -

- {t('admin.referralNetwork.legend.borderTitle')} -

-
- {BORDER_ITEMS.map((item) => ( -
-
- {t(item.labelKey)} -
- ))} -
- {/* Edges */}
diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index 47f0630..682c8eb 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -37,13 +37,21 @@ export function getSubscriptionStatusColor(status: SubscriptionStatus): string { } /** - * Fill color = subscription status (primary concern for campaign evaluation). + * Fill color priority: + * 1. Subscription status (when available) + * 2. Referral role (when no subscription data — keeps visual differentiation) + * 3. Campaign user / regular fallback */ export function getNodeFillColor( subscriptionStatus: SubscriptionStatus | null, campaignId: number | 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; if (campaignId !== null) return NODE_COLORS.campaignUser; return NODE_COLORS.regular; } From 5a92484912e1b6f251f1c1c13a68f5c6eeaa2d2a Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 09:39:19 +0300 Subject: [PATCH 05/12] fix: add fill border to node-border program config The createNodeBorderProgram was missing the second border entry with { size: { fill: true }, color: { attribute: 'color' } } which renders the node's fill color. Without it, node bodies were transparent and only edges were visible. --- src/pages/ReferralNetwork/components/NetworkGraph.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index e7ce30e..69e2e85 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -127,6 +127,10 @@ const BorderedNodeProgram = createNodeBorderProgram({ size: { attribute: 'borderSize', defaultValue: 0 }, color: { attribute: 'borderColor' }, }, + { + size: { fill: true }, + color: { attribute: 'color' }, + }, ], }); From cc64f7b8ea7a136df4ad7dcf5cf2b1631455b031 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 09:51:43 +0300 Subject: [PATCH 06/12] fix: rename "Regular user" to "No subscription" in legend The gray color in the referral network legend represents users without a subscription, not "regular/organic" users. Updated labels in all 4 locales (en, ru, fa, zh) to reflect this. --- src/locales/en.json | 2 +- src/locales/fa.json | 2 +- src/locales/ru.json | 2 +- src/locales/zh.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index c19ecca..3a6ae86 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1253,7 +1253,7 @@ "title": "Legend", "fillTitle": "Subscription", "borderTitle": "Referral role", - "regularUser": "Regular user", + "regularUser": "No subscription", "activeReferrer": "Active referrer", "partner": "Partner", "topReferrer": "Top referrer (10+)", diff --git a/src/locales/fa.json b/src/locales/fa.json index 02580f0..359166d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1087,7 +1087,7 @@ "title": "راهنما", "fillTitle": "اشتراک", "borderTitle": "نقش ارجاع‌دهنده", - "regularUser": "کاربر عادی", + "regularUser": "بدون اشتراک", "activeReferrer": "ارجاع‌دهنده فعال", "partner": "شریک", "topReferrer": "ارجاع‌دهنده برتر (+۱۰)", diff --git a/src/locales/ru.json b/src/locales/ru.json index cda1bfc..a0cb2a1 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1274,7 +1274,7 @@ "title": "Легенда", "fillTitle": "Подписка", "borderTitle": "Роль реферера", - "regularUser": "Обычный", + "regularUser": "Без подписки", "activeReferrer": "Активный реферер", "partner": "Партнёр", "topReferrer": "Топ-реферер (10+)", diff --git a/src/locales/zh.json b/src/locales/zh.json index 3d7fd84..857077e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1087,7 +1087,7 @@ "title": "图例", "fillTitle": "订阅状态", "borderTitle": "推荐角色", - "regularUser": "普通用户", + "regularUser": "无订阅", "activeReferrer": "活跃推荐人", "partner": "合作伙伴", "topReferrer": "顶级推荐人 (10+)", From 2a57442e04c1b09690504e5ad9bb96a3fcb9f7c4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:02:17 +0300 Subject: [PATCH 07/12] fix: make trial, campaign, and no-subscription node colors distinct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - campaignUser: #4dd9c0 (teal) → #fbbf24 (amber) — no longer blends with trial blue - regular: #6b7280 (gray) → #94a3b8 (slate) — visible on dark background - Campaign edges updated to amber tint - defaultNodeColor now references NODE_COLORS.regular instead of hardcoded value - Legend gradient uses actual campaign palette colors --- .../ReferralNetwork/components/NetworkGraph.tsx | 12 +++++++++--- .../ReferralNetwork/components/NetworkLegend.tsx | 8 +------- src/pages/ReferralNetwork/utils.ts | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 69e2e85..a3636a9 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -6,7 +6,13 @@ import { inferSettings } from 'graphology-layout-forceatlas2'; import { useReferralNetworkStore } from '@/store/referralNetwork'; import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork'; import { createNodeBorderProgram } from '@sigma/node-border'; -import { getNodeFillColor, getNodeBorderColor, getUserNodeSize, getCampaignColor } from '../utils'; +import { + getNodeFillColor, + getNodeBorderColor, + getUserNodeSize, + getCampaignColor, + NODE_COLORS, +} from '../utils'; import { setSigmaInstance } from '../sigmaGlobals'; interface NetworkGraphProps { @@ -198,7 +204,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(251, 191, 36, 0.06)' : 'rgba(255,255,255,0.03)', size: edge.type === 'partner_campaign' ? 1.5 : 0.3, edgeType: edge.type, @@ -334,7 +340,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { labelRenderedSizeThreshold: 14, zIndex: true, defaultEdgeColor: '#ffffff06', - defaultNodeColor: '#6b7280', + defaultNodeColor: NODE_COLORS.regular, nodeProgramClasses: { bordered: BorderedNodeProgram }, defaultNodeType: 'bordered', labelColor: { color: '#111827' }, diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx index bd31523..11ed00c 100644 --- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -5,13 +5,7 @@ interface NetworkLegendProps { className?: string; } -const CAMPAIGN_GRADIENT_COLORS = [ - NODE_COLORS.campaignUser, - NODE_COLORS.partner, - NODE_COLORS.topReferrer, - '#6b9fff', - '#b97aff', -]; +const CAMPAIGN_GRADIENT_COLORS = ['#4dd9c0', '#f0c261', '#e85d9a', '#6b9fff', '#b97aff']; const FILL_ITEMS = [ { color: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' }, diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index 682c8eb..3d4b9d2 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -18,8 +18,8 @@ export const NODE_COLORS = { trialActive: '#38bdf8', paidExpired: '#ef4444', trialExpired: '#fb923c', - campaignUser: '#4dd9c0', - regular: '#6b7280', + campaignUser: '#fbbf24', + regular: '#94a3b8', } as const; /** From 2436060b5a79e6b3f485b177e1be87a8c02d307b Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:08:42 +0300 Subject: [PATCH 08/12] fix: use white campaign edges to avoid blending with trial expired nodes --- src/pages/ReferralNetwork/components/NetworkGraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index a3636a9..7a5034b 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -204,7 +204,7 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { edge.type === 'partner_campaign' ? 'rgba(255, 138, 101, 0.5)' : edge.type === 'campaign' - ? 'rgba(251, 191, 36, 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, From 2229eeecb089a869483f2dbeb30f8817f434930d Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:13:56 +0300 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20remove=20campaignUser=20fill=20col?= =?UTF-8?q?or=20=E2=80=94=20campaign=20membership=20shown=20via=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign users no longer get a separate amber fill color. Their campaign association is already visible through the edge connecting them to the campaign node. Users from campaigns now show their subscription status color (or regular gray if no subscription), reducing visual noise. --- src/pages/ReferralNetwork/components/NetworkGraph.tsx | 1 - src/pages/ReferralNetwork/components/NetworkLegend.tsx | 1 - src/pages/ReferralNetwork/utils.ts | 4 +--- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 7a5034b..1648d67 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -166,7 +166,6 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { graphData.users.forEach((user) => { const fillColor = getNodeFillColor( user.subscription_status, - user.campaign_id, user.direct_referrals, user.is_partner, ); diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx index 11ed00c..b6f837d 100644 --- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -15,7 +15,6 @@ const FILL_ITEMS = [ { 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.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' }, { color: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' }, ]; diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index 3d4b9d2..c7b5e1d 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -40,11 +40,10 @@ export function getSubscriptionStatusColor(status: SubscriptionStatus): string { * Fill color priority: * 1. Subscription status (when available) * 2. Referral role (when no subscription data — keeps visual differentiation) - * 3. Campaign user / regular fallback + * 3. Regular fallback (campaign membership visible via edges, not fill) */ export function getNodeFillColor( subscriptionStatus: SubscriptionStatus | null, - campaignId: number | null, directReferrals: number = 0, isPartner: boolean = false, ): string { @@ -52,7 +51,6 @@ export function getNodeFillColor( if (isPartner) return NODE_COLORS.partner; if (directReferrals >= 10) return NODE_COLORS.topReferrer; if (directReferrals >= 1) return NODE_COLORS.activeReferrer; - if (campaignId !== null) return NODE_COLORS.campaignUser; return NODE_COLORS.regular; } From 0cc1cd5871a4b82ff2e9f1b8dc793d8563016362 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:18:34 +0300 Subject: [PATCH 10/12] fix: use light label color for graph nodes on dark background --- src/pages/ReferralNetwork/components/NetworkGraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 1648d67..56efc40 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -342,7 +342,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { defaultNodeColor: NODE_COLORS.regular, nodeProgramClasses: { bordered: BorderedNodeProgram }, defaultNodeType: 'bordered', - labelColor: { color: '#111827' }, + labelColor: { color: '#e5e7eb' }, labelFont: 'Inter, system-ui, sans-serif', labelSize: 11, stagePadding: 40, From ebe2c3af7e1b0abd622f4c8f20a94af5ca8e6076 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:26:16 +0300 Subject: [PATCH 11/12] fix: use dark label color on hover (white bg), light on normal (dark bg) Switch labelColor to attribute-based mode so hovered/highlighted nodes get dark text (#111827) readable on the white hover background, while normal labels stay light (#e5e7eb) readable on the dark canvas. --- src/pages/ReferralNetwork/components/NetworkGraph.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 56efc40..7b3dafb 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -342,7 +342,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { defaultNodeColor: NODE_COLORS.regular, nodeProgramClasses: { bordered: BorderedNodeProgram }, defaultNodeType: 'bordered', - labelColor: { color: '#e5e7eb' }, + labelColor: { attribute: 'labelColor', color: '#e5e7eb' }, labelFont: 'Inter, system-ui, sans-serif', labelSize: 11, stagePadding: 40, @@ -361,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`; @@ -372,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`; From 5b12784ab84b6b3afed015b3172d34c6015b9e33 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:31:29 +0300 Subject: [PATCH 12/12] feat: show subscription revenue and referral earnings in network stats Add subscription revenue (total from subscription payments) to the stats panel alongside referral earnings. Layout updated to 2-column grid with subscription revenue highlighted in accent color. --- src/locales/en.json | 3 +- src/locales/fa.json | 3 +- src/locales/ru.json | 3 +- src/locales/zh.json | 3 +- .../components/NetworkStats.tsx | 69 +++++++++++-------- src/types/referralNetwork.ts | 1 + 6 files changed, 50 insertions(+), 32 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 3a6ae86..420e20f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1247,7 +1247,8 @@ "totalUsers": "Users", "totalReferrers": "Referrers", "totalCampaigns": "Campaigns", - "totalEarnings": "Total earnings" + "subscriptionRevenue": "Subscription revenue", + "totalEarnings": "Ref. earnings" }, "legend": { "title": "Legend", diff --git a/src/locales/fa.json b/src/locales/fa.json index 359166d..3c06fe1 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1081,7 +1081,8 @@ "totalUsers": "کاربران", "totalReferrers": "ارجاع‌دهندگان", "totalCampaigns": "کمپین‌ها", - "totalEarnings": "درآمد کل" + "subscriptionRevenue": "درآمد اشتراک", + "totalEarnings": "درآمد ارجاع" }, "legend": { "title": "راهنما", diff --git a/src/locales/ru.json b/src/locales/ru.json index a0cb2a1..6327cb6 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1268,7 +1268,8 @@ "totalUsers": "Пользователей", "totalReferrers": "Рефереров", "totalCampaigns": "Кампаний", - "totalEarnings": "Общий доход" + "subscriptionRevenue": "Доход с подписок", + "totalEarnings": "Реф. доход" }, "legend": { "title": "Легенда", diff --git a/src/locales/zh.json b/src/locales/zh.json index 857077e..453b418 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1081,7 +1081,8 @@ "totalUsers": "用户", "totalReferrers": "推荐人", "totalCampaigns": "活动", - "totalEarnings": "总收入" + "subscriptionRevenue": "订阅收入", + "totalEarnings": "推荐收入" }, "legend": { "title": "图例", 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/types/referralNetwork.ts b/src/types/referralNetwork.ts index 7456103..9dffb8f 100644 --- a/src/types/referralNetwork.ts +++ b/src/types/referralNetwork.ts @@ -51,6 +51,7 @@ export interface NetworkGraphData { total_referrers: number; total_campaigns: number; total_earnings_kopeks: number; + total_subscription_revenue_kopeks: number; } export interface NetworkUserDetail {