diff --git a/src/locales/en.json b/src/locales/en.json index fc992cc..1d68520 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1257,7 +1257,11 @@ "topReferrer": "Top referrer (10+)", "campaignUser": "From campaign", "partnerCampaignEdge": "Partner → Campaign", - "campaignNode": "Ad campaign" + "campaignNode": "Ad campaign", + "paidActive": "Paid subscription", + "trialActive": "Trial", + "paidExpired": "Paid expired", + "trialExpired": "Trial expired" }, "user": { "tgId": "TG ID", @@ -1276,7 +1280,13 @@ "source": "Source", "referredBy": "Referred by", "fromCampaign": "Campaign", - "organic": "Organic" + "organic": "Organic", + "subscriptionStatus": { + "trial_active": "Trial", + "paid_active": "Paid", + "trial_expired": "Trial expired", + "paid_expired": "Paid expired" + } }, "campaign": { "startParam": "Parameter", diff --git a/src/locales/fa.json b/src/locales/fa.json index 3bb8ee0..87854ae 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1091,7 +1091,11 @@ "topReferrer": "ارجاع‌دهنده برتر (+۱۰)", "campaignUser": "از کمپین", "partnerCampaignEdge": "شریک → کمپین", - "campaignNode": "کمپین تبلیغاتی" + "campaignNode": "کمپین تبلیغاتی", + "paidActive": "اشتراک پرداختی", + "trialActive": "آزمایشی", + "paidExpired": "اشتراک پرداختی منقضی", + "trialExpired": "آزمایشی منقضی" }, "user": { "tgId": "شناسه TG", @@ -1110,7 +1114,13 @@ "source": "منبع", "referredBy": "معرفی شده توسط", "fromCampaign": "کمپین", - "organic": "طبیعی" + "organic": "طبیعی", + "subscriptionStatus": { + "trial_active": "آزمایشی", + "paid_active": "پرداختی", + "trial_expired": "آزمایشی منقضی", + "paid_expired": "پرداختی منقضی" + } }, "campaign": { "startParam": "پارامتر", diff --git a/src/locales/ru.json b/src/locales/ru.json index 9fe64e0..46967d8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1278,7 +1278,11 @@ "topReferrer": "Топ-реферер (10+)", "campaignUser": "Из кампании", "partnerCampaignEdge": "Партнёр → РК", - "campaignNode": "Рекламная кампания" + "campaignNode": "Рекламная кампания", + "paidActive": "Платная подписка", + "trialActive": "Триал", + "paidExpired": "Платная истекла", + "trialExpired": "Триал истёк" }, "user": { "tgId": "TG ID", @@ -1297,7 +1301,13 @@ "source": "Источник", "referredBy": "Пришёл от", "fromCampaign": "РК", - "organic": "Органический" + "organic": "Органический", + "subscriptionStatus": { + "trial_active": "Триал", + "paid_active": "Платная", + "trial_expired": "Триал истёк", + "paid_expired": "Платная истекла" + } }, "campaign": { "startParam": "Параметр", diff --git a/src/locales/zh.json b/src/locales/zh.json index d0b045a..068e449 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1091,7 +1091,11 @@ "topReferrer": "顶级推荐人 (10+)", "campaignUser": "来自活动", "partnerCampaignEdge": "合作伙伴 → 活动", - "campaignNode": "广告活动" + "campaignNode": "广告活动", + "paidActive": "付费订阅", + "trialActive": "试用", + "paidExpired": "付费已过期", + "trialExpired": "试用已过期" }, "user": { "tgId": "TG ID", @@ -1110,7 +1114,13 @@ "source": "来源", "referredBy": "由...推荐", "fromCampaign": "活动", - "organic": "自然流量" + "organic": "自然流量", + "subscriptionStatus": { + "trial_active": "试用", + "paid_active": "付费", + "trial_expired": "试用已过期", + "paid_expired": "付费已过期" + } }, "campaign": { "startParam": "参数", diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 1671e64..0adce54 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -30,7 +30,12 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { }); graphData.users.forEach((user) => { - const color = getUserNodeColor(user.direct_referrals, user.is_partner, user.campaign_id); + const color = getUserNodeColor( + user.direct_referrals, + user.is_partner, + user.campaign_id, + user.subscription_status, + ); const size = getUserNodeSize(user.direct_referrals); graph.addNode(`user_${user.id}`, { @@ -45,6 +50,7 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { isPartner: user.is_partner, directReferrals: user.direct_referrals, campaignId: user.campaign_id, + subscriptionStatus: user.subscription_status, }); }); diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx index 4bd766a..af622f8 100644 --- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -1,17 +1,28 @@ import { useTranslation } from 'react-i18next'; +import { NODE_COLORS } from '../utils'; interface NetworkLegendProps { className?: string; } -const CAMPAIGN_GRADIENT_COLORS = ['#4dd9c0', '#f0c261', '#e85d9a', '#6b9fff', '#b97aff']; +const CAMPAIGN_GRADIENT_COLORS = [ + NODE_COLORS.campaignUser, + NODE_COLORS.partner, + NODE_COLORS.topReferrer, + '#6b9fff', + '#b97aff', +]; const USER_LEGEND_ITEMS = [ - { colorKey: '#6b7280', labelKey: 'admin.referralNetwork.legend.regularUser' }, - { colorKey: '#7c6aef', labelKey: 'admin.referralNetwork.legend.activeReferrer' }, - { colorKey: '#f0c261', labelKey: 'admin.referralNetwork.legend.partner' }, - { colorKey: '#e85d9a', labelKey: 'admin.referralNetwork.legend.topReferrer' }, - { colorKey: '#4dd9c0', labelKey: 'admin.referralNetwork.legend.campaignUser' }, + { colorKey: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' }, + { colorKey: NODE_COLORS.activeReferrer, labelKey: 'admin.referralNetwork.legend.activeReferrer' }, + { colorKey: NODE_COLORS.partner, labelKey: 'admin.referralNetwork.legend.partner' }, + { colorKey: NODE_COLORS.topReferrer, labelKey: 'admin.referralNetwork.legend.topReferrer' }, + { colorKey: NODE_COLORS.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' }, + { colorKey: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' }, + { colorKey: NODE_COLORS.trialActive, labelKey: 'admin.referralNetwork.legend.trialActive' }, + { colorKey: NODE_COLORS.paidExpired, labelKey: 'admin.referralNetwork.legend.paidExpired' }, + { colorKey: NODE_COLORS.trialExpired, labelKey: 'admin.referralNetwork.legend.trialExpired' }, ]; export function NetworkLegend({ className }: NetworkLegendProps) { diff --git a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx index 653a4ed..0b626d7 100644 --- a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx +++ b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { referralNetworkApi } from '@/api/referralNetwork'; import { useReferralNetworkStore } from '@/store/referralNetwork'; -import { formatKopeksToRubles } from '../utils'; +import { formatKopeksToRubles, getSubscriptionStatusColor } from '../utils'; interface UserDetailPanelProps { userId: number; @@ -101,7 +101,24 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) { {user.subscription_name ? (
-

{user.subscription_name}

+
+

{user.subscription_name}

+ {user.subscription_status && ( + + + + {t( + `admin.referralNetwork.user.subscriptionStatus.${user.subscription_status}`, + )} + + + )} +
{user.subscription_end && (

{t('admin.referralNetwork.user.validUntil', { diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index f1a89ae..5af8ddf 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -1,3 +1,5 @@ +import type { SubscriptionStatus } from '@/types/referralNetwork'; + /** * Format kopeks to a human-readable ruble string. */ @@ -5,6 +7,35 @@ export function formatKopeksToRubles(kopeks: number): string { return `${(kopeks / 100).toLocaleString('ru-RU')}`; } +/** + * Single source of truth for user node colors. + */ +export const NODE_COLORS = { + partner: '#f0c261', + topReferrer: '#e85d9a', + activeReferrer: '#7c6aef', + paidActive: '#22c55e', + trialActive: '#38bdf8', + paidExpired: '#ef4444', + trialExpired: '#fb923c', + campaignUser: '#4dd9c0', + regular: '#6b7280', +} as const; + +/** + * Map subscription status to its node color. + */ +const SUBSCRIPTION_STATUS_COLOR: Record = { + paid_active: NODE_COLORS.paidActive, + trial_active: NODE_COLORS.trialActive, + paid_expired: NODE_COLORS.paidExpired, + trial_expired: NODE_COLORS.trialExpired, +}; + +export function getSubscriptionStatusColor(status: SubscriptionStatus): string { + return SUBSCRIPTION_STATUS_COLOR[status]; +} + /** * Campaign node color palette. Each campaign gets a distinct color * based on its index position. @@ -28,17 +59,20 @@ export function getCampaignColor(index: number): string { /** * Determine the visual color for a user node. + * Priority: partner > top referrer > active referrer > subscription status > campaign > regular. */ export function getUserNodeColor( directReferrals: number, isPartner: boolean, campaignId: number | null, + subscriptionStatus: SubscriptionStatus | null, ): string { - if (isPartner) return '#f0c261'; - if (directReferrals >= 10) return '#e85d9a'; - if (directReferrals >= 1) return '#7c6aef'; - if (campaignId !== null) return '#4dd9c0'; - return '#6b7280'; + if (isPartner) return NODE_COLORS.partner; + if (directReferrals >= 10) return NODE_COLORS.topReferrer; + if (directReferrals >= 1) return NODE_COLORS.activeReferrer; + if (subscriptionStatus) return SUBSCRIPTION_STATUS_COLOR[subscriptionStatus]; + if (campaignId !== null) return NODE_COLORS.campaignUser; + return NODE_COLORS.regular; } /** diff --git a/src/types/referralNetwork.ts b/src/types/referralNetwork.ts index 10802b4..7456103 100644 --- a/src/types/referralNetwork.ts +++ b/src/types/referralNetwork.ts @@ -1,3 +1,5 @@ +export type SubscriptionStatus = 'trial_active' | 'paid_active' | 'trial_expired' | 'paid_expired'; + export interface NetworkUserNode { id: number; tg_id: number | null; @@ -14,6 +16,7 @@ export interface NetworkUserNode { personal_spent_kopeks: number; subscription_name: string | null; subscription_end: string | null; + subscription_status: SubscriptionStatus | null; registered_at: string | null; } @@ -68,6 +71,7 @@ export interface NetworkUserDetail { personal_spent_kopeks: number; subscription_name: string | null; subscription_end: string | null; + subscription_status: SubscriptionStatus | null; registered_at: string | null; }