feat: add referral network graph visualization page

Interactive Sigma.js + graphology graph with ForceAtlas2 web worker
layout, campaign/user node coloring, filters via nodeReducer pattern,
search highlighting, user/campaign detail side panels, and 4-locale
translations. Dependencies: sigma v3, graphology, graphology-layout-forceatlas2.
This commit is contained in:
Fringg
2026-03-19 07:56:01 +03:00
parent 84f0e4e9b4
commit 235eaec85f
23 changed files with 2126 additions and 8 deletions

View File

@@ -0,0 +1,164 @@
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';
interface CampaignDetailPanelProps {
campaignId: number;
className?: string;
}
export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPanelProps) {
const { t } = useTranslation();
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
const {
data: campaign,
isLoading,
isError,
} = useQuery({
queryKey: ['referral-network-campaign', campaignId],
queryFn: () => referralNetworkApi.getCampaignDetail(campaignId),
staleTime: 60_000,
});
function handleClose() {
setSelectedNode(null);
}
return (
<div className={className}>
{/* Header */}
<div className="flex items-center justify-between border-b border-dark-700/50 p-4">
<h3 className="text-sm font-semibold text-dark-100">{campaign?.name ?? '...'}</h3>
<button
onClick={handleClose}
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
aria-label={t('common.close')}
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-4">
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
</div>
)}
{isError && (
<div className="py-8 text-center text-sm text-error-400">
{t('admin.referralNetwork.error')}
</div>
)}
{campaign && (
<div className="space-y-5">
{/* Info */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-dark-500">
{t('admin.referralNetwork.campaign.startParam')}
</span>
<span className="font-mono text-dark-200">{campaign.start_parameter}</span>
</div>
<div className="flex justify-end">
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${
campaign.is_active
? 'bg-success-500/20 text-success-400'
: 'bg-dark-700/50 text-dark-400'
}`}
>
{campaign.is_active
? t('admin.referralNetwork.campaign.active')
: t('admin.referralNetwork.campaign.inactive')}
</span>
</div>
</div>
{/* Stats */}
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.campaign.directUsers')}
</span>
<span className="font-mono text-dark-100">{campaign.direct_users}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.campaign.totalNetwork')}
</span>
<span className="font-mono text-dark-100">{campaign.total_network_users}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.campaign.totalRevenue')}
</span>
<span className="font-mono text-accent-400">
{formatKopeksToRubles(campaign.total_revenue_kopeks)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.campaign.conversionRate')}
</span>
<span className="font-mono text-dark-100">
{campaign.conversion_rate.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.campaign.avgCheck')}
</span>
<span className="font-mono text-dark-100">
{formatKopeksToRubles(campaign.avg_check_kopeks)}
</span>
</div>
</div>
</div>
{/* Top referrers */}
{campaign.top_referrers.length > 0 && (
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.campaign.topReferrers')}
</h4>
<div className="space-y-1.5">
{campaign.top_referrers.map((referrer, index) => (
<div
key={referrer.user_id}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-dark-700 text-[10px] font-medium text-dark-300">
{index + 1}
</span>
<span className="text-dark-200">
{referrer.username ? `@${referrer.username}` : `#${referrer.user_id}`}
</span>
</div>
<span className="font-mono text-dark-300">{referrer.referral_count}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getSigmaInstance } from '../sigmaGlobals';
interface NetworkControlsProps {
className?: string;
}
export function NetworkControls({ className }: NetworkControlsProps) {
const { t } = useTranslation();
const [isFullscreen, setIsFullscreen] = useState(false);
// Sync fullscreen state when user exits via Escape key or browser UI
const handleFullscreenChange = useCallback(() => {
setIsFullscreen(!!document.fullscreenElement);
}, []);
useEffect(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, [handleFullscreenChange]);
function handleZoomIn() {
const sigma = getSigmaInstance();
if (!sigma) return;
const camera = sigma.getCamera();
camera.animate({ ratio: camera.ratio / 1.5 }, { duration: 200 });
}
function handleZoomOut() {
const sigma = getSigmaInstance();
if (!sigma) return;
const camera = sigma.getCamera();
camera.animate({ ratio: camera.ratio * 1.5 }, { duration: 200 });
}
function handleResetZoom() {
const sigma = getSigmaInstance();
if (!sigma) return;
const camera = sigma.getCamera();
camera.animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 400 });
}
function handleFullscreen() {
const container = document.getElementById('referral-network-container');
if (!container) return;
if (!document.fullscreenElement) {
container
.requestFullscreen()
.then(() => setIsFullscreen(true))
.catch(() => {});
} else {
document
.exitFullscreen()
.then(() => setIsFullscreen(false))
.catch(() => {});
}
}
const buttons = [
{
label: t('admin.referralNetwork.controls.zoomIn'),
onClick: handleZoomIn,
icon: (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
),
},
{
label: t('admin.referralNetwork.controls.zoomOut'),
onClick: handleZoomOut,
icon: (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
</svg>
),
},
{
label: t('admin.referralNetwork.controls.resetZoom'),
onClick: handleResetZoom,
icon: (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
/>
</svg>
),
},
{
label: t('admin.referralNetwork.controls.fullscreen'),
onClick: handleFullscreen,
icon: isFullscreen ? (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
/>
</svg>
) : (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
/>
</svg>
),
},
];
return (
<div
className={`flex gap-1 rounded-xl border border-dark-700/50 bg-dark-900/80 p-1.5 backdrop-blur-md ${className ?? ''}`}
>
{buttons.map((btn) => (
<button
key={btn.label}
onClick={btn.onClick}
title={btn.label}
aria-label={btn.label}
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
>
{btn.icon}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { useTranslation } from 'react-i18next';
import { useReferralNetworkStore } from '@/store/referralNetwork';
import type { NetworkGraphData } from '@/types/referralNetwork';
interface NetworkFiltersProps {
data: NetworkGraphData;
className?: string;
}
export function NetworkFilters({ data, className }: NetworkFiltersProps) {
const { t } = useTranslation();
const filters = useReferralNetworkStore((s) => s.filters);
const updateFilters = useReferralNetworkStore((s) => s.updateFilters);
const resetFilters = useReferralNetworkStore((s) => s.resetFilters);
const isOpen = useReferralNetworkStore((s) => s.isFiltersOpen);
const setIsOpen = useReferralNetworkStore((s) => s.setIsFiltersOpen);
function toggleCampaign(campaignId: number) {
const current = filters.campaigns;
const next = current.includes(campaignId)
? current.filter((id) => id !== campaignId)
: [...current, campaignId];
updateFilters({ campaigns: next });
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
aria-label={t('admin.referralNetwork.filters.title')}
className={`flex items-center gap-2 rounded-lg border border-dark-700/50 bg-dark-800/80 px-3 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 ${className ?? ''}`}
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
/>
</svg>
{t('admin.referralNetwork.filters.title')}
</button>
);
}
return (
<div
className={`rounded-xl border border-dark-700/50 bg-dark-900/90 p-4 backdrop-blur-md ${className ?? ''}`}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-dark-100">
{t('admin.referralNetwork.filters.title')}
</h3>
<button
onClick={() => setIsOpen(false)}
aria-label={t('common.close')}
className="text-dark-500 transition-colors hover:text-dark-300"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
{/* Campaigns */}
{data.campaigns.length > 0 && (
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-400">
{t('admin.referralNetwork.filters.campaigns')}
</label>
<div className="max-h-32 space-y-1 overflow-y-auto">
{data.campaigns.map((campaign) => (
<label
key={campaign.id}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-dark-800/50"
>
<input
type="checkbox"
checked={filters.campaigns.includes(campaign.id)}
onChange={() => toggleCampaign(campaign.id)}
className="h-3.5 w-3.5 rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500/30"
/>
<span className="truncate text-dark-200">{campaign.name}</span>
</label>
))}
</div>
</div>
)}
{/* Partners only */}
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={filters.partnersOnly}
onChange={(e) => updateFilters({ partnersOnly: e.target.checked })}
className="h-3.5 w-3.5 rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500/30"
/>
<span className="text-dark-200">{t('admin.referralNetwork.filters.partnersOnly')}</span>
</label>
{/* Min referrals */}
<div>
<label className="mb-1.5 flex items-center justify-between text-xs font-medium text-dark-400">
<span>{t('admin.referralNetwork.filters.minReferrals')}</span>
<span className="font-mono text-dark-300">{filters.minReferrals}</span>
</label>
<input
type="range"
min={0}
max={50}
value={filters.minReferrals}
onChange={(e) => updateFilters({ minReferrals: Number(e.target.value) })}
className="w-full accent-accent-500"
/>
</div>
{/* Reset */}
<button
onClick={resetFilters}
className="w-full rounded-lg border border-dark-700/50 py-1.5 text-xs font-medium text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200"
>
{t('admin.referralNetwork.filters.reset')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,322 @@
import { useEffect, useRef, useCallback } from 'react';
import Graph from 'graphology';
import Sigma from 'sigma';
import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker';
import { useReferralNetworkStore } from '@/store/referralNetwork';
import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork';
import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils';
import { setSigmaInstance, setGraphInstance } from '../sigmaGlobals';
interface NetworkGraphProps {
data: NetworkGraphData;
className?: string;
}
/**
* Build the full graph from data (no filter logic).
* Stores filter-relevant attributes on nodes so reducers can check them.
*/
function buildFullGraph(graphData: NetworkGraphData): Graph {
const graph = new Graph();
graphData.campaigns.forEach((campaign, index) => {
graph.addNode(`campaign_${campaign.id}`, {
label: campaign.name,
x: Math.random() * 100,
y: Math.random() * 100,
size: 18,
color: getCampaignColor(index),
type: 'circle',
nodeType: 'campaign',
nodeId: campaign.id,
});
});
graphData.users.forEach((user) => {
const color = getUserNodeColor(user.direct_referrals, user.is_partner, user.campaign_id);
const size = getUserNodeSize(user.direct_referrals);
graph.addNode(`user_${user.id}`, {
label: user.display_name,
x: Math.random() * 100,
y: Math.random() * 100,
size,
color,
type: 'circle',
nodeType: 'user',
nodeId: user.id,
isPartner: user.is_partner,
directReferrals: user.direct_referrals,
campaignId: user.campaign_id,
});
});
graphData.edges.forEach((edge) => {
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
const edgeKey = `${edge.source}->${edge.target}`;
if (!graph.hasEdge(edgeKey)) {
graph.addEdgeWithKey(edgeKey, edge.source, edge.target, {
color: edge.type === 'campaign' ? 'rgba(77, 217, 192, 0.12)' : 'rgba(255,255,255,0.06)',
size: 0.5,
edgeType: edge.type,
});
}
}
});
return graph;
}
/**
* Compute set of node keys that should be hidden based on current filters.
*/
function computeHiddenNodes(graph: Graph, filters: NetworkFilters): Set<string> {
const hidden = new Set<string>();
const filterCampaignSet = new Set(filters.campaigns);
const hasCampaignFilter = filterCampaignSet.size > 0;
graph.forEachNode((node, attrs) => {
if (attrs.nodeType === 'user') {
if (filters.partnersOnly && !attrs.isPartner) {
hidden.add(node);
} else if ((attrs.directReferrals ?? 0) < filters.minReferrals) {
hidden.add(node);
} else if (
hasCampaignFilter &&
attrs.campaignId !== null &&
!filterCampaignSet.has(attrs.campaignId)
) {
hidden.add(node);
}
} else if (attrs.nodeType === 'campaign') {
if (hasCampaignFilter && !filterCampaignSet.has(attrs.nodeId)) {
hidden.add(node);
}
}
});
return hidden;
}
const FA2_DURATION_MS = 5000;
export function NetworkGraph({ data, className }: NetworkGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const sigmaRef = useRef<Sigma | null>(null);
const graphRef = useRef<Graph | null>(null);
const hiddenNodesRef = useRef<Set<string>>(new Set());
const fa2Ref = useRef<FA2LayoutSupervisor | null>(null);
const fa2TimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
const setHoveredNode = useReferralNetworkStore((s) => s.setHoveredNode);
const hoveredNodeId = useReferralNetworkStore((s) => s.hoveredNodeId);
const highlightedNodes = useReferralNetworkStore((s) => s.highlightedNodes);
const filters = useReferralNetworkStore((s) => s.filters);
const setNetworkData = useReferralNetworkStore((s) => s.setNetworkData);
const killFA2 = useCallback(() => {
if (fa2TimerRef.current !== null) {
clearTimeout(fa2TimerRef.current);
fa2TimerRef.current = null;
}
if (fa2Ref.current) {
fa2Ref.current.kill();
fa2Ref.current = null;
}
}, []);
// Initialize sigma — only re-runs when data changes (NOT on filter changes)
useEffect(() => {
if (!containerRef.current || !data) return;
// Cleanup previous instance
killFA2();
if (sigmaRef.current) {
sigmaRef.current.kill();
sigmaRef.current = null;
}
const graph = buildFullGraph(data);
graphRef.current = graph;
setNetworkData(data);
// Compute initial hidden set from current filters
const initialFilters = useReferralNetworkStore.getState().filters;
hiddenNodesRef.current = computeHiddenNodes(graph, initialFilters);
const sigma = new Sigma(graph, containerRef.current, {
renderEdgeLabels: false,
labelDensity: 0.5,
labelRenderedSizeThreshold: 6,
zIndex: true,
defaultEdgeColor: '#ffffff10',
defaultNodeColor: '#6b7280',
labelColor: { color: '#e8e6f0' },
labelFont: 'Inter, system-ui, sans-serif',
labelSize: 12,
stagePadding: 40,
nodeReducer: (node, attrs) => {
const res = { ...attrs };
// Filter visibility (read from pre-computed set)
if (hiddenNodesRef.current.has(node)) {
res.hidden = true;
return res;
}
const store = useReferralNetworkStore.getState();
const hovered = store.hoveredNodeId;
const highlighted = store.highlightedNodes;
// Search highlighting
if (highlighted.size > 0) {
if (highlighted.has(node)) {
res.highlighted = true;
res.zIndex = 2;
} else {
res.color = `${attrs.color}33`;
res.label = '';
res.zIndex = 0;
}
}
// Hover highlighting
if (hovered) {
if (node === hovered) {
res.highlighted = true;
res.zIndex = 2;
} else if (graph.hasNode(hovered) && graph.areNeighbors(node, hovered)) {
res.highlighted = true;
res.zIndex = 1;
} else if (!highlighted.has(node)) {
res.color = `${attrs.color}33`;
res.label = '';
res.zIndex = 0;
}
}
return res;
},
edgeReducer: (edge, attrs) => {
const res = { ...attrs };
// Hide edges connected to filtered-out nodes
const [source, target] = graph.extremities(edge);
if (hiddenNodesRef.current.has(source) || hiddenNodesRef.current.has(target)) {
res.hidden = true;
return res;
}
const store = useReferralNetworkStore.getState();
const hovered = store.hoveredNodeId;
const highlighted = store.highlightedNodes;
if (hovered && graph.hasNode(hovered)) {
if (!graph.hasExtremity(edge, hovered)) {
res.hidden = true;
} else {
res.color = '#ffffff30';
res.size = 1;
}
} else if (highlighted.size > 0) {
if (!highlighted.has(source) && !highlighted.has(target)) {
res.hidden = true;
}
}
return res;
},
});
sigmaRef.current = sigma;
// Expose to module-level globals for search/controls
setSigmaInstance(sigma);
setGraphInstance(graph);
// Start ForceAtlas2 in a web worker (non-blocking)
if (graph.order > 0) {
const supervisor = new FA2LayoutSupervisor(graph, {
settings: {
gravity: 1,
scalingRatio: 2,
barnesHutOptimize: true,
},
});
fa2Ref.current = supervisor;
supervisor.start();
// Kill after a fixed duration to free the web worker thread
fa2TimerRef.current = setTimeout(() => {
if (fa2Ref.current === supervisor) {
supervisor.kill();
fa2Ref.current = null;
}
fa2TimerRef.current = null;
}, FA2_DURATION_MS);
}
// Click handler
sigma.on('clickNode', ({ node }) => {
const attrs = graph.getNodeAttributes(node);
const nodeType = attrs.nodeType;
const nodeId = attrs.nodeId;
if (typeof nodeId !== 'number') return;
if (nodeType === 'user') {
setSelectedNode({ type: 'user', id: nodeId });
} else if (nodeType === 'campaign') {
setSelectedNode({ type: 'campaign', id: nodeId });
}
});
// Click on stage deselects
sigma.on('clickStage', () => {
setSelectedNode(null);
});
// Hover handler
sigma.on('enterNode', ({ node }) => {
setHoveredNode(node);
if (containerRef.current) {
containerRef.current.style.cursor = 'pointer';
}
});
sigma.on('leaveNode', () => {
setHoveredNode(null);
if (containerRef.current) {
containerRef.current.style.cursor = 'default';
}
});
return () => {
killFA2();
sigma.kill();
sigmaRef.current = null;
graphRef.current = null;
setSigmaInstance(null);
setGraphInstance(null);
};
}, [data, setSelectedNode, setHoveredNode, setNetworkData, killFA2]);
// Recompute hidden nodes when filters change, then refresh sigma
// (no graph rebuild — positions and Sigma instance preserved)
useEffect(() => {
if (!graphRef.current || !sigmaRef.current) return;
hiddenNodesRef.current = computeHiddenNodes(graphRef.current, filters);
sigmaRef.current.refresh();
}, [filters]);
// Refresh sigma on hover/highlight changes
useEffect(() => {
if (sigmaRef.current) {
sigmaRef.current.refresh();
}
}, [hoveredNodeId, highlightedNodes]);
return <div ref={containerRef} className={`bg-[#0a0a0f] ${className ?? ''}`} />;
}

View File

@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next';
interface NetworkLegendProps {
className?: string;
}
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' },
];
export function NetworkLegend({ className }: NetworkLegendProps) {
const { t } = useTranslation();
const gradientStyle = {
background: `linear-gradient(135deg, ${CAMPAIGN_GRADIENT_COLORS.join(', ')})`,
};
return (
<div
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">
{t('admin.referralNetwork.legend.title')}
</h4>
<div className="space-y-1.5">
{USER_LEGEND_ITEMS.map((item) => (
<div key={item.labelKey} className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.colorKey }}
/>
<span className="text-xs text-dark-300">{t(item.labelKey)}</span>
</div>
))}
{/* Campaign node with gradient to represent varying colors */}
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 shrink-0 rounded-full" style={gradientStyle} />
<span className="text-xs text-dark-300">
{t('admin.referralNetwork.legend.campaignNode')}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { referralNetworkApi } from '@/api/referralNetwork';
import { useReferralNetworkStore } from '@/store/referralNetwork';
import { getSigmaInstance, getGraphInstance } from '../sigmaGlobals';
interface NetworkSearchProps {
className?: string;
}
export function NetworkSearch({ className }: NetworkSearchProps) {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const setHighlightedNodes = useReferralNetworkStore((s) => s.setHighlightedNodes);
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
// Debounce
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(inputValue.trim());
}, 300);
return () => clearTimeout(timer);
}, [inputValue]);
const { data: searchResults, isFetching } = useQuery({
queryKey: ['referral-network-search', debouncedQuery],
queryFn: () => referralNetworkApi.search(debouncedQuery),
enabled: debouncedQuery.length >= 2,
staleTime: 30_000,
});
// Open dropdown when results arrive
useEffect(() => {
if (searchResults && debouncedQuery.length >= 2) {
setIsDropdownOpen(true);
}
}, [searchResults, debouncedQuery]);
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
function navigateToNode(nodeId: string) {
const sigma = getSigmaInstance();
const graph = getGraphInstance();
if (sigma && graph && graph.hasNode(nodeId)) {
// getNodeDisplayData returns viewport (pixel) coordinates.
// Convert to framed graph coordinates that the camera operates in.
const displayData = sigma.getNodeDisplayData(nodeId);
if (displayData) {
const camera = sigma.getCamera();
const framedCoords = sigma.viewportToFramedGraph({
x: displayData.x,
y: displayData.y,
});
camera.animate({ ...framedCoords, ratio: 0.15 }, { duration: 600 });
}
setHighlightedNodes(new Set([nodeId]));
}
}
function handleSelectUser(userId: number) {
setIsDropdownOpen(false);
setSelectedNode({ type: 'user', id: userId });
navigateToNode(`user_${userId}`);
}
function handleSelectCampaign(campaignId: number) {
setIsDropdownOpen(false);
setSelectedNode({ type: 'campaign', id: campaignId });
navigateToNode(`campaign_${campaignId}`);
}
function handleClear() {
setInputValue('');
setDebouncedQuery('');
setIsDropdownOpen(false);
setHighlightedNodes(new Set());
setSelectedNode(null);
}
const totalResults =
(searchResults?.users?.length ?? 0) + (searchResults?.campaigns?.length ?? 0);
return (
<div ref={dropdownRef} className={`relative ${className ?? ''}`}>
<div className="relative">
<svg
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={() => {
if (searchResults && debouncedQuery.length >= 2) setIsDropdownOpen(true);
}}
placeholder={t('admin.referralNetwork.search.placeholder')}
className="w-full rounded-lg border border-dark-700/50 bg-dark-800/80 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
/>
{inputValue && (
<button
onClick={handleClear}
aria-label={t('admin.referralNetwork.search.clear')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{isFetching && (
<div className="absolute right-10 top-1/2 -translate-y-1/2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
</div>
)}
</div>
{/* Dropdown */}
{isDropdownOpen && debouncedQuery.length >= 2 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-y-auto rounded-lg border border-dark-700/50 bg-dark-900/95 shadow-xl backdrop-blur-md">
{totalResults === 0 && !isFetching && (
<div className="px-4 py-3 text-center text-sm text-dark-500">
{t('admin.referralNetwork.search.noResults')}
</div>
)}
{searchResults?.users && searchResults.users.length > 0 && (
<div>
{searchResults.users.map((user) => (
<button
key={`u-${user.id}`}
onClick={() => handleSelectUser(user.id)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-800/80"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent-500/20 text-xs font-medium text-accent-400">
U
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">
{user.display_name}
</p>
<p className="truncate text-xs text-dark-500">
{user.username ? `@${user.username}` : ''}
{user.tg_id ? ` #${user.tg_id}` : ''}
</p>
</div>
{user.is_partner && (
<span className="shrink-0 rounded bg-warning-500/20 px-1.5 py-0.5 text-[10px] font-medium text-warning-400">
{t('admin.referralNetwork.user.partner')}
</span>
)}
</button>
))}
</div>
)}
{searchResults?.campaigns && searchResults.campaigns.length > 0 && (
<div>
{searchResults.users && searchResults.users.length > 0 && (
<div className="mx-4 border-t border-dark-700/50" />
)}
{searchResults.campaigns.map((campaign) => (
<button
key={`c-${campaign.id}`}
onClick={() => handleSelectCampaign(campaign.id)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-800/80"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-success-500/20 text-xs font-medium text-success-400">
C
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{campaign.name}</p>
<p className="truncate text-xs text-dark-500">{campaign.start_parameter}</p>
</div>
<span
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${
campaign.is_active
? 'bg-success-500/20 text-success-400'
: 'bg-dark-700/50 text-dark-400'
}`}
>
{campaign.is_active
? t('admin.referralNetwork.campaign.active')
: t('admin.referralNetwork.campaign.inactive')}
</span>
</button>
))}
</div>
)}
{totalResults > 0 && (
<div className="border-t border-dark-700/50 px-4 py-2 text-center text-xs text-dark-500">
{t('admin.referralNetwork.search.resultsCount', { count: totalResults })}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next';
import type { NetworkGraphData } from '@/types/referralNetwork';
import { formatKopeksToRubles } from '../utils';
interface NetworkStatsProps {
data: NetworkGraphData;
className?: string;
}
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 (
<div
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-3 backdrop-blur-md ${className ?? ''}`}
>
<div className="grid grid-cols-2 gap-3">
{stats.map((stat) => (
<div key={stat.label}>
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
{stat.label}
</p>
<p className="font-mono text-sm font-semibold text-dark-100">{stat.value}</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,203 @@
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';
interface UserDetailPanelProps {
userId: number;
className?: string;
}
export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
const { t } = useTranslation();
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
const {
data: user,
isLoading,
isError,
} = useQuery({
queryKey: ['referral-network-user', userId],
queryFn: () => referralNetworkApi.getUserDetail(userId),
staleTime: 60_000,
});
function handleClose() {
setSelectedNode(null);
}
return (
<div className={className}>
{/* Header */}
<div className="flex items-center justify-between border-b border-dark-700/50 p-4">
<h3 className="text-sm font-semibold text-dark-100">{user?.display_name ?? '...'}</h3>
<button
onClick={handleClose}
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
aria-label={t('common.close')}
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-4">
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
</div>
)}
{isError && (
<div className="py-8 text-center text-sm text-error-400">
{t('admin.referralNetwork.error')}
</div>
)}
{user && (
<div className="space-y-5">
{/* Identity */}
<div className="space-y-2">
{user.username && (
<div className="flex items-center justify-between text-sm">
<span className="text-dark-500">@</span>
<span className="font-mono text-dark-200">{user.username}</span>
</div>
)}
{user.tg_id && (
<div className="flex items-center justify-between text-sm">
<span className="text-dark-500">{t('admin.referralNetwork.user.tgId')}</span>
<span className="font-mono text-dark-200">{user.tg_id}</span>
</div>
)}
{user.email && (
<div className="flex items-center justify-between text-sm">
<span className="text-dark-500">{t('admin.referralNetwork.user.email')}</span>
<span className="truncate pl-4 font-mono text-dark-200">{user.email}</span>
</div>
)}
{user.is_partner && (
<div className="flex justify-end">
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-400">
{t('admin.referralNetwork.user.partner')}
</span>
</div>
)}
</div>
{/* Subscription */}
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.user.subscription')}
</h4>
{user.subscription_name ? (
<div>
<p className="text-sm font-medium text-dark-100">{user.subscription_name}</p>
{user.subscription_end && (
<p className="mt-0.5 text-xs text-dark-400">
{t('admin.referralNetwork.user.validUntil', {
date: new Date(user.subscription_end).toLocaleDateString(),
})}
</p>
)}
</div>
) : (
<p className="text-sm text-dark-500">
{t('admin.referralNetwork.user.noSubscription')}
</p>
)}
</div>
{/* Personal stats */}
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.user.personalStats')}
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.user.totalSpent')}
</span>
<span className="font-mono text-dark-100">
{formatKopeksToRubles(user.personal_spent_kopeks)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.user.referralEarnings')}
</span>
<span className="font-mono text-accent-400">
{formatKopeksToRubles(user.personal_revenue_kopeks)}
</span>
</div>
</div>
</div>
{/* Referral branch */}
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.user.referralBranch')}
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.user.directReferrals')}
</span>
<span className="font-mono text-dark-100">{user.direct_referrals}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.user.branchSize')}
</span>
<span className="font-mono text-dark-100">{user.total_branch_users}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.user.branchRevenue')}
</span>
<span className="font-mono text-dark-100">
{formatKopeksToRubles(user.branch_revenue_kopeks)}
</span>
</div>
</div>
</div>
{/* Source */}
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.user.source')}
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.user.referredBy')}
</span>
<span className="text-dark-200">
{user.referrer_display_name ?? t('admin.referralNetwork.user.organic')}
</span>
</div>
{user.campaign_name && (
<div className="flex items-center justify-between text-sm">
<span className="text-dark-400">
{t('admin.referralNetwork.user.fromCampaign')}
</span>
<span className="text-dark-200">{user.campaign_name}</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}