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) => (
{t(item.labelKey)}
))} - {/* 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; }