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.
This commit is contained in:
Fringg
2026-03-22 09:07:32 +03:00
parent 8e59af96c5
commit b289ea9c23
9 changed files with 134 additions and 22 deletions

View File

@@ -1257,7 +1257,11 @@
"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 +1280,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",

View File

@@ -1091,7 +1091,11 @@
"topReferrer": "ارجاع‌دهنده برتر (+۱۰)", "topReferrer": "ارجاع‌دهنده برتر (+۱۰)",
"campaignUser": "از کمپین", "campaignUser": "از کمپین",
"partnerCampaignEdge": "شریک → کمپین", "partnerCampaignEdge": "شریک → کمپین",
"campaignNode": "کمپین تبلیغاتی" "campaignNode": "کمپین تبلیغاتی",
"paidActive": "اشتراک پرداختی",
"trialActive": "آزمایشی",
"paidExpired": "اشتراک پرداختی منقضی",
"trialExpired": "آزمایشی منقضی"
}, },
"user": { "user": {
"tgId": "شناسه TG", "tgId": "شناسه TG",
@@ -1110,7 +1114,13 @@
"source": "منبع", "source": "منبع",
"referredBy": "معرفی شده توسط", "referredBy": "معرفی شده توسط",
"fromCampaign": "کمپین", "fromCampaign": "کمپین",
"organic": "طبیعی" "organic": "طبیعی",
"subscriptionStatus": {
"trial_active": "آزمایشی",
"paid_active": "پرداختی",
"trial_expired": "آزمایشی منقضی",
"paid_expired": "پرداختی منقضی"
}
}, },
"campaign": { "campaign": {
"startParam": "پارامتر", "startParam": "پارامتر",

View File

@@ -1278,7 +1278,11 @@
"topReferrer": "Топ-реферер (10+)", "topReferrer": "Топ-реферер (10+)",
"campaignUser": "Из кампании", "campaignUser": "Из кампании",
"partnerCampaignEdge": "Партнёр → РК", "partnerCampaignEdge": "Партнёр → РК",
"campaignNode": "Рекламная кампания" "campaignNode": "Рекламная кампания",
"paidActive": "Платная подписка",
"trialActive": "Триал",
"paidExpired": "Платная истекла",
"trialExpired": "Триал истёк"
}, },
"user": { "user": {
"tgId": "TG ID", "tgId": "TG ID",
@@ -1297,7 +1301,13 @@
"source": "Источник", "source": "Источник",
"referredBy": "Пришёл от", "referredBy": "Пришёл от",
"fromCampaign": "РК", "fromCampaign": "РК",
"organic": "Органический" "organic": "Органический",
"subscriptionStatus": {
"trial_active": "Триал",
"paid_active": "Платная",
"trial_expired": "Триал истёк",
"paid_expired": "Платная истекла"
}
}, },
"campaign": { "campaign": {
"startParam": "Параметр", "startParam": "Параметр",

View File

@@ -1091,7 +1091,11 @@
"topReferrer": "顶级推荐人 (10+)", "topReferrer": "顶级推荐人 (10+)",
"campaignUser": "来自活动", "campaignUser": "来自活动",
"partnerCampaignEdge": "合作伙伴 → 活动", "partnerCampaignEdge": "合作伙伴 → 活动",
"campaignNode": "广告活动" "campaignNode": "广告活动",
"paidActive": "付费订阅",
"trialActive": "试用",
"paidExpired": "付费已过期",
"trialExpired": "试用已过期"
}, },
"user": { "user": {
"tgId": "TG ID", "tgId": "TG ID",
@@ -1110,7 +1114,13 @@
"source": "来源", "source": "来源",
"referredBy": "由...推荐", "referredBy": "由...推荐",
"fromCampaign": "活动", "fromCampaign": "活动",
"organic": "自然流量" "organic": "自然流量",
"subscriptionStatus": {
"trial_active": "试用",
"paid_active": "付费",
"trial_expired": "试用已过期",
"paid_expired": "付费已过期"
}
}, },
"campaign": { "campaign": {
"startParam": "参数", "startParam": "参数",

View File

@@ -30,7 +30,12 @@ function buildFullGraph(graphData: NetworkGraphData): Graph {
}); });
graphData.users.forEach((user) => { 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); const size = getUserNodeSize(user.direct_referrals);
graph.addNode(`user_${user.id}`, { graph.addNode(`user_${user.id}`, {
@@ -45,6 +50,7 @@ function buildFullGraph(graphData: NetworkGraphData): Graph {
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,
}); });
}); });

View File

@@ -1,17 +1,28 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { NODE_COLORS } from '../utils';
interface NetworkLegendProps { interface NetworkLegendProps {
className?: string; 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 = [ const USER_LEGEND_ITEMS = [
{ colorKey: '#6b7280', labelKey: 'admin.referralNetwork.legend.regularUser' }, { colorKey: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' },
{ colorKey: '#7c6aef', labelKey: 'admin.referralNetwork.legend.activeReferrer' }, { colorKey: NODE_COLORS.activeReferrer, labelKey: 'admin.referralNetwork.legend.activeReferrer' },
{ colorKey: '#f0c261', labelKey: 'admin.referralNetwork.legend.partner' }, { colorKey: NODE_COLORS.partner, labelKey: 'admin.referralNetwork.legend.partner' },
{ colorKey: '#e85d9a', labelKey: 'admin.referralNetwork.legend.topReferrer' }, { colorKey: NODE_COLORS.topReferrer, labelKey: 'admin.referralNetwork.legend.topReferrer' },
{ colorKey: '#4dd9c0', labelKey: 'admin.referralNetwork.legend.campaignUser' }, { 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) { export function NetworkLegend({ className }: NetworkLegendProps) {

View File

@@ -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', {

View File

@@ -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,35 @@ 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: '#4dd9c0',
regular: '#6b7280',
} 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];
}
/** /**
* 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.
@@ -28,17 +59,20 @@ export function getCampaignColor(index: number): string {
/** /**
* Determine the visual color for a user node. * Determine the visual color for a user node.
* Priority: partner > top referrer > active referrer > subscription status > campaign > regular.
*/ */
export function getUserNodeColor( export function getUserNodeColor(
directReferrals: number, directReferrals: number,
isPartner: boolean, isPartner: boolean,
campaignId: number | null, campaignId: number | null,
subscriptionStatus: SubscriptionStatus | null,
): string { ): string {
if (isPartner) return '#f0c261'; if (isPartner) return NODE_COLORS.partner;
if (directReferrals >= 10) return '#e85d9a'; if (directReferrals >= 10) return NODE_COLORS.topReferrer;
if (directReferrals >= 1) return '#7c6aef'; if (directReferrals >= 1) return NODE_COLORS.activeReferrer;
if (campaignId !== null) return '#4dd9c0'; if (subscriptionStatus) return SUBSCRIPTION_STATUS_COLOR[subscriptionStatus];
return '#6b7280'; if (campaignId !== null) return NODE_COLORS.campaignUser;
return NODE_COLORS.regular;
} }
/** /**

View File

@@ -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;
} }
@@ -68,6 +71,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;
} }