mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
10
package-lock.json
generated
10
package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
|
"@sigma/node-border": "^3.0.0",
|
||||||
"@tanstack/react-query": "^5.8.0",
|
"@tanstack/react-query": "^5.8.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@telegram-apps/sdk-react": "^3.3.9",
|
"@telegram-apps/sdk-react": "^3.3.9",
|
||||||
@@ -2643,6 +2644,15 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
|
"@sigma/node-border": "^3.0.0",
|
||||||
"@tanstack/react-query": "^5.8.0",
|
"@tanstack/react-query": "^5.8.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@telegram-apps/sdk-react": "^3.3.9",
|
"@telegram-apps/sdk-react": "^3.3.9",
|
||||||
|
|||||||
@@ -1247,17 +1247,24 @@
|
|||||||
"totalUsers": "Users",
|
"totalUsers": "Users",
|
||||||
"totalReferrers": "Referrers",
|
"totalReferrers": "Referrers",
|
||||||
"totalCampaigns": "Campaigns",
|
"totalCampaigns": "Campaigns",
|
||||||
"totalEarnings": "Total earnings"
|
"subscriptionRevenue": "Subscription revenue",
|
||||||
|
"totalEarnings": "Ref. earnings"
|
||||||
},
|
},
|
||||||
"legend": {
|
"legend": {
|
||||||
"title": "Legend",
|
"title": "Legend",
|
||||||
"regularUser": "Regular user",
|
"fillTitle": "Subscription",
|
||||||
|
"borderTitle": "Referral role",
|
||||||
|
"regularUser": "No subscription",
|
||||||
"activeReferrer": "Active referrer",
|
"activeReferrer": "Active referrer",
|
||||||
"partner": "Partner",
|
"partner": "Partner",
|
||||||
"topReferrer": "Top referrer (10+)",
|
"topReferrer": "Top referrer (10+)",
|
||||||
"campaignUser": "From campaign",
|
"campaignUser": "From campaign",
|
||||||
"partnerCampaignEdge": "Partner → Campaign",
|
"partnerCampaignEdge": "Partner → Campaign",
|
||||||
"campaignNode": "Ad campaign"
|
"campaignNode": "Ad campaign",
|
||||||
|
"paidActive": "Paid subscription",
|
||||||
|
"trialActive": "Trial",
|
||||||
|
"paidExpired": "Paid expired",
|
||||||
|
"trialExpired": "Trial expired"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"tgId": "TG ID",
|
"tgId": "TG ID",
|
||||||
@@ -1276,7 +1283,13 @@
|
|||||||
"source": "Source",
|
"source": "Source",
|
||||||
"referredBy": "Referred by",
|
"referredBy": "Referred by",
|
||||||
"fromCampaign": "Campaign",
|
"fromCampaign": "Campaign",
|
||||||
"organic": "Organic"
|
"organic": "Organic",
|
||||||
|
"subscriptionStatus": {
|
||||||
|
"trial_active": "Trial",
|
||||||
|
"paid_active": "Paid",
|
||||||
|
"trial_expired": "Trial expired",
|
||||||
|
"paid_expired": "Paid expired"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"campaign": {
|
"campaign": {
|
||||||
"startParam": "Parameter",
|
"startParam": "Parameter",
|
||||||
@@ -3227,6 +3240,7 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"permanent": "Permanent",
|
"permanent": "Permanent",
|
||||||
"expired": "expired",
|
"expired": "expired",
|
||||||
|
"envManaged": "Managed via ADMIN_IDS / ADMIN_EMAILS in env",
|
||||||
"revoke": "Revoke",
|
"revoke": "Revoke",
|
||||||
"revokeAriaLabel": "Revoke role {{role}} from {{user}}",
|
"revokeAriaLabel": "Revoke role {{role}} from {{user}}",
|
||||||
"table": {
|
"table": {
|
||||||
|
|||||||
@@ -1081,17 +1081,24 @@
|
|||||||
"totalUsers": "کاربران",
|
"totalUsers": "کاربران",
|
||||||
"totalReferrers": "ارجاعدهندگان",
|
"totalReferrers": "ارجاعدهندگان",
|
||||||
"totalCampaigns": "کمپینها",
|
"totalCampaigns": "کمپینها",
|
||||||
"totalEarnings": "درآمد کل"
|
"subscriptionRevenue": "درآمد اشتراک",
|
||||||
|
"totalEarnings": "درآمد ارجاع"
|
||||||
},
|
},
|
||||||
"legend": {
|
"legend": {
|
||||||
"title": "راهنما",
|
"title": "راهنما",
|
||||||
"regularUser": "کاربر عادی",
|
"fillTitle": "اشتراک",
|
||||||
|
"borderTitle": "نقش ارجاعدهنده",
|
||||||
|
"regularUser": "بدون اشتراک",
|
||||||
"activeReferrer": "ارجاعدهنده فعال",
|
"activeReferrer": "ارجاعدهنده فعال",
|
||||||
"partner": "شریک",
|
"partner": "شریک",
|
||||||
"topReferrer": "ارجاعدهنده برتر (+۱۰)",
|
"topReferrer": "ارجاعدهنده برتر (+۱۰)",
|
||||||
"campaignUser": "از کمپین",
|
"campaignUser": "از کمپین",
|
||||||
"partnerCampaignEdge": "شریک → کمپین",
|
"partnerCampaignEdge": "شریک → کمپین",
|
||||||
"campaignNode": "کمپین تبلیغاتی"
|
"campaignNode": "کمپین تبلیغاتی",
|
||||||
|
"paidActive": "اشتراک پرداختی",
|
||||||
|
"trialActive": "آزمایشی",
|
||||||
|
"paidExpired": "اشتراک پرداختی منقضی",
|
||||||
|
"trialExpired": "آزمایشی منقضی"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"tgId": "شناسه TG",
|
"tgId": "شناسه TG",
|
||||||
@@ -1110,7 +1117,13 @@
|
|||||||
"source": "منبع",
|
"source": "منبع",
|
||||||
"referredBy": "معرفی شده توسط",
|
"referredBy": "معرفی شده توسط",
|
||||||
"fromCampaign": "کمپین",
|
"fromCampaign": "کمپین",
|
||||||
"organic": "طبیعی"
|
"organic": "طبیعی",
|
||||||
|
"subscriptionStatus": {
|
||||||
|
"trial_active": "آزمایشی",
|
||||||
|
"paid_active": "پرداختی",
|
||||||
|
"trial_expired": "آزمایشی منقضی",
|
||||||
|
"paid_expired": "پرداختی منقضی"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"campaign": {
|
"campaign": {
|
||||||
"startParam": "پارامتر",
|
"startParam": "پارامتر",
|
||||||
@@ -2968,6 +2981,7 @@
|
|||||||
"system": "سیستم",
|
"system": "سیستم",
|
||||||
"permanent": "دائمی",
|
"permanent": "دائمی",
|
||||||
"expired": "منقضی شده",
|
"expired": "منقضی شده",
|
||||||
|
"envManaged": "مدیریت از طریق ADMIN_IDS / ADMIN_EMAILS در env",
|
||||||
"revoke": "لغو",
|
"revoke": "لغو",
|
||||||
"revokeAriaLabel": "لغو نقش {{role}} از {{user}}",
|
"revokeAriaLabel": "لغو نقش {{role}} از {{user}}",
|
||||||
"table": {
|
"table": {
|
||||||
|
|||||||
@@ -1268,17 +1268,24 @@
|
|||||||
"totalUsers": "Пользователей",
|
"totalUsers": "Пользователей",
|
||||||
"totalReferrers": "Рефереров",
|
"totalReferrers": "Рефереров",
|
||||||
"totalCampaigns": "Кампаний",
|
"totalCampaigns": "Кампаний",
|
||||||
"totalEarnings": "Общий доход"
|
"subscriptionRevenue": "Доход с подписок",
|
||||||
|
"totalEarnings": "Реф. доход"
|
||||||
},
|
},
|
||||||
"legend": {
|
"legend": {
|
||||||
"title": "Легенда",
|
"title": "Легенда",
|
||||||
"regularUser": "Обычный",
|
"fillTitle": "Подписка",
|
||||||
|
"borderTitle": "Роль реферера",
|
||||||
|
"regularUser": "Без подписки",
|
||||||
"activeReferrer": "Активный реферер",
|
"activeReferrer": "Активный реферер",
|
||||||
"partner": "Партнёр",
|
"partner": "Партнёр",
|
||||||
"topReferrer": "Топ-реферер (10+)",
|
"topReferrer": "Топ-реферер (10+)",
|
||||||
"campaignUser": "Из кампании",
|
"campaignUser": "Из кампании",
|
||||||
"partnerCampaignEdge": "Партнёр → РК",
|
"partnerCampaignEdge": "Партнёр → РК",
|
||||||
"campaignNode": "Рекламная кампания"
|
"campaignNode": "Рекламная кампания",
|
||||||
|
"paidActive": "Платная подписка",
|
||||||
|
"trialActive": "Триал",
|
||||||
|
"paidExpired": "Платная истекла",
|
||||||
|
"trialExpired": "Триал истёк"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"tgId": "TG ID",
|
"tgId": "TG ID",
|
||||||
@@ -1297,7 +1304,13 @@
|
|||||||
"source": "Источник",
|
"source": "Источник",
|
||||||
"referredBy": "Пришёл от",
|
"referredBy": "Пришёл от",
|
||||||
"fromCampaign": "РК",
|
"fromCampaign": "РК",
|
||||||
"organic": "Органический"
|
"organic": "Органический",
|
||||||
|
"subscriptionStatus": {
|
||||||
|
"trial_active": "Триал",
|
||||||
|
"paid_active": "Платная",
|
||||||
|
"trial_expired": "Триал истёк",
|
||||||
|
"paid_expired": "Платная истекла"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"campaign": {
|
"campaign": {
|
||||||
"startParam": "Параметр",
|
"startParam": "Параметр",
|
||||||
@@ -3773,6 +3786,7 @@
|
|||||||
"system": "Система",
|
"system": "Система",
|
||||||
"permanent": "Бессрочно",
|
"permanent": "Бессрочно",
|
||||||
"expired": "истекло",
|
"expired": "истекло",
|
||||||
|
"envManaged": "Управляется через ADMIN_IDS / ADMIN_EMAILS в env",
|
||||||
"revoke": "Отозвать",
|
"revoke": "Отозвать",
|
||||||
"revokeAriaLabel": "Отозвать роль {{role}} у {{user}}",
|
"revokeAriaLabel": "Отозвать роль {{role}} у {{user}}",
|
||||||
"table": {
|
"table": {
|
||||||
|
|||||||
@@ -1081,17 +1081,24 @@
|
|||||||
"totalUsers": "用户",
|
"totalUsers": "用户",
|
||||||
"totalReferrers": "推荐人",
|
"totalReferrers": "推荐人",
|
||||||
"totalCampaigns": "活动",
|
"totalCampaigns": "活动",
|
||||||
"totalEarnings": "总收入"
|
"subscriptionRevenue": "订阅收入",
|
||||||
|
"totalEarnings": "推荐收入"
|
||||||
},
|
},
|
||||||
"legend": {
|
"legend": {
|
||||||
"title": "图例",
|
"title": "图例",
|
||||||
"regularUser": "普通用户",
|
"fillTitle": "订阅状态",
|
||||||
|
"borderTitle": "推荐角色",
|
||||||
|
"regularUser": "无订阅",
|
||||||
"activeReferrer": "活跃推荐人",
|
"activeReferrer": "活跃推荐人",
|
||||||
"partner": "合作伙伴",
|
"partner": "合作伙伴",
|
||||||
"topReferrer": "顶级推荐人 (10+)",
|
"topReferrer": "顶级推荐人 (10+)",
|
||||||
"campaignUser": "来自活动",
|
"campaignUser": "来自活动",
|
||||||
"partnerCampaignEdge": "合作伙伴 → 活动",
|
"partnerCampaignEdge": "合作伙伴 → 活动",
|
||||||
"campaignNode": "广告活动"
|
"campaignNode": "广告活动",
|
||||||
|
"paidActive": "付费订阅",
|
||||||
|
"trialActive": "试用",
|
||||||
|
"paidExpired": "付费已过期",
|
||||||
|
"trialExpired": "试用已过期"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"tgId": "TG ID",
|
"tgId": "TG ID",
|
||||||
@@ -1110,7 +1117,13 @@
|
|||||||
"source": "来源",
|
"source": "来源",
|
||||||
"referredBy": "由...推荐",
|
"referredBy": "由...推荐",
|
||||||
"fromCampaign": "活动",
|
"fromCampaign": "活动",
|
||||||
"organic": "自然流量"
|
"organic": "自然流量",
|
||||||
|
"subscriptionStatus": {
|
||||||
|
"trial_active": "试用",
|
||||||
|
"paid_active": "付费",
|
||||||
|
"trial_expired": "试用已过期",
|
||||||
|
"paid_expired": "付费已过期"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"campaign": {
|
"campaign": {
|
||||||
"startParam": "参数",
|
"startParam": "参数",
|
||||||
@@ -2967,6 +2980,7 @@
|
|||||||
"system": "系统",
|
"system": "系统",
|
||||||
"permanent": "永久",
|
"permanent": "永久",
|
||||||
"expired": "已过期",
|
"expired": "已过期",
|
||||||
|
"envManaged": "通过 ADMIN_IDS / ADMIN_EMAILS 环境变量管理",
|
||||||
"revoke": "撤销",
|
"revoke": "撤销",
|
||||||
"revokeAriaLabel": "撤销 {{user}} 的角色 {{role}}",
|
"revokeAriaLabel": "撤销 {{user}} 的角色 {{role}}",
|
||||||
"table": {
|
"table": {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const UserPlusIcon = () => (
|
|||||||
// === Constants ===
|
// === Constants ===
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
const SUPERADMIN_LEVEL = 999;
|
||||||
|
|
||||||
// === Sub-components ===
|
// === Sub-components ===
|
||||||
|
|
||||||
@@ -278,10 +279,10 @@ export default function AdminRoleAssign() {
|
|||||||
enabled: !!roles && roles.length > 0,
|
enabled: !!roles && roles.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Available roles filtered by canManageRole
|
// Available roles filtered by canManageRole (superadmin excluded — env-only)
|
||||||
const assignableRoles = useMemo(() => {
|
const assignableRoles = useMemo(() => {
|
||||||
if (!roles) return [];
|
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, canManageRole]);
|
||||||
|
|
||||||
// Roles map for quick lookup
|
// Roles map for quick lookup
|
||||||
@@ -627,20 +628,29 @@ export default function AdminRoleAssign() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="col-span-1 flex justify-end sm:justify-start">
|
<div className="col-span-1 flex justify-end sm:justify-start">
|
||||||
<PermissionGate permission="roles:assign">
|
{assignment.role_level >= SUPERADMIN_LEVEL ? (
|
||||||
<button
|
<span
|
||||||
onClick={() => setRevokeConfirm(assignment.id)}
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium text-dark-500"
|
||||||
disabled={!canRevoke}
|
title={t('admin.roleAssign.envManaged')}
|
||||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
title={t('admin.roleAssign.revoke')}
|
|
||||||
aria-label={t('admin.roleAssign.revokeAriaLabel', {
|
|
||||||
user: assignment.user_first_name || assignment.user_id,
|
|
||||||
role: assignment.role_name,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<XCircleIcon />
|
ENV
|
||||||
</button>
|
</span>
|
||||||
</PermissionGate>
|
) : (
|
||||||
|
<PermissionGate permission="roles:assign">
|
||||||
|
<button
|
||||||
|
onClick={() => setRevokeConfirm(assignment.id)}
|
||||||
|
disabled={!canRevoke}
|
||||||
|
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title={t('admin.roleAssign.revoke')}
|
||||||
|
aria-label={t('admin.roleAssign.revokeAriaLabel', {
|
||||||
|
user: assignment.user_first_name || assignment.user_id,
|
||||||
|
role: assignment.role_name,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<XCircleIcon />
|
||||||
|
</button>
|
||||||
|
</PermissionGate>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker';
|
|||||||
import { inferSettings } from 'graphology-layout-forceatlas2';
|
import { inferSettings } from 'graphology-layout-forceatlas2';
|
||||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||||
import type { NetworkGraphData, NetworkFilters } from '@/types/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';
|
import { setSigmaInstance } from '../sigmaGlobals';
|
||||||
|
|
||||||
interface NetworkGraphProps {
|
interface NetworkGraphProps {
|
||||||
@@ -13,38 +20,177 @@ interface NetworkGraphProps {
|
|||||||
className?: string;
|
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<string, { x: number; y: number }> {
|
||||||
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
|
||||||
|
// Build adjacency: referrer_id -> [user_ids]
|
||||||
|
const childrenOf = new Map<number, number[]>();
|
||||||
|
const userMap = new Map<number, (typeof graphData.users)[0]>();
|
||||||
|
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<number>();
|
||||||
|
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 {
|
function buildFullGraph(graphData: NetworkGraphData): Graph {
|
||||||
const graph = new Graph();
|
const graph = new Graph();
|
||||||
|
const positions = computeRadialPositions(graphData);
|
||||||
|
|
||||||
graphData.campaigns.forEach((campaign, index) => {
|
graphData.campaigns.forEach((campaign, index) => {
|
||||||
graph.addNode(`campaign_${campaign.id}`, {
|
const pos = positions.get(`campaign_${campaign.id}`) ?? {
|
||||||
label: campaign.name,
|
|
||||||
x: Math.random() * 100,
|
x: Math.random() * 100,
|
||||||
y: Math.random() * 100,
|
y: Math.random() * 100,
|
||||||
|
};
|
||||||
|
graph.addNode(`campaign_${campaign.id}`, {
|
||||||
|
label: campaign.name,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: getCampaignColor(index),
|
color: getCampaignColor(index),
|
||||||
type: 'circle',
|
type: 'bordered',
|
||||||
nodeType: 'campaign',
|
nodeType: 'campaign',
|
||||||
nodeId: campaign.id,
|
nodeId: campaign.id,
|
||||||
|
borderSize: 0,
|
||||||
|
borderColor: getCampaignColor(index),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
graphData.users.forEach((user) => {
|
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 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}`, {
|
graph.addNode(`user_${user.id}`, {
|
||||||
label: user.display_name,
|
label: user.display_name,
|
||||||
x: Math.random() * 100,
|
x: pos.x,
|
||||||
y: Math.random() * 100,
|
y: pos.y,
|
||||||
size,
|
size,
|
||||||
color,
|
color: fillColor,
|
||||||
type: 'circle',
|
type: 'bordered',
|
||||||
nodeType: 'user',
|
nodeType: 'user',
|
||||||
nodeId: user.id,
|
nodeId: user.id,
|
||||||
isPartner: user.is_partner,
|
isPartner: user.is_partner,
|
||||||
directReferrals: user.direct_referrals,
|
directReferrals: user.direct_referrals,
|
||||||
campaignId: user.campaign_id,
|
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'
|
edge.type === 'partner_campaign'
|
||||||
? 'rgba(255, 138, 101, 0.5)'
|
? 'rgba(255, 138, 101, 0.5)'
|
||||||
: edge.type === 'campaign'
|
: edge.type === 'campaign'
|
||||||
? 'rgba(77, 217, 192, 0.06)'
|
? 'rgba(255, 255, 255, 0.06)'
|
||||||
: 'rgba(255,255,255,0.03)',
|
: 'rgba(255,255,255,0.03)',
|
||||||
size: edge.type === 'partner_campaign' ? 1.5 : 0.3,
|
size: edge.type === 'partner_campaign' ? 1.5 : 0.3,
|
||||||
edgeType: edge.type,
|
edgeType: edge.type,
|
||||||
@@ -193,8 +339,10 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
|||||||
labelRenderedSizeThreshold: 14,
|
labelRenderedSizeThreshold: 14,
|
||||||
zIndex: true,
|
zIndex: true,
|
||||||
defaultEdgeColor: '#ffffff06',
|
defaultEdgeColor: '#ffffff06',
|
||||||
defaultNodeColor: '#6b7280',
|
defaultNodeColor: NODE_COLORS.regular,
|
||||||
labelColor: { color: '#e8e6f0' },
|
nodeProgramClasses: { bordered: BorderedNodeProgram },
|
||||||
|
defaultNodeType: 'bordered',
|
||||||
|
labelColor: { attribute: 'labelColor', color: '#e5e7eb' },
|
||||||
labelFont: 'Inter, system-ui, sans-serif',
|
labelFont: 'Inter, system-ui, sans-serif',
|
||||||
labelSize: 11,
|
labelSize: 11,
|
||||||
stagePadding: 40,
|
stagePadding: 40,
|
||||||
@@ -213,6 +361,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
|||||||
if (highlighted.size > 0) {
|
if (highlighted.size > 0) {
|
||||||
if (highlighted.has(node)) {
|
if (highlighted.has(node)) {
|
||||||
res.highlighted = true;
|
res.highlighted = true;
|
||||||
|
res.labelColor = '#111827';
|
||||||
res.zIndex = 2;
|
res.zIndex = 2;
|
||||||
} else {
|
} else {
|
||||||
res.color = `${attrs.color}33`;
|
res.color = `${attrs.color}33`;
|
||||||
@@ -224,9 +373,11 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
|||||||
if (hovered) {
|
if (hovered) {
|
||||||
if (node === hovered) {
|
if (node === hovered) {
|
||||||
res.highlighted = true;
|
res.highlighted = true;
|
||||||
|
res.labelColor = '#111827';
|
||||||
res.zIndex = 2;
|
res.zIndex = 2;
|
||||||
} else if (graph.hasNode(hovered) && graph.areNeighbors(node, hovered)) {
|
} else if (graph.hasNode(hovered) && graph.areNeighbors(node, hovered)) {
|
||||||
res.highlighted = true;
|
res.highlighted = true;
|
||||||
|
res.labelColor = '#111827';
|
||||||
res.zIndex = 1;
|
res.zIndex = 1;
|
||||||
} else if (!highlighted.has(node)) {
|
} else if (!highlighted.has(node)) {
|
||||||
res.color = `${attrs.color}33`;
|
res.color = `${attrs.color}33`;
|
||||||
@@ -278,8 +429,10 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
|||||||
linLogMode: true,
|
linLogMode: true,
|
||||||
adjustSizes: true,
|
adjustSizes: true,
|
||||||
barnesHutOptimize: graph.order > 100,
|
barnesHutOptimize: graph.order > 100,
|
||||||
gravity: 1,
|
gravity: 0.05,
|
||||||
slowDown: 2,
|
scalingRatio: 10,
|
||||||
|
slowDown: 5,
|
||||||
|
strongGravityMode: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
fa2Ref.current = supervisor;
|
fa2Ref.current = supervisor;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { NODE_COLORS } from '../utils';
|
||||||
|
|
||||||
interface NetworkLegendProps {
|
interface NetworkLegendProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -6,12 +7,15 @@ interface NetworkLegendProps {
|
|||||||
|
|
||||||
const CAMPAIGN_GRADIENT_COLORS = ['#4dd9c0', '#f0c261', '#e85d9a', '#6b9fff', '#b97aff'];
|
const CAMPAIGN_GRADIENT_COLORS = ['#4dd9c0', '#f0c261', '#e85d9a', '#6b9fff', '#b97aff'];
|
||||||
|
|
||||||
const USER_LEGEND_ITEMS = [
|
const FILL_ITEMS = [
|
||||||
{ colorKey: '#6b7280', labelKey: 'admin.referralNetwork.legend.regularUser' },
|
{ color: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' },
|
||||||
{ colorKey: '#7c6aef', labelKey: 'admin.referralNetwork.legend.activeReferrer' },
|
{ color: NODE_COLORS.trialActive, labelKey: 'admin.referralNetwork.legend.trialActive' },
|
||||||
{ colorKey: '#f0c261', labelKey: 'admin.referralNetwork.legend.partner' },
|
{ color: NODE_COLORS.paidExpired, labelKey: 'admin.referralNetwork.legend.paidExpired' },
|
||||||
{ colorKey: '#e85d9a', labelKey: 'admin.referralNetwork.legend.topReferrer' },
|
{ color: NODE_COLORS.trialExpired, labelKey: 'admin.referralNetwork.legend.trialExpired' },
|
||||||
{ colorKey: '#4dd9c0', labelKey: 'admin.referralNetwork.legend.campaignUser' },
|
{ 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) {
|
export function NetworkLegend({ className }: NetworkLegendProps) {
|
||||||
@@ -25,27 +29,30 @@ export function NetworkLegend({ className }: NetworkLegendProps) {
|
|||||||
<div
|
<div
|
||||||
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-3 backdrop-blur-md ${className ?? ''}`}
|
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-3 backdrop-blur-md ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
<h4 className="mb-2 text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
{/* Fill = Subscription status */}
|
||||||
{t('admin.referralNetwork.legend.title')}
|
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.legend.fillTitle')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
{USER_LEGEND_ITEMS.map((item) => (
|
{FILL_ITEMS.map((item) => (
|
||||||
<div key={item.labelKey} className="flex items-center gap-2">
|
<div key={item.labelKey} className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: item.colorKey }}
|
style={{ backgroundColor: item.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-dark-300">{t(item.labelKey)}</span>
|
<span className="text-xs text-dark-300">{t(item.labelKey)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Partner → Campaign edge */}
|
</div>
|
||||||
|
|
||||||
|
{/* Edges */}
|
||||||
|
<div className="mt-2.5 space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-0.5 w-4 shrink-0 rounded-full" style={{ backgroundColor: '#ff8a65' }} />
|
<div className="h-0.5 w-4 shrink-0 rounded-full" style={{ backgroundColor: '#ff8a65' }} />
|
||||||
<span className="text-xs text-dark-300">
|
<span className="text-xs text-dark-300">
|
||||||
{t('admin.referralNetwork.legend.partnerCampaignEdge')}
|
{t('admin.referralNetwork.legend.partnerCampaignEdge')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Campaign node with gradient to represent varying colors */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-2.5 w-2.5 shrink-0 rounded-full" style={gradientStyle} />
|
<div className="h-2.5 w-2.5 shrink-0 rounded-full" style={gradientStyle} />
|
||||||
<span className="text-xs text-dark-300">
|
<span className="text-xs text-dark-300">
|
||||||
|
|||||||
@@ -10,38 +10,51 @@ interface NetworkStatsProps {
|
|||||||
export function NetworkStats({ data, className }: NetworkStatsProps) {
|
export function NetworkStats({ data, className }: NetworkStatsProps) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-2 backdrop-blur-md sm:p-3 ${className ?? ''}`}
|
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-2 backdrop-blur-md sm:p-3 ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 sm:gap-3">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:gap-x-6 sm:gap-y-2">
|
||||||
{stats.map((stat) => (
|
<div>
|
||||||
<div key={stat.label}>
|
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
{t('admin.referralNetwork.stats.totalUsers')}
|
||||||
{stat.label}
|
</p>
|
||||||
</p>
|
<p className="font-mono text-sm font-semibold text-dark-100">
|
||||||
<p className="font-mono text-sm font-semibold text-dark-100">{stat.value}</p>
|
{data.total_users.toLocaleString()}
|
||||||
</div>
|
</p>
|
||||||
))}
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.stats.totalReferrers')}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-sm font-semibold text-dark-100">
|
||||||
|
{data.total_referrers.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.stats.totalCampaigns')}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-sm font-semibold text-dark-100">
|
||||||
|
{data.total_campaigns.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.stats.subscriptionRevenue')}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-sm font-semibold text-accent-400">
|
||||||
|
{formatKopeksToRubles(data.total_subscription_revenue_kopeks)} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 border-t border-dark-700/30 pt-1.5">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.stats.totalEarnings')}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-sm font-semibold text-dark-100">
|
||||||
|
{formatKopeksToRubles(data.total_earnings_kopeks)} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { referralNetworkApi } from '@/api/referralNetwork';
|
import { referralNetworkApi } from '@/api/referralNetwork';
|
||||||
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||||
import { formatKopeksToRubles } from '../utils';
|
import { formatKopeksToRubles, getSubscriptionStatusColor } from '../utils';
|
||||||
|
|
||||||
interface UserDetailPanelProps {
|
interface UserDetailPanelProps {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -101,7 +101,24 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
|
|||||||
</h4>
|
</h4>
|
||||||
{user.subscription_name ? (
|
{user.subscription_name ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-dark-100">{user.subscription_name}</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-dark-100">{user.subscription_name}</p>
|
||||||
|
{user.subscription_status && (
|
||||||
|
<span className="flex items-center gap-1.5 rounded-full bg-dark-700/50 px-2 py-0.5">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getSubscriptionStatusColor(user.subscription_status),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-medium text-dark-300">
|
||||||
|
{t(
|
||||||
|
`admin.referralNetwork.user.subscriptionStatus.${user.subscription_status}`,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{user.subscription_end && (
|
{user.subscription_end && (
|
||||||
<p className="mt-0.5 text-xs text-dark-400">
|
<p className="mt-0.5 text-xs text-dark-400">
|
||||||
{t('admin.referralNetwork.user.validUntil', {
|
{t('admin.referralNetwork.user.validUntil', {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SubscriptionStatus } from '@/types/referralNetwork';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format kopeks to a human-readable ruble string.
|
* Format kopeks to a human-readable ruble string.
|
||||||
*/
|
*/
|
||||||
@@ -5,6 +7,63 @@ export function formatKopeksToRubles(kopeks: number): string {
|
|||||||
return `${(kopeks / 100).toLocaleString('ru-RU')}`;
|
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<SubscriptionStatus, string> = {
|
||||||
|
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
|
* Campaign node color palette. Each campaign gets a distinct color
|
||||||
* based on its index position.
|
* based on its index position.
|
||||||
@@ -26,21 +85,6 @@ export function getCampaignColor(index: number): string {
|
|||||||
return CAMPAIGN_COLORS[index % CAMPAIGN_COLORS.length];
|
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.
|
* Determine the visual size for a user node.
|
||||||
* Size is proportional to direct_referrals, clamped between min and max.
|
* Size is proportional to direct_referrals, clamped between min and max.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type SubscriptionStatus = 'trial_active' | 'paid_active' | 'trial_expired' | 'paid_expired';
|
||||||
|
|
||||||
export interface NetworkUserNode {
|
export interface NetworkUserNode {
|
||||||
id: number;
|
id: number;
|
||||||
tg_id: number | null;
|
tg_id: number | null;
|
||||||
@@ -14,6 +16,7 @@ export interface NetworkUserNode {
|
|||||||
personal_spent_kopeks: number;
|
personal_spent_kopeks: number;
|
||||||
subscription_name: string | null;
|
subscription_name: string | null;
|
||||||
subscription_end: string | null;
|
subscription_end: string | null;
|
||||||
|
subscription_status: SubscriptionStatus | null;
|
||||||
registered_at: string | null;
|
registered_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ export interface NetworkGraphData {
|
|||||||
total_referrers: number;
|
total_referrers: number;
|
||||||
total_campaigns: number;
|
total_campaigns: number;
|
||||||
total_earnings_kopeks: number;
|
total_earnings_kopeks: number;
|
||||||
|
total_subscription_revenue_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkUserDetail {
|
export interface NetworkUserDetail {
|
||||||
@@ -68,6 +72,7 @@ export interface NetworkUserDetail {
|
|||||||
personal_spent_kopeks: number;
|
personal_spent_kopeks: number;
|
||||||
subscription_name: string | null;
|
subscription_name: string | null;
|
||||||
subscription_end: string | null;
|
subscription_end: string | null;
|
||||||
|
subscription_status: SubscriptionStatus | null;
|
||||||
registered_at: string | null;
|
registered_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user