mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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": "پارامتر",
|
||||||
|
|||||||
@@ -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": "Параметр",
|
||||||
|
|||||||
@@ -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": "参数",
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user