From 84f0e4e9b4fd6b73444505696d3dd20a30cc0c82 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 06:39:05 +0300 Subject: [PATCH 01/19] feat: add media attachment support for admin ticket replies Admin can now attach photos, videos, and documents when replying to tickets. Upload happens on file selection via existing media upload endpoint. Includes attachment preview with type-specific icons, upload progress, error handling, race condition protection, and proper blob URL lifecycle management. Added video player rendering for video attachments in message thread. Updated ticket list preview to show [media_type] for media-only messages. --- src/api/admin.ts | 11 +- src/locales/en.json | 6 + src/locales/fa.json | 6 + src/locales/ru.json | 6 + src/locales/zh.json | 6 + src/pages/AdminTickets.tsx | 285 +++++++++++++++++++++++++++++++++++-- 6 files changed, 307 insertions(+), 13 deletions(-) diff --git a/src/api/admin.ts b/src/api/admin.ts index bf09059..26bb21e 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -115,8 +115,15 @@ export const adminApi = { }, // Reply to ticket - replyToTicket: async (ticketId: number, message: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message }); + replyToTicket: async ( + ticketId: number, + message: string, + media?: { media_type?: string; media_file_id?: string; media_caption?: string }, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { + message, + ...media, + }); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index 39a5bb3..3c2fcc8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1917,6 +1917,12 @@ "adminLabel": "Admin", "replyPlaceholder": "Type your reply...", "sendReply": "Send Reply", + "attachMedia": "Attach media", + "invalidFileType": "Invalid file type. Use images, videos (MP4, WebM) or documents (PDF, DOC, TXT, ZIP).", + "fileTooLarge": "File is too large. Maximum size is 10MB.", + "uploadFailed": "Failed to upload file", + "uploading": "Uploading...", + "uploadComplete": "Uploaded", "settings": "Settings", "settingsSubtitle": "Configure ticket system and SLA", "settingsLoadError": "Failed to load settings", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2a24e5a..5ab243d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1579,6 +1579,12 @@ "adminLabel": "مدیر", "replyPlaceholder": "پاسخ خود را بنویسید...", "sendReply": "ارسال پاسخ", + "attachMedia": "پیوست رسانه", + "invalidFileType": "نوع فایل نامعتبر. از تصاویر، ویدئو (MP4, WebM) یا اسناد (PDF, DOC, TXT, ZIP) استفاده کنید.", + "fileTooLarge": "فایل بسیار بزرگ است. حداکثر اندازه ۱۰ مگابایت.", + "uploadFailed": "آپلود فایل ناموفق بود", + "uploading": "در حال آپلود...", + "uploadComplete": "آپلود شد", "settings": "تنظیمات", "settingsSubtitle": "پیکربندی سیستم تیکت و SLA", "settingsLoadError": "خطا در بارگذاری تنظیمات", diff --git a/src/locales/ru.json b/src/locales/ru.json index 31017be..77a3a6b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2428,6 +2428,12 @@ "adminLabel": "Админ", "replyPlaceholder": "Введите ваш ответ...", "sendReply": "Отправить", + "attachMedia": "Прикрепить медиа", + "invalidFileType": "Неверный тип файла. Используйте изображения, видео (MP4, WebM) или документы (PDF, DOC, TXT, ZIP).", + "fileTooLarge": "Файл слишком большой. Максимальный размер 10МБ.", + "uploadFailed": "Не удалось загрузить файл", + "uploading": "Загрузка...", + "uploadComplete": "Загружено", "settings": "Настройки", "settingsSubtitle": "Настройка тикетной системы и SLA", "settingsLoadError": "Не удалось загрузить настройки", diff --git a/src/locales/zh.json b/src/locales/zh.json index d2fea20..05003f7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1617,6 +1617,12 @@ "adminLabel": "管理员", "replyPlaceholder": "输入您的回复...", "sendReply": "发送回复", + "attachMedia": "附加媒体", + "invalidFileType": "文件类型无效。请使用图片、视频 (MP4, WebM) 或文档 (PDF, DOC, TXT, ZIP)。", + "fileTooLarge": "文件太大。最大 10MB。", + "uploadFailed": "上传文件失败", + "uploading": "上传中...", + "uploadComplete": "已上传", "settings": "设置", "settingsSubtitle": "配置工单系统和SLA", "settingsLoadError": "加载设置失败", diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 4f37753..4253912 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -6,6 +6,34 @@ import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '.. import { ticketsApi } from '../api/tickets'; import { usePlatform } from '../platform/hooks/usePlatform'; +interface MediaAttachment { + file: File; + preview: string; + uploading: boolean; + fileId?: string; + mediaType: string; + error?: string; +} + +const ALLOWED_FILE_TYPES: Record = { + 'image/jpeg': 'photo', + 'image/png': 'photo', + 'image/gif': 'photo', + 'image/webp': 'photo', + 'video/mp4': 'video', + 'video/webm': 'video', + 'video/quicktime': 'video', + 'application/pdf': 'document', + 'application/msword': 'document', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', + 'text/plain': 'document', + 'application/zip': 'document', + 'application/x-rar-compressed': 'document', +}; + +const ACCEPT_STRING = Object.keys(ALLOWED_FILE_TYPES).join(','); +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + function AdminMessageMedia({ message, t, @@ -80,6 +108,22 @@ function AdminMessageMedia({ ); } + if (message.media_type === 'video') { + return ( +
+
+ ); + } + return (
- + {message.media_caption || `Download ${message.media_type}`} @@ -126,6 +174,20 @@ export default function AdminTickets() { const [statusFilter, setStatusFilter] = useState(''); const [replyText, setReplyText] = useState(''); const [page, setPage] = useState(1); + const [attachment, setAttachment] = useState(null); + const fileInputRef = useRef(null); + const previewRef = useRef(null); + const uploadIdRef = useRef(0); + + // Cancel in-flight uploads and cleanup blob URL on unmount + useEffect(() => { + return () => { + uploadIdRef.current++; + if (previewRef.current) { + URL.revokeObjectURL(previewRef.current); + } + }; + }, []); const { data: stats } = useQuery({ queryKey: ['admin-ticket-stats'], @@ -149,10 +211,18 @@ export default function AdminTickets() { }); const replyMutation = useMutation({ - mutationFn: ({ ticketId, message }: { ticketId: number; message: string }) => - adminApi.replyToTicket(ticketId, message), + mutationFn: ({ + ticketId, + message, + media, + }: { + ticketId: number; + message: string; + media?: { media_type?: string; media_file_id?: string; media_caption?: string }; + }) => adminApi.replyToTicket(ticketId, message, media), onSuccess: () => { setReplyText(''); + clearAttachment(); queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] }); @@ -169,10 +239,84 @@ export default function AdminTickets() { }, }); + const clearAttachment = () => { + uploadIdRef.current++; + if (previewRef.current) { + URL.revokeObjectURL(previewRef.current); + previewRef.current = null; + } + setAttachment(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Revoke any existing blob URL before processing new file + if (previewRef.current) { + URL.revokeObjectURL(previewRef.current); + previewRef.current = null; + } + + const mediaType = ALLOWED_FILE_TYPES[file.type]; + if (!mediaType) { + setAttachment({ + file, + preview: '', + uploading: false, + mediaType: 'document', + error: t('admin.tickets.invalidFileType'), + }); + return; + } + + if (file.size > MAX_FILE_SIZE) { + setAttachment({ + file, + preview: '', + uploading: false, + mediaType, + error: t('admin.tickets.fileTooLarge'), + }); + return; + } + + const preview = mediaType === 'photo' ? URL.createObjectURL(file) : ''; + previewRef.current = preview; + + const currentUploadId = ++uploadIdRef.current; + setAttachment({ file, preview, uploading: true, mediaType }); + + try { + const result = await ticketsApi.uploadMedia(file, mediaType); + if (uploadIdRef.current !== currentUploadId) return; // stale upload + setAttachment((prev) => + prev ? { ...prev, uploading: false, fileId: result.file_id } : null, + ); + } catch { + if (uploadIdRef.current !== currentUploadId) return; // stale upload + setAttachment((prev) => + prev ? { ...prev, uploading: false, error: t('admin.tickets.uploadFailed') } : null, + ); + } + }; + const handleReply = (e: React.FormEvent) => { e.preventDefault(); if (!selectedTicketId || !replyText.trim()) return; - replyMutation.mutate({ ticketId: selectedTicketId, message: replyText }); + if (attachment && (attachment.uploading || attachment.error)) return; + + const media = attachment?.fileId + ? { + media_type: attachment.mediaType, + media_file_id: attachment.fileId, + } + : undefined; + + replyMutation.mutate({ ticketId: selectedTicketId, message: replyText, media }); }; const getStatusBadge = (status: string) => { @@ -321,7 +465,11 @@ export default function AdminTickets() { {ticketsData?.items.map((ticket) => (
)} @@ -501,7 +654,9 @@ export default function AdminTickets() { {new Date(msg.created_at).toLocaleString()} -

{msg.message_text}

+ {msg.message_text && ( +

{msg.message_text}

+ )} ))} @@ -517,10 +672,118 @@ export default function AdminTickets() { rows={3} className="input resize-none" /> -
+ + {/* Attachment preview */} + {attachment && ( +
+ {attachment.mediaType === 'photo' && attachment.preview ? ( + Preview + ) : ( +
+ + {attachment.mediaType === 'video' ? ( + + ) : ( + + )} + +
+ )} +
+
{attachment.file.name}
+ {attachment.uploading && ( +
+ + {t('admin.tickets.uploading')} +
+ )} + {attachment.error && ( +
{attachment.error}
+ )} + {attachment.fileId && !attachment.uploading && ( +
+ {t('admin.tickets.uploadComplete')} +
+ )} +
+ +
+ )} + + + +
+ +
+ + {/* Content */} +
+ {isLoading && ( +
+
+
+ )} + + {isError && ( +
+ {t('admin.referralNetwork.error')} +
+ )} + + {campaign && ( +
+ {/* Info */} +
+
+ + {t('admin.referralNetwork.campaign.startParam')} + + {campaign.start_parameter} +
+
+ + {campaign.is_active + ? t('admin.referralNetwork.campaign.active') + : t('admin.referralNetwork.campaign.inactive')} + +
+
+ + {/* Stats */} +
+
+
+ + {t('admin.referralNetwork.campaign.directUsers')} + + {campaign.direct_users} +
+
+ + {t('admin.referralNetwork.campaign.totalNetwork')} + + {campaign.total_network_users} +
+
+ + {t('admin.referralNetwork.campaign.totalRevenue')} + + + {formatKopeksToRubles(campaign.total_revenue_kopeks)} ₽ + +
+
+ + {t('admin.referralNetwork.campaign.conversionRate')} + + + {campaign.conversion_rate.toFixed(1)}% + +
+
+ + {t('admin.referralNetwork.campaign.avgCheck')} + + + {formatKopeksToRubles(campaign.avg_check_kopeks)} ₽ + +
+
+
+ + {/* Top referrers */} + {campaign.top_referrers.length > 0 && ( +
+

+ {t('admin.referralNetwork.campaign.topReferrers')} +

+
+ {campaign.top_referrers.map((referrer, index) => ( +
+
+ + {index + 1} + + + {referrer.username ? `@${referrer.username}` : `#${referrer.user_id}`} + +
+ {referrer.referral_count} +
+ ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkControls.tsx b/src/pages/ReferralNetwork/components/NetworkControls.tsx new file mode 100644 index 0000000..fbd4478 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkControls.tsx @@ -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: ( + + + + ), + }, + { + label: t('admin.referralNetwork.controls.zoomOut'), + onClick: handleZoomOut, + icon: ( + + + + ), + }, + { + label: t('admin.referralNetwork.controls.resetZoom'), + onClick: handleResetZoom, + icon: ( + + + + ), + }, + { + label: t('admin.referralNetwork.controls.fullscreen'), + onClick: handleFullscreen, + icon: isFullscreen ? ( + + + + ) : ( + + + + ), + }, + ]; + + return ( +
+ {buttons.map((btn) => ( + + ))} +
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkFilters.tsx b/src/pages/ReferralNetwork/components/NetworkFilters.tsx new file mode 100644 index 0000000..4e2ff6d --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkFilters.tsx @@ -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 ( + + ); + } + + return ( +
+
+

+ {t('admin.referralNetwork.filters.title')} +

+ +
+ +
+ {/* Campaigns */} + {data.campaigns.length > 0 && ( +
+ +
+ {data.campaigns.map((campaign) => ( + + ))} +
+
+ )} + + {/* Partners only */} + + + {/* Min referrals */} +
+ + updateFilters({ minReferrals: Number(e.target.value) })} + className="w-full accent-accent-500" + /> +
+ + {/* Reset */} + +
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx new file mode 100644 index 0000000..371cb25 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -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 { + const hidden = new Set(); + 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(null); + const sigmaRef = useRef(null); + const graphRef = useRef(null); + const hiddenNodesRef = useRef>(new Set()); + const fa2Ref = useRef(null); + const fa2TimerRef = useRef | 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
; +} diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx new file mode 100644 index 0000000..05944c9 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -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 ( +
+

+ {t('admin.referralNetwork.legend.title')} +

+
+ {USER_LEGEND_ITEMS.map((item) => ( +
+
+ {t(item.labelKey)} +
+ ))} + {/* Campaign node with gradient to represent varying colors */} +
+
+ + {t('admin.referralNetwork.legend.campaignNode')} + +
+
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkSearch.tsx b/src/pages/ReferralNetwork/components/NetworkSearch.tsx new file mode 100644 index 0000000..5f30d36 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkSearch.tsx @@ -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(null); + const inputRef = useRef(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 ( +
+
+ + + + 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 && ( + + )} + {isFetching && ( +
+
+
+ )} +
+ + {/* Dropdown */} + {isDropdownOpen && debouncedQuery.length >= 2 && ( +
+ {totalResults === 0 && !isFetching && ( +
+ {t('admin.referralNetwork.search.noResults')} +
+ )} + + {searchResults?.users && searchResults.users.length > 0 && ( +
+ {searchResults.users.map((user) => ( + + ))} +
+ )} + + {searchResults?.campaigns && searchResults.campaigns.length > 0 && ( +
+ {searchResults.users && searchResults.users.length > 0 && ( +
+ )} + {searchResults.campaigns.map((campaign) => ( + + ))} +
+ )} + + {totalResults > 0 && ( +
+ {t('admin.referralNetwork.search.resultsCount', { count: totalResults })} +
+ )} +
+ )} +
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkStats.tsx b/src/pages/ReferralNetwork/components/NetworkStats.tsx new file mode 100644 index 0000000..a7303e0 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkStats.tsx @@ -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 ( +
+
+ {stats.map((stat) => ( +
+

+ {stat.label} +

+

{stat.value}

+
+ ))} +
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx new file mode 100644 index 0000000..3ec117f --- /dev/null +++ b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx @@ -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 ( +
+ {/* Header */} +
+

{user?.display_name ?? '...'}

+ +
+ + {/* Content */} +
+ {isLoading && ( +
+
+
+ )} + + {isError && ( +
+ {t('admin.referralNetwork.error')} +
+ )} + + {user && ( +
+ {/* Identity */} +
+ {user.username && ( +
+ @ + {user.username} +
+ )} + {user.tg_id && ( +
+ {t('admin.referralNetwork.user.tgId')} + {user.tg_id} +
+ )} + {user.email && ( +
+ {t('admin.referralNetwork.user.email')} + {user.email} +
+ )} + {user.is_partner && ( +
+ + {t('admin.referralNetwork.user.partner')} + +
+ )} +
+ + {/* Subscription */} +
+

+ {t('admin.referralNetwork.user.subscription')} +

+ {user.subscription_name ? ( +
+

{user.subscription_name}

+ {user.subscription_end && ( +

+ {t('admin.referralNetwork.user.validUntil', { + date: new Date(user.subscription_end).toLocaleDateString(), + })} +

+ )} +
+ ) : ( +

+ {t('admin.referralNetwork.user.noSubscription')} +

+ )} +
+ + {/* Personal stats */} +
+

+ {t('admin.referralNetwork.user.personalStats')} +

+
+
+ + {t('admin.referralNetwork.user.totalSpent')} + + + {formatKopeksToRubles(user.personal_spent_kopeks)} ₽ + +
+
+ + {t('admin.referralNetwork.user.referralEarnings')} + + + {formatKopeksToRubles(user.personal_revenue_kopeks)} ₽ + +
+
+
+ + {/* Referral branch */} +
+

+ {t('admin.referralNetwork.user.referralBranch')} +

+
+
+ + {t('admin.referralNetwork.user.directReferrals')} + + {user.direct_referrals} +
+
+ + {t('admin.referralNetwork.user.branchSize')} + + {user.total_branch_users} +
+
+ + {t('admin.referralNetwork.user.branchRevenue')} + + + {formatKopeksToRubles(user.branch_revenue_kopeks)} ₽ + +
+
+
+ + {/* Source */} +
+

+ {t('admin.referralNetwork.user.source')} +

+
+
+ + {t('admin.referralNetwork.user.referredBy')} + + + {user.referrer_display_name ?? t('admin.referralNetwork.user.organic')} + +
+ {user.campaign_name && ( +
+ + {t('admin.referralNetwork.user.fromCampaign')} + + {user.campaign_name} +
+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/src/pages/ReferralNetwork/index.tsx b/src/pages/ReferralNetwork/index.tsx new file mode 100644 index 0000000..94debef --- /dev/null +++ b/src/pages/ReferralNetwork/index.tsx @@ -0,0 +1,3 @@ +import { ReferralNetwork } from './ReferralNetwork'; + +export default ReferralNetwork; diff --git a/src/pages/ReferralNetwork/sigmaGlobals.ts b/src/pages/ReferralNetwork/sigmaGlobals.ts new file mode 100644 index 0000000..b702492 --- /dev/null +++ b/src/pages/ReferralNetwork/sigmaGlobals.ts @@ -0,0 +1,25 @@ +import type Sigma from 'sigma'; +import type Graph from 'graphology'; + +/** + * We store sigma and graph instances on a module-level variable + * so that search and controls can access them without prop drilling. + */ +let sigmaInstance: Sigma | null = null; +let graphInstance: Graph | null = null; + +export function setSigmaInstance(sigma: Sigma | null): void { + sigmaInstance = sigma; +} + +export function getSigmaInstance(): Sigma | null { + return sigmaInstance; +} + +export function setGraphInstance(graph: Graph | null): void { + graphInstance = graph; +} + +export function getGraphInstance(): Graph | null { + return graphInstance; +} diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts new file mode 100644 index 0000000..661e966 --- /dev/null +++ b/src/pages/ReferralNetwork/utils.ts @@ -0,0 +1,53 @@ +/** + * Format kopeks to a human-readable ruble string. + */ +export function formatKopeksToRubles(kopeks: number): string { + return `${(kopeks / 100).toLocaleString('ru-RU')}`; +} + +/** + * Campaign node color palette. Each campaign gets a distinct color + * based on its index position. + */ +const CAMPAIGN_COLORS = [ + '#4dd9c0', + '#f0c261', + '#e85d9a', + '#6b9fff', + '#b97aff', + '#ff8a65', + '#66d9a0', + '#ff6b9d', + '#7ec8e3', + '#c4b5fd', +]; + +export function getCampaignColor(index: number): string { + 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. + * Size is proportional to direct_referrals, clamped between min and max. + */ +export function getUserNodeSize(directReferrals: number): number { + const MIN_SIZE = 4; + const MAX_SIZE = 30; + if (directReferrals === 0) return MIN_SIZE; + return Math.min(MAX_SIZE, MIN_SIZE + Math.sqrt(directReferrals) * 4); +} diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts new file mode 100644 index 0000000..db12e20 --- /dev/null +++ b/src/store/referralNetwork.ts @@ -0,0 +1,70 @@ +import { create } from 'zustand'; +import type { + NetworkGraphData, + SelectedNode, + NetworkFilters, + NetworkUserNode, + NetworkCampaignNode, +} from '@/types/referralNetwork'; + +const DEFAULT_FILTERS: NetworkFilters = { + campaigns: [], + partnersOnly: false, + minReferrals: 0, +}; + +interface ReferralNetworkState { + networkData: NetworkGraphData | null; + selectedNode: SelectedNode; + hoveredNodeId: string | null; + highlightedNodes: Set; + searchQuery: string; + searchResults: { users: NetworkUserNode[]; campaigns: NetworkCampaignNode[] }; + filters: NetworkFilters; + isFiltersOpen: boolean; + + setNetworkData: (data: NetworkGraphData) => void; + setSelectedNode: (node: SelectedNode) => void; + setHoveredNode: (id: string | null) => void; + setHighlightedNodes: (nodes: Set) => void; + setSearchQuery: (query: string) => void; + setSearchResults: (results: { + users: NetworkUserNode[]; + campaigns: NetworkCampaignNode[]; + }) => void; + updateFilters: (filters: Partial) => void; + resetFilters: () => void; + setIsFiltersOpen: (open: boolean) => void; +} + +export const useReferralNetworkStore = create()((set) => ({ + networkData: null, + selectedNode: null, + hoveredNodeId: null, + highlightedNodes: new Set(), + searchQuery: '', + searchResults: { users: [], campaigns: [] }, + filters: { ...DEFAULT_FILTERS }, + isFiltersOpen: false, + + setNetworkData: (data) => set({ networkData: data }), + + setSelectedNode: (node) => set({ selectedNode: node }), + + setHoveredNode: (id) => set({ hoveredNodeId: id }), + + setHighlightedNodes: (nodes) => set({ highlightedNodes: nodes }), + + setSearchQuery: (query) => set({ searchQuery: query }), + + setSearchResults: (results) => set({ searchResults: results }), + + updateFilters: (partial) => + set((state) => ({ + filters: { ...state.filters, ...partial }, + })), + + resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), + + setIsFiltersOpen: (open) => set({ isFiltersOpen: open }), +})); diff --git a/src/types/referralNetwork.ts b/src/types/referralNetwork.ts new file mode 100644 index 0000000..a66f3b7 --- /dev/null +++ b/src/types/referralNetwork.ts @@ -0,0 +1,102 @@ +export interface NetworkUserNode { + id: number; + tg_id: number | null; + username: string | null; + email: string | null; + display_name: string; + is_partner: boolean; + referrer_id: number | null; + campaign_id: number | null; + direct_referrals: number; + total_branch_users: number; + branch_revenue_kopeks: number; + personal_revenue_kopeks: number; + personal_spent_kopeks: number; + subscription_name: string | null; + subscription_end: string | null; + registered_at: string | null; +} + +export interface NetworkCampaignNode { + id: number; + name: string; + start_parameter: string; + is_active: boolean; + direct_users: number; + total_network_users: number; + total_revenue_kopeks: number; + conversion_rate: number; + avg_check_kopeks: number; + top_referrers: Array<{ + user_id: number; + username: string | null; + referral_count: number; + }>; +} + +export interface NetworkEdge { + source: string; + target: string; + type: 'referral' | 'campaign'; +} + +export interface NetworkGraphData { + users: NetworkUserNode[]; + campaigns: NetworkCampaignNode[]; + edges: NetworkEdge[]; + total_users: number; + total_referrers: number; + total_campaigns: number; + total_earnings_kopeks: number; +} + +export interface NetworkUserDetail { + id: number; + tg_id: number | null; + username: string | null; + email: string | null; + display_name: string; + is_partner: boolean; + referrer_id: number | null; + referrer_display_name: string | null; + campaign_id: number | null; + campaign_name: string | null; + direct_referrals: number; + total_branch_users: number; + branch_revenue_kopeks: number; + personal_revenue_kopeks: number; + personal_spent_kopeks: number; + subscription_name: string | null; + subscription_end: string | null; + registered_at: string | null; +} + +export interface NetworkCampaignDetail { + id: number; + name: string; + start_parameter: string; + is_active: boolean; + direct_users: number; + total_network_users: number; + total_revenue_kopeks: number; + conversion_rate: number; + avg_check_kopeks: number; + top_referrers: Array<{ + user_id: number; + username: string | null; + referral_count: number; + }>; +} + +export interface NetworkSearchResult { + users: NetworkUserNode[]; + campaigns: NetworkCampaignNode[]; +} + +export type SelectedNode = { type: 'user'; id: number } | { type: 'campaign'; id: number } | null; + +export interface NetworkFilters { + campaigns: number[]; + partnersOnly: boolean; + minReferrals: number; +} From 0f756d633d8d64e11e5738c20bf9e4fdd631a667 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 08:08:18 +0300 Subject: [PATCH 03/19] fix: remove dead store code and add search input maxLength - Remove unused networkData, searchQuery, searchResults from zustand store - Remove setNetworkData write in NetworkGraph - Add maxLength={200} to search input --- .../components/NetworkGraph.tsx | 6 +---- .../components/NetworkSearch.tsx | 1 + src/store/referralNetwork.ts | 26 +------------------ 3 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 371cb25..5fa5bfe 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -113,8 +113,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { 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); @@ -140,8 +138,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { 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); @@ -301,7 +297,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { setSigmaInstance(null); setGraphInstance(null); }; - }, [data, setSelectedNode, setHoveredNode, setNetworkData, killFA2]); + }, [data, setSelectedNode, setHoveredNode, killFA2]); // Recompute hidden nodes when filters change, then refresh sigma // (no graph rebuild — positions and Sigma instance preserved) diff --git a/src/pages/ReferralNetwork/components/NetworkSearch.tsx b/src/pages/ReferralNetwork/components/NetworkSearch.tsx index 5f30d36..735f0f9 100644 --- a/src/pages/ReferralNetwork/components/NetworkSearch.tsx +++ b/src/pages/ReferralNetwork/components/NetworkSearch.tsx @@ -117,6 +117,7 @@ export function NetworkSearch({ className }: NetworkSearchProps) { ref={inputRef} type="text" value={inputValue} + maxLength={200} onChange={(e) => setInputValue(e.target.value)} onFocus={() => { if (searchResults && debouncedQuery.length >= 2) setIsDropdownOpen(true); diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts index db12e20..903f22e 100644 --- a/src/store/referralNetwork.ts +++ b/src/store/referralNetwork.ts @@ -1,11 +1,5 @@ import { create } from 'zustand'; -import type { - NetworkGraphData, - SelectedNode, - NetworkFilters, - NetworkUserNode, - NetworkCampaignNode, -} from '@/types/referralNetwork'; +import type { SelectedNode, NetworkFilters } from '@/types/referralNetwork'; const DEFAULT_FILTERS: NetworkFilters = { campaigns: [], @@ -14,51 +8,33 @@ const DEFAULT_FILTERS: NetworkFilters = { }; interface ReferralNetworkState { - networkData: NetworkGraphData | null; selectedNode: SelectedNode; hoveredNodeId: string | null; highlightedNodes: Set; - searchQuery: string; - searchResults: { users: NetworkUserNode[]; campaigns: NetworkCampaignNode[] }; filters: NetworkFilters; isFiltersOpen: boolean; - setNetworkData: (data: NetworkGraphData) => void; setSelectedNode: (node: SelectedNode) => void; setHoveredNode: (id: string | null) => void; setHighlightedNodes: (nodes: Set) => void; - setSearchQuery: (query: string) => void; - setSearchResults: (results: { - users: NetworkUserNode[]; - campaigns: NetworkCampaignNode[]; - }) => void; updateFilters: (filters: Partial) => void; resetFilters: () => void; setIsFiltersOpen: (open: boolean) => void; } export const useReferralNetworkStore = create()((set) => ({ - networkData: null, selectedNode: null, hoveredNodeId: null, highlightedNodes: new Set(), - searchQuery: '', - searchResults: { users: [], campaigns: [] }, filters: { ...DEFAULT_FILTERS }, isFiltersOpen: false, - setNetworkData: (data) => set({ networkData: data }), - setSelectedNode: (node) => set({ selectedNode: node }), setHoveredNode: (id) => set({ hoveredNodeId: id }), setHighlightedNodes: (nodes) => set({ highlightedNodes: nodes }), - setSearchQuery: (query) => set({ searchQuery: query }), - - setSearchResults: (results) => set({ searchResults: results }), - updateFilters: (partial) => set((state) => ({ filters: { ...state.filters, ...partial }, From 818557f57d486a47c2965d4f3dfc0eb385ead7ad Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 08:20:38 +0300 Subject: [PATCH 04/19] fix: improve graph spacing, mobile layout, and Telegram viewport - Increase FA2 scalingRatio to 10 and decrease gravity to 0.5 for better node spacing (less cluttered graph) - Use 100dvh instead of 100vh for Telegram Mini App viewport compat - Hide legend on mobile to prevent overlay collision - Move controls to bottom-right on mobile, bottom-center on desktop - Make stats single-column on mobile for compact layout --- src/pages/ReferralNetwork/ReferralNetwork.tsx | 10 +++++----- src/pages/ReferralNetwork/components/NetworkGraph.tsx | 5 +++-- src/pages/ReferralNetwork/components/NetworkStats.tsx | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 4356e38..e5b3b2a 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -31,7 +31,7 @@ export function ReferralNetwork() { return (
{/* Top bar */}
@@ -86,13 +86,13 @@ export function ReferralNetwork() {
- {/* Bottom-right: legend */} -
+ {/* Bottom-right: legend (hidden on mobile to avoid overlap) */} +
- {/* Bottom-center: controls */} -
+ {/* Bottom-right on mobile, bottom-center on desktop: controls */} +
diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 5fa5bfe..f00bb0f 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -236,9 +236,10 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { if (graph.order > 0) { const supervisor = new FA2LayoutSupervisor(graph, { settings: { - gravity: 1, - scalingRatio: 2, + gravity: 0.5, + scalingRatio: 10, barnesHutOptimize: true, + slowDown: 2, }, }); fa2Ref.current = supervisor; diff --git a/src/pages/ReferralNetwork/components/NetworkStats.tsx b/src/pages/ReferralNetwork/components/NetworkStats.tsx index a7303e0..e715bd2 100644 --- a/src/pages/ReferralNetwork/components/NetworkStats.tsx +++ b/src/pages/ReferralNetwork/components/NetworkStats.tsx @@ -31,9 +31,9 @@ export function NetworkStats({ data, className }: NetworkStatsProps) { return (
-
+
{stats.map((stat) => (

From ea143fdbc91243375f2abe2b2250d1666aede769 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 08:30:32 +0300 Subject: [PATCH 05/19] fix: redesign top bar and filter panel for mobile - Split top bar into two rows on mobile: title + filter icon, then full-width search below - Desktop keeps single-row layout with search and filter inline - Filter button: icon-only on mobile with active indicator dot - Filter panel: full-screen overlay with backdrop on mobile, inline dropdown on desktop - Outside-click closes filter panel - Removed subtitle (only on desktop, saves space) --- src/pages/ReferralNetwork/ReferralNetwork.tsx | 29 ++- .../components/NetworkFilters.tsx | 222 +++++++++++------- 2 files changed, 159 insertions(+), 92 deletions(-) diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index e5b3b2a..502b3ed 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -34,19 +34,28 @@ export function ReferralNetwork() { className="relative flex h-[calc(100dvh-64px)] flex-col overflow-hidden bg-[#0a0a0f]" > {/* Top bar */} -

- -
-

+
+ {/* Row 1: Back + Title + (desktop: search + filter) + (mobile: filter icon) */} +
+ +

{t('admin.referralNetwork.title')}

-

- {t('admin.referralNetwork.subtitle')} -

+ {/* Desktop: search + filter inline */} +
+ + {networkData && } +
+ {/* Mobile: filter icon only */} + {networkData && ( +
+ +
+ )}
-
- - {networkData && } + {/* Row 2 (mobile only): full-width search */} +
+
diff --git a/src/pages/ReferralNetwork/components/NetworkFilters.tsx b/src/pages/ReferralNetwork/components/NetworkFilters.tsx index 4e2ff6d..d363bbb 100644 --- a/src/pages/ReferralNetwork/components/NetworkFilters.tsx +++ b/src/pages/ReferralNetwork/components/NetworkFilters.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useReferralNetworkStore } from '@/store/referralNetwork'; import type { NetworkGraphData } from '@/types/referralNetwork'; @@ -9,6 +10,7 @@ interface NetworkFiltersProps { export function NetworkFilters({ data, className }: NetworkFiltersProps) { const { t } = useTranslation(); + const panelRef = useRef(null); const filters = useReferralNetworkStore((s) => s.filters); const updateFilters = useReferralNetworkStore((s) => s.updateFilters); @@ -16,6 +18,21 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) { const isOpen = useReferralNetworkStore((s) => s.isFiltersOpen); const setIsOpen = useReferralNetworkStore((s) => s.setIsFiltersOpen); + const hasActiveFilters = + filters.campaigns.length > 0 || filters.partnersOnly || filters.minReferrals > 0; + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + function handleClick(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isOpen, setIsOpen]); + function toggleCampaign(campaignId: number) { const current = filters.campaigns; const next = current.includes(campaignId) @@ -24,12 +41,13 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) { updateFilters({ campaigns: next }); } + // Trigger button if (!isOpen) { return ( ); } - return ( -
-
-

- {t('admin.referralNetwork.filters.title')} -

- + // Filter panel content (shared between mobile/desktop) + const panelContent = ( +
+ {/* Campaigns */} + {data.campaigns.length > 0 && ( +
+ +
+ {data.campaigns.map((campaign) => ( + + ))} +
+
+ )} + + {/* Partners only */} + + + {/* Min referrals */} +
+ + updateFilters({ minReferrals: Number(e.target.value) })} + className="w-full accent-accent-500" + />
-
- {/* Campaigns */} - {data.campaigns.length > 0 && ( -
- -
- {data.campaigns.map((campaign) => ( - - ))} -
+ {/* Reset */} + +
+ ); + + return ( +
+ {/* Desktop: inline dropdown */} +
+
+
+

+ {t('admin.referralNetwork.filters.title')} +

+
- )} - - {/* Partners only */} - - - {/* Min referrals */} -
- - updateFilters({ minReferrals: Number(e.target.value) })} - className="w-full accent-accent-500" - /> + {panelContent}
+
- {/* Reset */} - + {/* Mobile: full-width overlay below top bar */} +
+ {/* Backdrop */} +
setIsOpen(false)} /> + {/* Panel */} +
+
+

+ {t('admin.referralNetwork.filters.title')} +

+ +
+ {panelContent} +
); From 4ebd85b65d7bb2ebdace8eae3efc8b6fd4ef61cc Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 08:43:39 +0300 Subject: [PATCH 06/19] fix: fullscreen layout and filter dropdown positioning - Page now uses fixed inset-0 overlay to fill entire viewport - Simplified top bar to single row (back + title + search + filter) - Filter dropdown uses absolute positioning on desktop (no layout shift) - Filter button always visible, toggles dropdown on click --- src/pages/ReferralNetwork/ReferralNetwork.tsx | 30 +--- .../components/NetworkFilters.tsx | 167 +++++++++--------- 2 files changed, 92 insertions(+), 105 deletions(-) diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 502b3ed..0d0021b 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -29,33 +29,19 @@ export function ReferralNetwork() { const isPanelOpen = selectedNode !== null; return ( -
+
{/* Top bar */}
- {/* Row 1: Back + Title + (desktop: search + filter) + (mobile: filter icon) */} -
+ {/* Row 1: Back + Title + Search + Filter */} +
-

+

{t('admin.referralNetwork.title')}

- {/* Desktop: search + filter inline */} -
- - {networkData && } -
- {/* Mobile: filter icon only */} - {networkData && ( -
- -
- )} -
- {/* Row 2 (mobile only): full-width search */} -
- + {/* Search — always visible */} + + {/* Filter button */} + {networkData && }
diff --git a/src/pages/ReferralNetwork/components/NetworkFilters.tsx b/src/pages/ReferralNetwork/components/NetworkFilters.tsx index d363bbb..fb5bdec 100644 --- a/src/pages/ReferralNetwork/components/NetworkFilters.tsx +++ b/src/pages/ReferralNetwork/components/NetworkFilters.tsx @@ -41,36 +41,6 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) { updateFilters({ campaigns: next }); } - // Trigger button - if (!isOpen) { - return ( - - ); - } - - // Filter panel content (shared between mobile/desktop) const panelContent = (
{/* Campaigns */} @@ -136,63 +106,94 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) { ); return ( -
- {/* Desktop: inline dropdown */} -
-
-
-

- {t('admin.referralNetwork.filters.title')} -

- -
- {panelContent} -
-
+
+ {/* Trigger button — always rendered */} + - {/* Mobile: full-width overlay below top bar */} -
- {/* Backdrop */} -
setIsOpen(false)} /> - {/* Panel */} -
-
-

- {t('admin.referralNetwork.filters.title')} -

- + + + + +
+ {panelContent}
- {panelContent}
-
+ )} + + {/* Mobile: full-screen overlay */} + {isOpen && ( +
+
setIsOpen(false)} /> +
+
+

+ {t('admin.referralNetwork.filters.title')} +

+ +
+ {panelContent} +
+
+ )}
); } From 3434073d7c327299db8ca80440d6054cf9e8065e Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 08:51:45 +0300 Subject: [PATCH 07/19] fix: resolve Sigma container height error on page load Use absolute positioning for graph container instead of flex-1 to ensure explicit dimensions when Sigma initializes. --- src/pages/ReferralNetwork/ReferralNetwork.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 0d0021b..b690227 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -46,10 +46,10 @@ export function ReferralNetwork() {
{/* Main content area */} -
+
{/* Loading state */} {isLoading && ( -
+

{t('admin.referralNetwork.loading')}

@@ -59,14 +59,14 @@ export function ReferralNetwork() { {/* Error state */} {isError && ( -
+

{t('admin.referralNetwork.error')}

)} {/* Empty state */} {networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && ( -
+

{t('admin.referralNetwork.empty')}

)} @@ -74,7 +74,7 @@ export function ReferralNetwork() { {/* Graph */} {networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( <> - + {/* Bottom-left: stats overlay */}
From 6f58a0ce5d326eb67a154b6f2687873aac6cd247 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 08:56:31 +0300 Subject: [PATCH 08/19] fix: defer Sigma init with requestAnimationFrame to prevent no-height crash Container has no computed height when useEffect fires immediately after data arrives. Defer to next animation frame so browser layout completes. Also add allowInvalidContainer as safety net. --- .../components/NetworkGraph.tsx | 298 +++++++++--------- 1 file changed, 155 insertions(+), 143 deletions(-) diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index f00bb0f..db90f0b 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -128,172 +128,184 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { useEffect(() => { if (!containerRef.current || !data) return; - // Cleanup previous instance - killFA2(); - if (sigmaRef.current) { - sigmaRef.current.kill(); - sigmaRef.current = null; - } + const container = containerRef.current; - const graph = buildFullGraph(data); - graphRef.current = graph; + // Defer initialization to next frame so the browser computes layout first. + // Without this, container.offsetHeight is 0 and Sigma throws. + const rafId = requestAnimationFrame(() => { + if (!container.isConnected) return; - // Compute initial hidden set from current filters - const initialFilters = useReferralNetworkStore.getState().filters; - hiddenNodesRef.current = computeHiddenNodes(graph, initialFilters); + // Cleanup previous instance + killFA2(); + if (sigmaRef.current) { + sigmaRef.current.kill(); + sigmaRef.current = null; + } - 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 }; + const graph = buildFullGraph(data); + graphRef.current = graph; - // Filter visibility (read from pre-computed set) - if (hiddenNodesRef.current.has(node)) { - res.hidden = true; - return res; - } + // Compute initial hidden set from current filters + const initialFilters = useReferralNetworkStore.getState().filters; + hiddenNodesRef.current = computeHiddenNodes(graph, initialFilters); - const store = useReferralNetworkStore.getState(); - const hovered = store.hoveredNodeId; - const highlighted = store.highlightedNodes; + const sigma = new Sigma(graph, container, { + allowInvalidContainer: true, + 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 }; - // 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)) { + // Filter visibility (read from pre-computed set) + if (hiddenNodesRef.current.has(node)) { res.hidden = true; - } else { - res.color = '#ffffff30'; - res.size = 1; + return res; } - } else if (highlighted.size > 0) { - if (!highlighted.has(source) && !highlighted.has(target)) { + + 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; } - } - return res; - }, - }); + const store = useReferralNetworkStore.getState(); + const hovered = store.hoveredNodeId; + const highlighted = store.highlightedNodes; - sigmaRef.current = sigma; + 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; + } + } - // 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: 0.5, - scalingRatio: 10, - barnesHutOptimize: true, - slowDown: 2, + return res; }, }); - 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; + 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: 0.5, + scalingRatio: 10, + barnesHutOptimize: true, + slowDown: 2, + }, + }); + 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 }); } - 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; + // Click on stage deselects + sigma.on('clickStage', () => { + setSelectedNode(null); + }); - if (typeof nodeId !== 'number') return; + // Hover handler + sigma.on('enterNode', ({ node }) => { + setHoveredNode(node); + if (containerRef.current) { + containerRef.current.style.cursor = 'pointer'; + } + }); - 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'; - } - }); + sigma.on('leaveNode', () => { + setHoveredNode(null); + if (containerRef.current) { + containerRef.current.style.cursor = 'default'; + } + }); + }); // end requestAnimationFrame return () => { + cancelAnimationFrame(rafId); killFA2(); - sigma.kill(); - sigmaRef.current = null; + if (sigmaRef.current) { + sigmaRef.current.kill(); + sigmaRef.current = null; + } graphRef.current = null; setSigmaInstance(null); setGraphInstance(null); From fd9a47ecda6660903bea54b6cf2a838392fd85cd Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 09:04:55 +0300 Subject: [PATCH 09/19] fix: position page below AppShell header, wait for container size - Page uses top-16/sm:top-14 to start below AppShell header (z-50) instead of inset-0 which renders behind the header - Sigma init polls with RAF until container has real dimensions instead of allowInvalidContainer which creates broken canvas - Removed overflow-hidden from content area (not needed) --- src/pages/ReferralNetwork/ReferralNetwork.tsx | 10 +++++----- .../components/NetworkGraph.tsx | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index b690227..848ba2c 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -29,24 +29,24 @@ export function ReferralNetwork() { const isPanelOpen = selectedNode !== null; return ( -
+
{/* Top bar */}
- {/* Row 1: Back + Title + Search + Filter */}

{t('admin.referralNetwork.title')}

- {/* Search — always visible */} - {/* Filter button */} {networkData && }
{/* Main content area */} -
+
{/* Loading state */} {isLoading && (
diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index db90f0b..ff3c34a 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -129,11 +129,16 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { if (!containerRef.current || !data) return; const container = containerRef.current; + let rafId: number; + let cancelled = false; - // Defer initialization to next frame so the browser computes layout first. - // Without this, container.offsetHeight is 0 and Sigma throws. - const rafId = requestAnimationFrame(() => { - if (!container.isConnected) return; + // Poll until container has real dimensions (browser needs a frame to compute layout) + function tryInit() { + if (cancelled || !container.isConnected) return; + if (container.offsetWidth === 0 || container.offsetHeight === 0) { + rafId = requestAnimationFrame(tryInit); + return; + } // Cleanup previous instance killFA2(); @@ -150,7 +155,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { hiddenNodesRef.current = computeHiddenNodes(graph, initialFilters); const sigma = new Sigma(graph, container, { - allowInvalidContainer: true, renderEdgeLabels: false, labelDensity: 0.5, labelRenderedSizeThreshold: 6, @@ -297,9 +301,12 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { containerRef.current.style.cursor = 'default'; } }); - }); // end requestAnimationFrame + } + + rafId = requestAnimationFrame(tryInit); return () => { + cancelled = true; cancelAnimationFrame(rafId); killFA2(); if (sigmaRef.current) { From 7c0b8e571a613173ed9cec6e87af469c6b141ca8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 09:18:11 +0300 Subject: [PATCH 10/19] fix: graph layout, node visibility and FA2 settings - Switch to CSS Grid layout for reliable content area height - Use inferSettings() for ForceAtlas2 auto-tuning based on graph structure - Enable linLogMode and adjustSizes for better node clustering - Fit camera after layout settles with animatedReset - Increase node sizes for better visibility (MIN 8, MAX 40) - Center controls at bottom, fix overlay positions --- src/pages/ReferralNetwork/ReferralNetwork.tsx | 26 +++++++++---------- .../components/NetworkGraph.tsx | 18 +++++++++---- src/pages/ReferralNetwork/utils.ts | 6 ++--- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 848ba2c..4c0f98a 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -31,10 +31,10 @@ export function ReferralNetwork() { return (
{/* Top bar */} -
+

@@ -45,11 +45,11 @@ export function ReferralNetwork() {

- {/* Main content area */} -
+ {/* Main content area — grid row takes all remaining space */} +
{/* Loading state */} {isLoading && ( -
+

{t('admin.referralNetwork.loading')}

@@ -59,35 +59,35 @@ export function ReferralNetwork() { {/* Error state */} {isError && ( -
+

{t('admin.referralNetwork.error')}

)} {/* Empty state */} {networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && ( -
+

{t('admin.referralNetwork.empty')}

)} - {/* Graph */} + {/* Graph — fills entire content area */} {networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( <> - + {/* Bottom-left: stats overlay */} -
+
{/* Bottom-right: legend (hidden on mobile to avoid overlap) */} -
+
- {/* Bottom-right on mobile, bottom-center on desktop: controls */} -
+ {/* Bottom-center: controls */} +
diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index ff3c34a..f457cd0 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react'; import Graph from 'graphology'; import Sigma from 'sigma'; import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker'; +import { inferSettings } from 'graphology-layout-forceatlas2'; import { useReferralNetworkStore } from '@/store/referralNetwork'; import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork'; import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils'; @@ -246,24 +247,31 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { // Start ForceAtlas2 in a web worker (non-blocking) if (graph.order > 0) { + const inferred = inferSettings(graph); const supervisor = new FA2LayoutSupervisor(graph, { settings: { - gravity: 0.5, - scalingRatio: 10, - barnesHutOptimize: true, - slowDown: 2, + ...inferred, + linLogMode: true, + adjustSizes: true, + barnesHutOptimize: graph.order > 100, + slowDown: 5, }, }); fa2Ref.current = supervisor; supervisor.start(); - // Kill after a fixed duration to free the web worker thread + // Kill after a fixed duration, then fit camera to all nodes fa2TimerRef.current = setTimeout(() => { if (fa2Ref.current === supervisor) { supervisor.kill(); fa2Ref.current = null; } fa2TimerRef.current = null; + + // Fit camera to show all nodes after layout settles + if (sigmaRef.current) { + sigmaRef.current.getCamera().animatedReset({ duration: 400 }); + } }, FA2_DURATION_MS); } diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index 661e966..092e356 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -46,8 +46,8 @@ export function getUserNodeColor( * Size is proportional to direct_referrals, clamped between min and max. */ export function getUserNodeSize(directReferrals: number): number { - const MIN_SIZE = 4; - const MAX_SIZE = 30; + const MIN_SIZE = 8; + const MAX_SIZE = 40; if (directReferrals === 0) return MIN_SIZE; - return Math.min(MAX_SIZE, MIN_SIZE + Math.sqrt(directReferrals) * 4); + return Math.min(MAX_SIZE, MIN_SIZE + Math.sqrt(directReferrals) * 5); } From b787726d1bbfd0371ae5b1f470777439e0a8c95c Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 19 Mar 2026 10:24:47 +0300 Subject: [PATCH 11/19] fix: referral network graph rendering and layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix top offset breakpoint: sm:top-14 → lg:top-14 to match AppShell header visibility (mobile header is lg:hidden with h-16) - add gravity to FA2 layout to prevent disconnected nodes from flying to extreme positions, making auto-rescale shrink everything to dots - clamp outlier node positions after FA2 finishes (3σ threshold) - add ResizeObserver to sync sigma canvas with container size changes - call sigma.resize() before camera reset after FA2 - increase node sizes: user min 8→12, max 40→50, campaign 18→24 - reduce FA2 duration 5s→3s and slowDown 5→2 for tighter layout --- src/pages/ReferralNetwork/ReferralNetwork.tsx | 12 +-- .../components/NetworkGraph.tsx | 89 +++++++++++++------ src/pages/ReferralNetwork/utils.ts | 4 +- 3 files changed, 63 insertions(+), 42 deletions(-) diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 4c0f98a..f09b2a8 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -31,9 +31,8 @@ export function ReferralNetwork() { return (
- {/* Top bar */}
@@ -45,9 +44,7 @@ export function ReferralNetwork() {
- {/* Main content area — grid row takes all remaining space */}
- {/* Loading state */} {isLoading && (
@@ -57,43 +54,36 @@ export function ReferralNetwork() {
)} - {/* Error state */} {isError && (

{t('admin.referralNetwork.error')}

)} - {/* Empty state */} {networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && (

{t('admin.referralNetwork.empty')}

)} - {/* Graph — fills entire content area */} {networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( <> - {/* Bottom-left: stats overlay */}
- {/* Bottom-right: legend (hidden on mobile to avoid overlap) */}
- {/* Bottom-center: controls */}
)} - {/* Right side panel */} +
, + document.body, ); } diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 0233871..be89d1a 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -21,7 +21,7 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { label: campaign.name, x: Math.random() * 100, y: Math.random() * 100, - size: 24, + size: 16, color: getCampaignColor(index), type: 'circle', nodeType: 'campaign', @@ -53,8 +53,8 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { 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, + color: edge.type === 'campaign' ? 'rgba(77, 217, 192, 0.06)' : 'rgba(255,255,255,0.03)', + size: 0.3, edgeType: edge.type, }); } @@ -183,14 +183,14 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { const sigma = new Sigma(graph, container, { renderEdgeLabels: false, - labelDensity: 0.5, - labelRenderedSizeThreshold: 6, + labelDensity: 0.12, + labelRenderedSizeThreshold: 14, zIndex: true, - defaultEdgeColor: '#ffffff10', + defaultEdgeColor: '#ffffff06', defaultNodeColor: '#6b7280', labelColor: { color: '#e8e6f0' }, labelFont: 'Inter, system-ui, sans-serif', - labelSize: 12, + labelSize: 11, stagePadding: 40, nodeReducer: (node, attrs) => { const res = { ...attrs }; diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index 703d60b..f1a89ae 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -46,8 +46,7 @@ export function getUserNodeColor( * Size is proportional to direct_referrals, clamped between min and max. */ export function getUserNodeSize(directReferrals: number): number { - const MIN_SIZE = 12; - const MAX_SIZE = 50; - if (directReferrals === 0) return MIN_SIZE; - return Math.min(MAX_SIZE, MIN_SIZE + Math.sqrt(directReferrals) * 5); + if (directReferrals === 0) return 5; + if (directReferrals <= 2) return 10; + return Math.min(40, 10 + Math.sqrt(directReferrals) * 4); } From 2adb0047dda970df58ccddbcc00193441680c7e8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 01:17:01 +0300 Subject: [PATCH 13/19] =?UTF-8?q?feat:=20add=20partner=20=E2=86=92=20campa?= =?UTF-8?q?ign=20edges=20with=20distinct=20color=20to=20referral=20network?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/ReferralNetwork/components/NetworkGraph.tsx | 9 +++++++-- src/pages/ReferralNetwork/components/NetworkLegend.tsx | 7 +++++++ src/types/referralNetwork.ts | 2 +- 7 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 33690fa..aa08f88 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1253,6 +1253,7 @@ "partner": "Partner", "topReferrer": "Top referrer (10+)", "campaignUser": "From campaign", + "partnerCampaignEdge": "Partner → Campaign", "campaignNode": "Ad campaign" }, "user": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 377b65b..6adaf93 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1087,6 +1087,7 @@ "partner": "شریک", "topReferrer": "ارجاع‌دهنده برتر (+۱۰)", "campaignUser": "از کمپین", + "partnerCampaignEdge": "شریک → کمپین", "campaignNode": "کمپین تبلیغاتی" }, "user": { diff --git a/src/locales/ru.json b/src/locales/ru.json index e43d71e..f43c612 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1274,6 +1274,7 @@ "partner": "Партнёр", "topReferrer": "Топ-реферер (10+)", "campaignUser": "Из кампании", + "partnerCampaignEdge": "Партнёр → РК", "campaignNode": "Рекламная кампания" }, "user": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 60545e9..fa558a8 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1087,6 +1087,7 @@ "partner": "合作伙伴", "topReferrer": "顶级推荐人 (10+)", "campaignUser": "来自活动", + "partnerCampaignEdge": "合作伙伴 → 活动", "campaignNode": "广告活动" }, "user": { diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index be89d1a..10cd9d8 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -53,8 +53,13 @@ function buildFullGraph(graphData: NetworkGraphData): Graph { 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.06)' : 'rgba(255,255,255,0.03)', - size: 0.3, + color: + edge.type === 'partner_campaign' + ? 'rgba(255, 138, 101, 0.5)' + : edge.type === 'campaign' + ? 'rgba(77, 217, 192, 0.06)' + : 'rgba(255,255,255,0.03)', + size: edge.type === 'partner_campaign' ? 1.5 : 0.3, edgeType: edge.type, }); } diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx index 05944c9..4bd766a 100644 --- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -38,6 +38,13 @@ export function NetworkLegend({ className }: NetworkLegendProps) { {t(item.labelKey)}
))} + {/* Partner → Campaign edge */} +
+
+ + {t('admin.referralNetwork.legend.partnerCampaignEdge')} + +
{/* Campaign node with gradient to represent varying colors */}
diff --git a/src/types/referralNetwork.ts b/src/types/referralNetwork.ts index a66f3b7..92c8ab0 100644 --- a/src/types/referralNetwork.ts +++ b/src/types/referralNetwork.ts @@ -37,7 +37,7 @@ export interface NetworkCampaignNode { export interface NetworkEdge { source: string; target: string; - type: 'referral' | 'campaign'; + type: 'referral' | 'campaign' | 'partner_campaign'; } export interface NetworkGraphData { From a6faf702ec445f899ab0a9b93d668340483c44de Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 01:50:09 +0300 Subject: [PATCH 14/19] feat: redesign referral network with scope selector - Replace full graph with scoped view (campaign/partner/user) - ScopeSelector component with tabs and searchable dropdown - Store resets all state on scope change (selection, hover, filters) - Remove dead NetworkSearch component and graphInstance globals - i18n keys for scope selector in all 4 locales --- src/api/referralNetwork.ts | 12 +- src/locales/en.json | 16 + src/locales/fa.json | 16 + src/locales/ru.json | 16 + src/locales/zh.json | 16 + src/pages/ReferralNetwork/ReferralNetwork.tsx | 91 +++-- .../components/NetworkFilters.tsx | 9 +- .../components/NetworkGraph.tsx | 10 +- .../components/NetworkSearch.tsx | 234 ------------ .../components/ScopeSelector.tsx | 358 ++++++++++++++++++ src/pages/ReferralNetwork/sigmaGlobals.ts | 14 +- src/store/referralNetwork.ts | 17 +- src/types/referralNetwork.ts | 28 ++ 13 files changed, 549 insertions(+), 288 deletions(-) delete mode 100644 src/pages/ReferralNetwork/components/NetworkSearch.tsx create mode 100644 src/pages/ReferralNetwork/components/ScopeSelector.tsx diff --git a/src/api/referralNetwork.ts b/src/api/referralNetwork.ts index 360bd66..5d63908 100644 --- a/src/api/referralNetwork.ts +++ b/src/api/referralNetwork.ts @@ -4,11 +4,19 @@ import type { NetworkUserDetail, NetworkCampaignDetail, NetworkSearchResult, + ScopeOptionsData, } from '@/types/referralNetwork'; export const referralNetworkApi = { - getNetworkGraph: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/referral-network/'); + getScopeOptions: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/referral-network/scope-options'); + return response.data; + }, + + getScopedGraph: async (scope: string, id: number): Promise => { + const response = await apiClient.get('/cabinet/admin/referral-network/scoped', { + params: { scope, id }, + }); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index aa08f88..68551ab 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1292,6 +1292,22 @@ "resetZoom": "Reset zoom", "fullscreen": "Fullscreen" }, + "scope": { + "title": "Scope", + "campaign": "Campaign", + "partner": "Partner", + "user": "User", + "searchPlaceholder": "Search...", + "selectCampaign": "Select campaign", + "selectPartner": "Select partner", + "selectUser": "Search user", + "emptyState": "Select a campaign, partner, or user to view their network", + "users": "users", + "campaigns": "campaigns", + "active": "Active", + "inactive": "Inactive", + "noResults": "Nothing found" + }, "loading": "Loading graph...", "error": "Failed to load network data", "empty": "No data to display" diff --git a/src/locales/fa.json b/src/locales/fa.json index 6adaf93..067c200 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1126,6 +1126,22 @@ "resetZoom": "بازنشانی زوم", "fullscreen": "تمام‌صفحه" }, + "scope": { + "title": "محدوده", + "campaign": "کمپین", + "partner": "شریک", + "user": "کاربر", + "searchPlaceholder": "جستجو...", + "selectCampaign": "انتخاب کمپین", + "selectPartner": "انتخاب شریک", + "selectUser": "جستجوی کاربر", + "emptyState": "یک کمپین، شریک یا کاربر را برای مشاهده شبکه انتخاب کنید", + "users": "کاربران", + "campaigns": "کمپین‌ها", + "active": "فعال", + "inactive": "غیرفعال", + "noResults": "نتیجه‌ای یافت نشد" + }, "loading": "بارگذاری گراف...", "error": "خطا در بارگذاری داده‌های شبکه", "empty": "داده‌ای برای نمایش وجود ندارد" diff --git a/src/locales/ru.json b/src/locales/ru.json index f43c612..5e50c7c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1313,6 +1313,22 @@ "resetZoom": "Сбросить зум", "fullscreen": "Полный экран" }, + "scope": { + "title": "Область", + "campaign": "Кампания", + "partner": "Партнёр", + "user": "Пользователь", + "searchPlaceholder": "Поиск...", + "selectCampaign": "Выберите кампанию", + "selectPartner": "Выберите партнёра", + "selectUser": "Найти пользователя", + "emptyState": "Выберите кампанию, партнёра или пользователя для просмотра сети", + "users": "пользователей", + "campaigns": "кампаний", + "active": "Активна", + "inactive": "Неактивна", + "noResults": "Ничего не найдено" + }, "loading": "Загрузка графа...", "error": "Ошибка загрузки данных сети", "empty": "Нет данных для отображения" diff --git a/src/locales/zh.json b/src/locales/zh.json index fa558a8..c8fa8a7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1126,6 +1126,22 @@ "resetZoom": "重置缩放", "fullscreen": "全屏" }, + "scope": { + "title": "范围", + "campaign": "活动", + "partner": "合作伙伴", + "user": "用户", + "searchPlaceholder": "搜索...", + "selectCampaign": "选择活动", + "selectPartner": "选择合作伙伴", + "selectUser": "搜索用户", + "emptyState": "选择活动、合作伙伴或用户以查看其网络", + "users": "用户", + "campaigns": "活动", + "active": "活跃", + "inactive": "非活跃", + "noResults": "未找到" + }, "loading": "加载图表...", "error": "加载网络数据失败", "empty": "无数据显示" diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 8f729de..936952a 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -5,8 +5,7 @@ import { referralNetworkApi } from '@/api/referralNetwork'; import { useReferralNetworkStore } from '@/store/referralNetwork'; import { AdminBackButton } from '@/components/admin/AdminBackButton'; import { NetworkGraph } from './components/NetworkGraph'; -import { NetworkSearch } from './components/NetworkSearch'; -import { NetworkFilters } from './components/NetworkFilters'; +import { ScopeSelector } from './components/ScopeSelector'; import { UserDetailPanel } from './components/UserDetailPanel'; import { CampaignDetailPanel } from './components/CampaignDetailPanel'; import { NetworkStats } from './components/NetworkStats'; @@ -16,14 +15,17 @@ import { NetworkControls } from './components/NetworkControls'; export function ReferralNetwork() { const { t } = useTranslation(); const selectedNode = useReferralNetworkStore((s) => s.selectedNode); + const scope = useReferralNetworkStore((s) => s.scope); + const setScope = useReferralNetworkStore((s) => s.setScope); const { data: networkData, isLoading, isError, } = useQuery({ - queryKey: ['referral-network-graph'], - queryFn: referralNetworkApi.getNetworkGraph, + queryKey: ['referral-network', 'scoped', scope?.type, scope?.id], + queryFn: () => referralNetworkApi.getScopedGraph(scope!.type, scope!.id), + enabled: scope !== null, staleTime: 120_000, }); @@ -40,13 +42,37 @@ export function ReferralNetwork() {

{t('admin.referralNetwork.title')}

- - {networkData && } +
- {isLoading && ( + {/* Empty state: no scope selected */} + {!scope && ( +
+
+ + + +

+ {t('admin.referralNetwork.scope.emptyState')} +

+
+
+ )} + + {/* Loading state */} + {scope && isLoading && (
@@ -55,36 +81,45 @@ export function ReferralNetwork() {
)} - {isError && ( + {/* Error state */} + {scope && isError && (

{t('admin.referralNetwork.error')}

)} - {networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && ( -
-

{t('admin.referralNetwork.empty')}

-
- )} - - {networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( - <> - - -
- + {/* Empty data */} + {scope && + networkData && + networkData.users.length === 0 && + networkData.campaigns.length === 0 && ( +
+

{t('admin.referralNetwork.empty')}

+ )} -
- -
+ {/* Graph + overlays */} + {scope && + networkData && + (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( + <> + -
- -
- - )} +
+ +
+
+ +
+ +
+ +
+ + )} + + {/* Detail panel (slide-in from right) */}
(null); @@ -15,8 +19,7 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) { 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); + const [isOpen, setIsOpen] = useState(false); const hasActiveFilters = filters.campaigns.length > 0 || filters.partnersOnly || filters.minReferrals > 0; diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 10cd9d8..1671e64 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -6,7 +6,7 @@ import { inferSettings } from 'graphology-layout-forceatlas2'; import { useReferralNetworkStore } from '@/store/referralNetwork'; import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork'; import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils'; -import { setSigmaInstance, setGraphInstance } from '../sigmaGlobals'; +import { setSigmaInstance } from '../sigmaGlobals'; interface NetworkGraphProps { data: NetworkGraphData; @@ -145,6 +145,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode); const setHoveredNode = useReferralNetworkStore((s) => s.setHoveredNode); + const setHighlightedNodes = useReferralNetworkStore((s) => s.setHighlightedNodes); const hoveredNodeId = useReferralNetworkStore((s) => s.hoveredNodeId); const highlightedNodes = useReferralNetworkStore((s) => s.highlightedNodes); const filters = useReferralNetworkStore((s) => s.filters); @@ -268,7 +269,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { sigmaRef.current = sigma; setSigmaInstance(sigma); - setGraphInstance(graph); if (graph.order > 0) { const inferred = inferSettings(graph); @@ -348,9 +348,11 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) { } graphRef.current = null; setSigmaInstance(null); - setGraphInstance(null); + + setHoveredNode(null); + setHighlightedNodes(new Set()); }; - }, [data, setSelectedNode, setHoveredNode, killFA2]); + }, [data, setSelectedNode, setHoveredNode, setHighlightedNodes, killFA2]); useEffect(() => { const container = containerRef.current; diff --git a/src/pages/ReferralNetwork/components/NetworkSearch.tsx b/src/pages/ReferralNetwork/components/NetworkSearch.tsx deleted file mode 100644 index 735f0f9..0000000 --- a/src/pages/ReferralNetwork/components/NetworkSearch.tsx +++ /dev/null @@ -1,234 +0,0 @@ -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(null); - const inputRef = useRef(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 ( -
-
- - - - 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 && ( - - )} - {isFetching && ( -
-
-
- )} -
- - {/* Dropdown */} - {isDropdownOpen && debouncedQuery.length >= 2 && ( -
- {totalResults === 0 && !isFetching && ( -
- {t('admin.referralNetwork.search.noResults')} -
- )} - - {searchResults?.users && searchResults.users.length > 0 && ( -
- {searchResults.users.map((user) => ( - - ))} -
- )} - - {searchResults?.campaigns && searchResults.campaigns.length > 0 && ( -
- {searchResults.users && searchResults.users.length > 0 && ( -
- )} - {searchResults.campaigns.map((campaign) => ( - - ))} -
- )} - - {totalResults > 0 && ( -
- {t('admin.referralNetwork.search.resultsCount', { count: totalResults })} -
- )} -
- )} -
- ); -} diff --git a/src/pages/ReferralNetwork/components/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx new file mode 100644 index 0000000..b1fe24e --- /dev/null +++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx @@ -0,0 +1,358 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { referralNetworkApi } from '@/api/referralNetwork'; +import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; + +interface ScopeSelectorProps { + value: ScopeSelection | null; + onSelect: (selection: ScopeSelection | null) => void; + className?: string; +} + +const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user']; + +export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps) { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState(value?.type ?? 'campaign'); + const [searchInput, setSearchInput] = useState(''); + const [debouncedUserQuery, setDebouncedUserQuery] = useState(''); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const { data: scopeOptions, isLoading: isScopeLoading } = useQuery({ + queryKey: ['referral-network', 'scope-options'], + queryFn: referralNetworkApi.getScopeOptions, + staleTime: 120_000, + }); + + // Debounce user search + useEffect(() => { + if (activeTab !== 'user') return; + const timer = setTimeout(() => { + setDebouncedUserQuery(searchInput.trim()); + }, 300); + return () => clearTimeout(timer); + }, [searchInput, activeTab]); + + const { data: userSearchResults, isFetching: isUserSearching } = useQuery({ + queryKey: ['referral-network-search', debouncedUserQuery], + queryFn: () => referralNetworkApi.search(debouncedUserQuery), + enabled: activeTab === 'user' && debouncedUserQuery.length >= 2, + staleTime: 30_000, + }); + + // Close dropdown on outside click + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsDropdownOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Filter campaigns/partners by local search input + const filteredCampaigns = useMemo(() => { + if (!scopeOptions) return []; + const q = searchInput.toLowerCase().trim(); + if (!q) return scopeOptions.campaigns; + return scopeOptions.campaigns.filter( + (c) => c.name.toLowerCase().includes(q) || c.start_parameter.toLowerCase().includes(q), + ); + }, [scopeOptions, searchInput]); + + const filteredPartners = useMemo(() => { + if (!scopeOptions) return []; + const q = searchInput.toLowerCase().trim(); + if (!q) return scopeOptions.partners; + return scopeOptions.partners.filter( + (p) => + p.display_name.toLowerCase().includes(q) || + (p.username && p.username.toLowerCase().includes(q)), + ); + }, [scopeOptions, searchInput]); + + function handleTabChange(tab: ScopeType) { + setActiveTab(tab); + setSearchInput(''); + setDebouncedUserQuery(''); + setIsDropdownOpen(true); + inputRef.current?.focus(); + } + + function handleSelectCampaign(campaign: { id: number; name: string }) { + onSelect({ type: 'campaign', id: campaign.id, label: campaign.name }); + setIsDropdownOpen(false); + setSearchInput(''); + } + + function handleSelectPartner(partner: { id: number; display_name: string }) { + onSelect({ type: 'partner', id: partner.id, label: partner.display_name }); + setIsDropdownOpen(false); + setSearchInput(''); + } + + function handleSelectUser(user: { id: number; display_name: string }) { + onSelect({ type: 'user', id: user.id, label: user.display_name }); + setIsDropdownOpen(false); + setSearchInput(''); + } + + function handleClear() { + onSelect(null); + setSearchInput(''); + setDebouncedUserQuery(''); + setIsDropdownOpen(false); + } + + const tabLabels: Record = { + campaign: t('admin.referralNetwork.scope.campaign'), + partner: t('admin.referralNetwork.scope.partner'), + user: t('admin.referralNetwork.scope.user'), + }; + + const placeholders: Record = { + campaign: t('admin.referralNetwork.scope.selectCampaign'), + partner: t('admin.referralNetwork.scope.selectPartner'), + user: t('admin.referralNetwork.scope.selectUser'), + }; + + // When scope is selected, show compact display + if (value) { + return ( +
+ + {tabLabels[value.type]} + + {value.label} + +
+ ); + } + + const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading; + + return ( +
+ {/* Tab bar + search input row */} +
+ {/* Tabs */} +
+ {SCOPE_TABS.map((tab) => ( + + ))} +
+ + {/* Search input */} +
+ + + + { + setSearchInput(e.target.value); + if (!isDropdownOpen) setIsDropdownOpen(true); + }} + onFocus={() => setIsDropdownOpen(true)} + placeholder={placeholders[activeTab]} + className="w-full rounded-lg border border-dark-700/50 bg-dark-800/50 py-1.5 pl-8 pr-8 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" + /> + {isLoading && ( +
+
+
+ )} +
+
+ + {/* Dropdown */} + {isDropdownOpen && ( +
+ {activeTab === 'campaign' && renderCampaignList()} + {activeTab === 'partner' && renderPartnerList()} + {activeTab === 'user' && renderUserList()} +
+ )} +
+ ); + + function renderCampaignList() { + if (isScopeLoading) { + return ( +
+
+
+ ); + } + + if (filteredCampaigns.length === 0) { + return ( +
+ {t('admin.referralNetwork.scope.noResults')} +
+ ); + } + + return filteredCampaigns.map((campaign) => ( + + )); + } + + function renderPartnerList() { + if (isScopeLoading) { + return ( +
+
+
+ ); + } + + if (filteredPartners.length === 0) { + return ( +
+ {t('admin.referralNetwork.scope.noResults')} +
+ ); + } + + return filteredPartners.map((partner) => ( + + )); + } + + function renderUserList() { + if (debouncedUserQuery.length < 2) { + return ( +
+ {t('admin.referralNetwork.scope.selectUser')} +
+ ); + } + + if (isUserSearching) { + return ( +
+
+
+ ); + } + + const users = userSearchResults?.users ?? []; + + if (users.length === 0) { + return ( +
+ {t('admin.referralNetwork.scope.noResults')} +
+ ); + } + + return users.map((user) => ( + + )); + } +} diff --git a/src/pages/ReferralNetwork/sigmaGlobals.ts b/src/pages/ReferralNetwork/sigmaGlobals.ts index b702492..1052628 100644 --- a/src/pages/ReferralNetwork/sigmaGlobals.ts +++ b/src/pages/ReferralNetwork/sigmaGlobals.ts @@ -1,12 +1,10 @@ import type Sigma from 'sigma'; -import type Graph from 'graphology'; /** - * We store sigma and graph instances on a module-level variable - * so that search and controls can access them without prop drilling. + * We store the sigma instance on a module-level variable + * so that controls can access it without prop drilling. */ let sigmaInstance: Sigma | null = null; -let graphInstance: Graph | null = null; export function setSigmaInstance(sigma: Sigma | null): void { sigmaInstance = sigma; @@ -15,11 +13,3 @@ export function setSigmaInstance(sigma: Sigma | null): void { export function getSigmaInstance(): Sigma | null { return sigmaInstance; } - -export function setGraphInstance(graph: Graph | null): void { - graphInstance = graph; -} - -export function getGraphInstance(): Graph | null { - return graphInstance; -} diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts index 903f22e..48295c0 100644 --- a/src/store/referralNetwork.ts +++ b/src/store/referralNetwork.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { SelectedNode, NetworkFilters } from '@/types/referralNetwork'; +import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork'; const DEFAULT_FILTERS: NetworkFilters = { campaigns: [], @@ -12,14 +12,14 @@ interface ReferralNetworkState { hoveredNodeId: string | null; highlightedNodes: Set; filters: NetworkFilters; - isFiltersOpen: boolean; + scope: ScopeSelection | null; setSelectedNode: (node: SelectedNode) => void; setHoveredNode: (id: string | null) => void; setHighlightedNodes: (nodes: Set) => void; updateFilters: (filters: Partial) => void; resetFilters: () => void; - setIsFiltersOpen: (open: boolean) => void; + setScope: (scope: ScopeSelection | null) => void; } export const useReferralNetworkStore = create()((set) => ({ @@ -27,7 +27,7 @@ export const useReferralNetworkStore = create()((set) => ( hoveredNodeId: null, highlightedNodes: new Set(), filters: { ...DEFAULT_FILTERS }, - isFiltersOpen: false, + scope: null, setSelectedNode: (node) => set({ selectedNode: node }), @@ -42,5 +42,12 @@ export const useReferralNetworkStore = create()((set) => ( resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), - setIsFiltersOpen: (open) => set({ isFiltersOpen: open }), + setScope: (scope) => + set({ + scope, + selectedNode: null, + hoveredNodeId: null, + highlightedNodes: new Set(), + filters: { ...DEFAULT_FILTERS }, + }), })); diff --git a/src/types/referralNetwork.ts b/src/types/referralNetwork.ts index 92c8ab0..10802b4 100644 --- a/src/types/referralNetwork.ts +++ b/src/types/referralNetwork.ts @@ -100,3 +100,31 @@ export interface NetworkFilters { partnersOnly: boolean; minReferrals: number; } + +export interface CampaignOption { + id: number; + name: string; + start_parameter: string; + is_active: boolean; + direct_users: number; +} + +export interface PartnerOption { + id: number; + display_name: string; + username: string | null; + campaign_count: number; +} + +export interface ScopeOptionsData { + campaigns: CampaignOption[]; + partners: PartnerOption[]; +} + +export type ScopeType = 'campaign' | 'partner' | 'user'; + +export interface ScopeSelection { + type: ScopeType; + id: number; + label: string; +} From db76cd0c647fb7be986bdebad4cf7f56b554334c Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 02:31:11 +0300 Subject: [PATCH 15/19] feat: multi-select scope for referral network graph Replace single-select scope with multi-select supporting multiple campaigns, partners, and users simultaneously. Chips UI with remove buttons, clear-all, sorted query keys for cache stability. --- src/api/referralNetwork.ts | 16 +- src/locales/en.json | 5 +- src/locales/fa.json | 5 +- src/locales/ru.json | 5 +- src/locales/zh.json | 5 +- src/pages/ReferralNetwork/ReferralNetwork.tsx | 30 +- .../components/ScopeSelector.tsx | 449 +++++++++++------- src/store/referralNetwork.ts | 39 +- 8 files changed, 364 insertions(+), 190 deletions(-) diff --git a/src/api/referralNetwork.ts b/src/api/referralNetwork.ts index 5d63908..9751d08 100644 --- a/src/api/referralNetwork.ts +++ b/src/api/referralNetwork.ts @@ -5,6 +5,7 @@ import type { NetworkCampaignDetail, NetworkSearchResult, ScopeOptionsData, + ScopeSelection, } from '@/types/referralNetwork'; export const referralNetworkApi = { @@ -13,9 +14,20 @@ export const referralNetworkApi = { return response.data; }, - getScopedGraph: async (scope: string, id: number): Promise => { + getScopedGraph: async (selections: ScopeSelection[]): Promise => { + const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id); + const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id); + const userIds = selections.filter((s) => s.type === 'user').map((s) => s.id); + const response = await apiClient.get('/cabinet/admin/referral-network/scoped', { - params: { scope, id }, + params: { + campaign_ids: campaignIds, + partner_ids: partnerIds, + user_ids: userIds, + }, + paramsSerializer: { + indexes: null, + }, }); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index 68551ab..80d79fa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1306,7 +1306,10 @@ "campaigns": "campaigns", "active": "Active", "inactive": "Inactive", - "noResults": "Nothing found" + "noResults": "Nothing found", + "removeItem": "Remove {{label}}", + "clearAll": "Clear all selections", + "addScope": "Add scope" }, "loading": "Loading graph...", "error": "Failed to load network data", diff --git a/src/locales/fa.json b/src/locales/fa.json index 067c200..47f419e 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1140,7 +1140,10 @@ "campaigns": "کمپین‌ها", "active": "فعال", "inactive": "غیرفعال", - "noResults": "نتیجه‌ای یافت نشد" + "noResults": "نتیجه‌ای یافت نشد", + "removeItem": "حذف {{label}}", + "clearAll": "پاک کردن همه انتخاب‌ها", + "addScope": "افزودن محدوده" }, "loading": "بارگذاری گراف...", "error": "خطا در بارگذاری داده‌های شبکه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5e50c7c..11dbf8f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1327,7 +1327,10 @@ "campaigns": "кампаний", "active": "Активна", "inactive": "Неактивна", - "noResults": "Ничего не найдено" + "noResults": "Ничего не найдено", + "removeItem": "Удалить {{label}}", + "clearAll": "Очистить все выбранные", + "addScope": "Добавить область" }, "loading": "Загрузка графа...", "error": "Ошибка загрузки данных сети", diff --git a/src/locales/zh.json b/src/locales/zh.json index c8fa8a7..e7543c6 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1140,7 +1140,10 @@ "campaigns": "活动", "active": "活跃", "inactive": "非活跃", - "noResults": "未找到" + "noResults": "未找到", + "removeItem": "移除 {{label}}", + "clearAll": "清除所有选择", + "addScope": "添加范围" }, "loading": "加载图表...", "error": "加载网络数据失败", diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 936952a..4e08d27 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -16,16 +16,20 @@ export function ReferralNetwork() { const { t } = useTranslation(); const selectedNode = useReferralNetworkStore((s) => s.selectedNode); const scope = useReferralNetworkStore((s) => s.scope); - const setScope = useReferralNetworkStore((s) => s.setScope); + const addScope = useReferralNetworkStore((s) => s.addScope); + const removeScope = useReferralNetworkStore((s) => s.removeScope); + const clearScope = useReferralNetworkStore((s) => s.clearScope); + + const hasScope = scope.length > 0; const { data: networkData, isLoading, isError, } = useQuery({ - queryKey: ['referral-network', 'scoped', scope?.type, scope?.id], - queryFn: () => referralNetworkApi.getScopedGraph(scope!.type, scope!.id), - enabled: scope !== null, + queryKey: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()], + queryFn: () => referralNetworkApi.getScopedGraph(scope), + enabled: hasScope, staleTime: 120_000, }); @@ -42,13 +46,19 @@ export function ReferralNetwork() {

{t('admin.referralNetwork.title')}

- +
{/* Empty state: no scope selected */} - {!scope && ( + {!hasScope && (
@@ -82,14 +92,14 @@ export function ReferralNetwork() { )} {/* Error state */} - {scope && isError && ( + {hasScope && isError && (

{t('admin.referralNetwork.error')}

)} {/* Empty data */} - {scope && + {hasScope && networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && ( @@ -99,7 +109,7 @@ export function ReferralNetwork() { )} {/* Graph + overlays */} - {scope && + {hasScope && networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( <> diff --git a/src/pages/ReferralNetwork/components/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx index b1fe24e..02c00a8 100644 --- a/src/pages/ReferralNetwork/components/ScopeSelector.tsx +++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx @@ -5,16 +5,24 @@ import { referralNetworkApi } from '@/api/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; interface ScopeSelectorProps { - value: ScopeSelection | null; - onSelect: (selection: ScopeSelection | null) => void; + value: ScopeSelection[]; + onAdd: (selection: ScopeSelection) => void; + onRemove: (type: ScopeSelection['type'], id: number) => void; + onClear: () => void; className?: string; } const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user']; -export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps) { +const CHIP_COLORS: Record = { + campaign: 'bg-success-500/20 text-success-400', + partner: 'bg-warning-500/20 text-warning-400', + user: 'bg-accent-500/20 text-accent-400', +}; + +export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState(value?.type ?? 'campaign'); + const [activeTab, setActiveTab] = useState('campaign'); const [searchInput, setSearchInput] = useState(''); const [debouncedUserQuery, setDebouncedUserQuery] = useState(''); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -43,17 +51,30 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps staleTime: 30_000, }); - // Close dropdown on outside click + // Close dropdown on outside click or Escape key useEffect(() => { function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsDropdownOpen(false); } } + function handleEscape(e: KeyboardEvent) { + if (e.key === 'Escape') { + setIsDropdownOpen(false); + } + } document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; }, []); + // Check if item is already selected + const isSelected = (type: ScopeType, id: number): boolean => + value.some((s) => s.type === type && s.id === id); + // Filter campaigns/partners by local search input const filteredCampaigns = useMemo(() => { if (!scopeOptions) return []; @@ -84,28 +105,29 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps } function handleSelectCampaign(campaign: { id: number; name: string }) { - onSelect({ type: 'campaign', id: campaign.id, label: campaign.name }); - setIsDropdownOpen(false); - setSearchInput(''); + if (isSelected('campaign', campaign.id)) { + onRemove('campaign', campaign.id); + } else { + onAdd({ type: 'campaign', id: campaign.id, label: campaign.name }); + } } function handleSelectPartner(partner: { id: number; display_name: string }) { - onSelect({ type: 'partner', id: partner.id, label: partner.display_name }); - setIsDropdownOpen(false); - setSearchInput(''); + if (isSelected('partner', partner.id)) { + onRemove('partner', partner.id); + } else { + onAdd({ type: 'partner', id: partner.id, label: partner.display_name }); + } } function handleSelectUser(user: { id: number; display_name: string }) { - onSelect({ type: 'user', id: user.id, label: user.display_name }); - setIsDropdownOpen(false); - setSearchInput(''); - } - - function handleClear() { - onSelect(null); + if (isSelected('user', user.id)) { + onRemove('user', user.id); + } else { + onAdd({ type: 'user', id: user.id, label: user.display_name }); + } setSearchInput(''); setDebouncedUserQuery(''); - setIsDropdownOpen(false); } const tabLabels: Record = { @@ -120,18 +142,69 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps user: t('admin.referralNetwork.scope.selectUser'), }; - // When scope is selected, show compact display - if (value) { - return ( -
- - {tabLabels[value.type]} - - {value.label} + const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading; + + return ( +
+ {/* Chips row + search trigger */} +
+ {/* Selected chips */} + {value.length > 0 && ( +
+ {value.map((item) => ( + + {item.label} + + + ))} + {/* Clear all */} + +
+ )} + + {/* Add button (always visible) */}
- ); - } - - const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading; - - return ( -
- {/* Tab bar + search input row */} -
- {/* Tabs */} -
- {SCOPE_TABS.map((tab) => ( - - ))} -
- - {/* Search input */} -
- - - - { - setSearchInput(e.target.value); - if (!isDropdownOpen) setIsDropdownOpen(true); - }} - onFocus={() => setIsDropdownOpen(true)} - placeholder={placeholders[activeTab]} - className="w-full rounded-lg border border-dark-700/50 bg-dark-800/50 py-1.5 pl-8 pr-8 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" - /> - {isLoading && ( -
-
-
- )} -
-
{/* Dropdown */} {isDropdownOpen && ( -
- {activeTab === 'campaign' && renderCampaignList()} - {activeTab === 'partner' && renderPartnerList()} - {activeTab === 'user' && renderUserList()} +
+ {/* Tab bar + search input */} +
+
+ {SCOPE_TABS.map((tab) => ( + + ))} +
+ +
+ + + + setSearchInput(e.target.value)} + placeholder={placeholders[activeTab]} + className="w-full rounded-lg border border-dark-700/50 bg-dark-900/50 py-1.5 pl-8 pr-8 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" + /> + {isLoading && ( +
+
+
+ )} +
+
+ + {/* List */} +
+ {activeTab === 'campaign' && renderCampaignList()} + {activeTab === 'partner' && renderPartnerList()} + {activeTab === 'user' && renderUserList()} +
)}
@@ -234,36 +298,53 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps ); } - return filteredCampaigns.map((campaign) => ( - - )); +
+ {selected ? ( + + + + ) : ( + 'C' + )} +
+
+

{campaign.name}

+

+ {campaign.start_parameter} + {' / '} + {campaign.direct_users} {t('admin.referralNetwork.scope.users')} +

+
+ + {campaign.is_active + ? t('admin.referralNetwork.scope.active') + : t('admin.referralNetwork.scope.inactive')} + + + ); + }); } function renderPartnerList() { @@ -283,25 +364,42 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps ); } - return filteredPartners.map((partner) => ( - - )); + return filteredPartners.map((partner) => { + const selected = isSelected('partner', partner.id); + return ( + + ); + }); } function renderUserList() { @@ -331,28 +429,45 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps ); } - return users.map((user) => ( - - )); + return users.map((user) => { + const selected = isSelected('user', user.id); + return ( + + ); + }); } } diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts index 48295c0..14ffa09 100644 --- a/src/store/referralNetwork.ts +++ b/src/store/referralNetwork.ts @@ -1,6 +1,8 @@ import { create } from 'zustand'; import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork'; +const MAX_SCOPE_ITEMS = 50; + const DEFAULT_FILTERS: NetworkFilters = { campaigns: [], partnersOnly: false, @@ -12,14 +14,16 @@ interface ReferralNetworkState { hoveredNodeId: string | null; highlightedNodes: Set; filters: NetworkFilters; - scope: ScopeSelection | null; + scope: ScopeSelection[]; setSelectedNode: (node: SelectedNode) => void; setHoveredNode: (id: string | null) => void; setHighlightedNodes: (nodes: Set) => void; updateFilters: (filters: Partial) => void; resetFilters: () => void; - setScope: (scope: ScopeSelection | null) => void; + addScope: (item: ScopeSelection) => void; + removeScope: (type: ScopeSelection['type'], id: number) => void; + clearScope: () => void; } export const useReferralNetworkStore = create()((set) => ({ @@ -27,12 +31,10 @@ export const useReferralNetworkStore = create()((set) => ( hoveredNodeId: null, highlightedNodes: new Set(), filters: { ...DEFAULT_FILTERS }, - scope: null, + scope: [], setSelectedNode: (node) => set({ selectedNode: node }), - setHoveredNode: (id) => set({ hoveredNodeId: id }), - setHighlightedNodes: (nodes) => set({ highlightedNodes: nodes }), updateFilters: (partial) => @@ -42,9 +44,32 @@ export const useReferralNetworkStore = create()((set) => ( resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), - setScope: (scope) => + addScope: (item) => + set((state) => { + const exists = state.scope.some((s) => s.type === item.type && s.id === item.id); + if (exists) return state; + if (state.scope.length >= MAX_SCOPE_ITEMS) return state; + return { + scope: [...state.scope, item], + selectedNode: null, + hoveredNodeId: null, + highlightedNodes: new Set(), + filters: { ...DEFAULT_FILTERS }, + }; + }), + + removeScope: (type, id) => + set((state) => ({ + scope: state.scope.filter((s) => !(s.type === type && s.id === id)), + selectedNode: null, + hoveredNodeId: null, + highlightedNodes: new Set(), + filters: { ...DEFAULT_FILTERS }, + })), + + clearScope: () => set({ - scope, + scope: [], selectedNode: null, hoveredNodeId: null, highlightedNodes: new Set(), From 2780898d1c4944d69045a3271c1bbd67ef72c47a Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 02:44:29 +0300 Subject: [PATCH 16/19] fix: scope selector UX, ARIA, and code quality improvements - removeScope preserves graph state when items remain (only resets on empty) - Extract shared CheckIcon, CloseIcon, Spinner, ScopeListItem components - Unify three handleSelect* into single handleToggle - Fix shared mutable Set in reset state (factory function) - Add MAX_SCOPE_ITEMS UI feedback (disabled button, warning banner) - Correct ARIA: dialog > tablist + listbox > option structure - Event listeners only attached when dropdown is open - isSelected uses Set lookup via useMemo instead of O(n) scan - Clear user search only on add, not on deselect --- src/locales/en.json | 3 +- src/locales/fa.json | 3 +- src/locales/ru.json | 3 +- src/locales/zh.json | 3 +- .../components/ScopeSelector.tsx | 412 +++++++++--------- src/store/referralNetwork.ts | 35 +- 6 files changed, 230 insertions(+), 229 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 80d79fa..6e338c3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1309,7 +1309,8 @@ "noResults": "Nothing found", "removeItem": "Remove {{label}}", "clearAll": "Clear all selections", - "addScope": "Add scope" + "addScope": "Add scope", + "maxReached": "Maximum {{max}} items" }, "loading": "Loading graph...", "error": "Failed to load network data", diff --git a/src/locales/fa.json b/src/locales/fa.json index 47f419e..797c3e9 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1143,7 +1143,8 @@ "noResults": "نتیجه‌ای یافت نشد", "removeItem": "حذف {{label}}", "clearAll": "پاک کردن همه انتخاب‌ها", - "addScope": "افزودن محدوده" + "addScope": "افزودن محدوده", + "maxReached": "حداکثر {{max}} مورد" }, "loading": "بارگذاری گراف...", "error": "خطا در بارگذاری داده‌های شبکه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 11dbf8f..5137d97 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1330,7 +1330,8 @@ "noResults": "Ничего не найдено", "removeItem": "Удалить {{label}}", "clearAll": "Очистить все выбранные", - "addScope": "Добавить область" + "addScope": "Добавить область", + "maxReached": "Максимум {{max}} элементов" }, "loading": "Загрузка графа...", "error": "Ошибка загрузки данных сети", diff --git a/src/locales/zh.json b/src/locales/zh.json index e7543c6..734a8bb 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1143,7 +1143,8 @@ "noResults": "未找到", "removeItem": "移除 {{label}}", "clearAll": "清除所有选择", - "addScope": "添加范围" + "addScope": "添加范围", + "maxReached": "最多 {{max}} 个项目" }, "loading": "加载图表...", "error": "加载网络数据失败", diff --git a/src/pages/ReferralNetwork/components/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx index 02c00a8..e35f9e0 100644 --- a/src/pages/ReferralNetwork/components/ScopeSelector.tsx +++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { referralNetworkApi } from '@/api/referralNetwork'; +import { MAX_SCOPE_ITEMS } from '@/store/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; interface ScopeSelectorProps { @@ -20,6 +21,82 @@ const CHIP_COLORS: Record = { user: 'bg-accent-500/20 text-accent-400', }; +// Reuse CHIP_COLORS for avatar backgrounds (same palette) +const AVATAR_COLORS = CHIP_COLORS; + +const AVATAR_LETTERS: Record = { + campaign: 'C', + partner: 'P', + user: 'U', +}; + +function CheckIcon() { + return ( + + + + ); +} + +function CloseIcon({ className = 'h-3 w-3' }: { className?: string }) { + return ( + + + + ); +} + +function Spinner({ size = 'h-5 w-5' }: { size?: string }) { + return ( +
+ ); +} + +function EmptyMessage({ text }: { text: string }) { + return
{text}
; +} + +interface ScopeListItemProps { + type: ScopeType; + selected: boolean; + onClick: () => void; + title: string; + subtitle: string; + badge?: React.ReactNode; +} + +function ScopeListItem({ type, selected, onClick, title, subtitle, badge }: ScopeListItemProps) { + return ( + + ); +} + export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('campaign'); @@ -29,6 +106,8 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc const containerRef = useRef(null); const inputRef = useRef(null); + const isMaxReached = value.length >= MAX_SCOPE_ITEMS; + const { data: scopeOptions, isLoading: isScopeLoading } = useQuery({ queryKey: ['referral-network', 'scope-options'], queryFn: referralNetworkApi.getScopeOptions, @@ -51,8 +130,16 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc staleTime: 30_000, }); - // Close dropdown on outside click or Escape key + // Focus input when dropdown opens useEffect(() => { + if (isDropdownOpen) { + inputRef.current?.focus(); + } + }, [isDropdownOpen]); + + // Close dropdown on outside click or Escape key (only when open) + useEffect(() => { + if (!isDropdownOpen) return; function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsDropdownOpen(false); @@ -69,11 +156,14 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); }; - }, []); + }, [isDropdownOpen]); - // Check if item is already selected - const isSelected = (type: ScopeType, id: number): boolean => - value.some((s) => s.type === type && s.id === id); + const selectedSet = useMemo(() => new Set(value.map((s) => `${s.type}:${s.id}`)), [value]); + + const isSelected = useCallback( + (type: ScopeType, id: number): boolean => selectedSet.has(`${type}:${id}`), + [selectedSet], + ); // Filter campaigns/partners by local search input const filteredCampaigns = useMemo(() => { @@ -104,49 +194,42 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc inputRef.current?.focus(); } - function handleSelectCampaign(campaign: { id: number; name: string }) { - if (isSelected('campaign', campaign.id)) { - onRemove('campaign', campaign.id); + function handleToggle(type: ScopeType, id: number, label: string) { + if (isSelected(type, id)) { + onRemove(type, id); } else { - onAdd({ type: 'campaign', id: campaign.id, label: campaign.name }); + if (isMaxReached) return; + onAdd({ type, id, label }); + if (type === 'user') { + setSearchInput(''); + setDebouncedUserQuery(''); + } } } - function handleSelectPartner(partner: { id: number; display_name: string }) { - if (isSelected('partner', partner.id)) { - onRemove('partner', partner.id); - } else { - onAdd({ type: 'partner', id: partner.id, label: partner.display_name }); - } - } + const tabLabels = useMemo>( + () => ({ + campaign: t('admin.referralNetwork.scope.campaign'), + partner: t('admin.referralNetwork.scope.partner'), + user: t('admin.referralNetwork.scope.user'), + }), + [t], + ); - function handleSelectUser(user: { id: number; display_name: string }) { - if (isSelected('user', user.id)) { - onRemove('user', user.id); - } else { - onAdd({ type: 'user', id: user.id, label: user.display_name }); - } - setSearchInput(''); - setDebouncedUserQuery(''); - } - - const tabLabels: Record = { - campaign: t('admin.referralNetwork.scope.campaign'), - partner: t('admin.referralNetwork.scope.partner'), - user: t('admin.referralNetwork.scope.user'), - }; - - const placeholders: Record = { - campaign: t('admin.referralNetwork.scope.selectCampaign'), - partner: t('admin.referralNetwork.scope.selectPartner'), - user: t('admin.referralNetwork.scope.selectUser'), - }; + const placeholders = useMemo>( + () => ({ + campaign: t('admin.referralNetwork.scope.selectCampaign'), + partner: t('admin.referralNetwork.scope.selectPartner'), + user: t('admin.referralNetwork.scope.selectUser'), + }), + [t], + ); const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading; return (
- {/* Chips row + search trigger */} + {/* Chips row + add trigger */}
{/* Selected chips */} {value.length > 0 && ( @@ -162,49 +245,33 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc aria-label={t('admin.referralNetwork.scope.removeItem', { label: item.label })} className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-white/10" > - - - + ))} - {/* Clear all */} - + + + )}
)} - {/* Add button (always visible) */} + {/* Add button */}
{/* List */} -
+
{activeTab === 'campaign' && renderCampaignList()} {activeTab === 'partner' && renderPartnerList()} {activeTab === 'user' && renderUserList()} @@ -285,52 +368,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc if (isScopeLoading) { return (
-
+
); } if (filteredCampaigns.length === 0) { - return ( -
- {t('admin.referralNetwork.scope.noResults')} -
- ); + return ; } - return filteredCampaigns.map((campaign) => { - const selected = isSelected('campaign', campaign.id); - return ( - - ); - }); + } + /> + )); } function renderPartnerList() { if (isScopeLoading) { return (
-
+
); } if (filteredPartners.length === 0) { - return ( -
- {t('admin.referralNetwork.scope.noResults')} -
- ); + return ; } - return filteredPartners.map((partner) => { - const selected = isSelected('partner', partner.id); - return ( - - ); - }); + return filteredPartners.map((partner) => ( + handleToggle('partner', partner.id, partner.display_name)} + title={partner.display_name} + subtitle={`${partner.username ? `@${partner.username} / ` : ''}${partner.campaign_count} ${t('admin.referralNetwork.scope.campaigns')}`} + /> + )); } function renderUserList() { if (debouncedUserQuery.length < 2) { - return ( -
- {t('admin.referralNetwork.scope.selectUser')} -
- ); + return ; } if (isUserSearching) { return (
-
+
); } @@ -422,52 +443,25 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc const users = userSearchResults?.users ?? []; if (users.length === 0) { - return ( -
- {t('admin.referralNetwork.scope.noResults')} -
- ); + return ; } - return users.map((user) => { - const selected = isSelected('user', user.id); - return ( - - ); - }); + ) : undefined + } + /> + )); } } diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts index 14ffa09..8501fdc 100644 --- a/src/store/referralNetwork.ts +++ b/src/store/referralNetwork.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork'; -const MAX_SCOPE_ITEMS = 50; +export const MAX_SCOPE_ITEMS = 50; const DEFAULT_FILTERS: NetworkFilters = { campaigns: [], @@ -9,6 +9,15 @@ const DEFAULT_FILTERS: NetworkFilters = { minReferrals: 0, }; +function resetGraphState() { + return { + selectedNode: null as SelectedNode, + hoveredNodeId: null as string | null, + highlightedNodes: new Set(), + filters: { ...DEFAULT_FILTERS }, + }; +} + interface ReferralNetworkState { selectedNode: SelectedNode; hoveredNodeId: string | null; @@ -51,28 +60,22 @@ export const useReferralNetworkStore = create()((set) => ( if (state.scope.length >= MAX_SCOPE_ITEMS) return state; return { scope: [...state.scope, item], - selectedNode: null, - hoveredNodeId: null, - highlightedNodes: new Set(), - filters: { ...DEFAULT_FILTERS }, + ...resetGraphState(), }; }), removeScope: (type, id) => - set((state) => ({ - scope: state.scope.filter((s) => !(s.type === type && s.id === id)), - selectedNode: null, - hoveredNodeId: null, - highlightedNodes: new Set(), - filters: { ...DEFAULT_FILTERS }, - })), + set((state) => { + const next = state.scope.filter((s) => !(s.type === type && s.id === id)); + if (next.length === 0) { + return { scope: next, ...resetGraphState() }; + } + return { scope: next }; + }), clearScope: () => set({ scope: [], - selectedNode: null, - hoveredNodeId: null, - highlightedNodes: new Set(), - filters: { ...DEFAULT_FILTERS }, + ...resetGraphState(), }), })); From 33486a09d01b19961e02d4d72b63710e7cf8bc8f Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 04:23:45 +0300 Subject: [PATCH 17/19] fix: adapt referral network for Telegram MiniApp safe areas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract useHeaderHeight() hook — single source of truth for header height across AppShell, ReferralNetwork, and TicketNotificationBell - Portal top offset now computed dynamically from TG safe area insets instead of hardcoded top-16 (fixes overlap with TG header bar) - Two-row mobile layout: title + selector on separate rows so dropdown gets full width on small screens - Add MOBILE_HEADER_HEIGHT_PX and DESKTOP_HEADER_HEIGHT_PX to constants - Fix TicketNotificationBell using hardcoded 45px for all platforms --- src/components/TicketNotificationBell.tsx | 15 +++-------- src/components/layout/AppShell/AppShell.tsx | 12 +++------ src/config/constants.ts | 2 ++ src/hooks/useHeaderHeight.ts | 25 +++++++++++++++++++ src/pages/ReferralNetwork/ReferralNetwork.tsx | 19 +++++++++----- 5 files changed, 47 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useHeaderHeight.ts diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index 24018a8..e840bc9 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -6,7 +6,7 @@ import { ticketNotificationsApi } from '../api/ticketNotifications'; import { useAuthStore } from '../store/auth'; import { useToast } from './Toast'; import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; -import { useTelegramSDK } from '../hooks/useTelegramSDK'; +import { useHeaderHeight } from '../hooks/useHeaderHeight'; import type { TicketNotification } from '../types'; const BellIcon = () => ( @@ -37,12 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi const { showToast } = useToast(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK(); - - // Calculate dropdown top position (account for fullscreen safe area + TG buttons) - const dropdownTop = isFullscreen - ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header - : 64; // default header height + const { mobile: dropdownTop } = useHeaderHeight(); // Show toast for WebSocket notification const showWSNotificationToast = useCallback( @@ -259,10 +254,8 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi {/* Dropdown */} {isOpen && (
{/* Header */}
diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 80abd12..9441ed5 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { useAuthStore } from '@/store/auth'; import { useHaptic } from '@/platform'; import { useTelegramSDK } from '@/hooks/useTelegramSDK'; +import { useHeaderHeight } from '@/hooks/useHeaderHeight'; import { useTheme } from '@/hooks/useTheme'; import { useBranding } from '@/hooks/useBranding'; import { useFeatureFlags } from '@/hooks/useFeatureFlags'; @@ -13,7 +14,6 @@ import { useScrollRestoration } from '@/hooks/useScrollRestoration'; import { themeColorsApi } from '@/api/themeColors'; import { isLogoPreloaded } from '@/api/branding'; import { cn } from '@/lib/utils'; -import { UI } from '@/config/constants'; import WebSocketNotifications from '@/components/WebSocketNotifications'; import CampaignBonusNotifier from '@/components/CampaignBonusNotifier'; @@ -198,6 +198,7 @@ export function AppShell({ children }: AppShellProps) { const logout = useAuthStore((state) => state.logout); const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } = useTelegramSDK(); + const { mobile: headerHeight } = useHeaderHeight(); const haptic = useHaptic(); const { toggleTheme, isDark } = useTheme(); @@ -269,14 +270,7 @@ export function AppShell({ children }: AppShellProps) { haptic.impact('light'); }; - // Calculate header height based on fullscreen mode (only on mobile Telegram) - // On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header - // On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px) - const telegramHeaderHeight = - platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX; - const headerHeight = isMobileFullscreen - ? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight - : 64; + // headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen return (
diff --git a/src/config/constants.ts b/src/config/constants.ts index 2b8f754..21e4f29 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -17,6 +17,8 @@ export const UI = { RESEND_COOLDOWN_SEC: 60, TELEGRAM_HEADER_ANDROID_PX: 48, TELEGRAM_HEADER_IOS_PX: 45, + MOBILE_HEADER_HEIGHT_PX: 64, + DESKTOP_HEADER_HEIGHT_PX: 56, } as const; // API diff --git a/src/hooks/useHeaderHeight.ts b/src/hooks/useHeaderHeight.ts new file mode 100644 index 0000000..8b1b0d6 --- /dev/null +++ b/src/hooks/useHeaderHeight.ts @@ -0,0 +1,25 @@ +import { useTelegramSDK } from '@/hooks/useTelegramSDK'; +import { UI } from '@/config/constants'; + +/** + * Computes the app header height in pixels, accounting for + * Telegram MiniApp safe area insets in fullscreen mode. + * + * Desktop: 56px (h-14). Mobile: 64px (h-16) + safe area + TG header when fullscreen. + */ +export function useHeaderHeight(): { mobile: number; desktop: number } { + const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } = + useTelegramSDK(); + const isMobileFullscreen = isFullscreen && isMobile; + + const telegramHeaderHeight = + platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX; + + const mobile = isMobileFullscreen + ? UI.MOBILE_HEADER_HEIGHT_PX + + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + + telegramHeaderHeight + : UI.MOBILE_HEADER_HEIGHT_PX; + + return { mobile, desktop: UI.DESKTOP_HEADER_HEIGHT_PX }; +} diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 4e08d27..5fbe7dc 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { referralNetworkApi } from '@/api/referralNetwork'; import { useReferralNetworkStore } from '@/store/referralNetwork'; +import { useHeaderHeight } from '@/hooks/useHeaderHeight'; import { AdminBackButton } from '@/components/admin/AdminBackButton'; import { NetworkGraph } from './components/NetworkGraph'; import { ScopeSelector } from './components/ScopeSelector'; @@ -20,6 +21,8 @@ export function ReferralNetwork() { const removeScope = useReferralNetworkStore((s) => s.removeScope); const clearScope = useReferralNetworkStore((s) => s.clearScope); + const { mobile: mobileHeaderHeight } = useHeaderHeight(); + const hasScope = scope.length > 0; const { @@ -38,14 +41,18 @@ export function ReferralNetwork() { return createPortal(
-
- -

- {t('admin.referralNetwork.title')} -

+ {/* Mobile: two rows — title on top, selector below */} +
+
+ +

+ {t('admin.referralNetwork.title')} +

+
Date: Fri, 20 Mar 2026 05:02:47 +0300 Subject: [PATCH 18/19] fix: bottom nav overlap and safe area handling in referral network - Raise portal z-index to z-50 so it covers MobileBottomNav - Use TG SDK bottom safe area (JS) via CSS variable instead of unreliable env() - Fix TicketNotificationBell regression: conditional inline style for fullscreen only - Add bottom safe area padding to detail panel scroll areas --- src/components/TicketNotificationBell.tsx | 4 ++-- src/hooks/useHeaderHeight.ts | 14 ++++++++++++-- src/pages/ReferralNetwork/ReferralNetwork.tsx | 17 +++++++++++------ .../components/CampaignDetailPanel.tsx | 2 +- .../components/UserDetailPanel.tsx | 2 +- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index e840bc9..88ef9b4 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -37,7 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi const { showToast } = useToast(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { mobile: dropdownTop } = useHeaderHeight(); + const { mobile: dropdownTop, isMobileFullscreen } = useHeaderHeight(); // Show toast for WebSocket notification const showWSNotificationToast = useCallback( @@ -255,7 +255,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi {isOpen && (
{/* Header */}
diff --git a/src/hooks/useHeaderHeight.ts b/src/hooks/useHeaderHeight.ts index 8b1b0d6..f9719f3 100644 --- a/src/hooks/useHeaderHeight.ts +++ b/src/hooks/useHeaderHeight.ts @@ -6,8 +6,14 @@ import { UI } from '@/config/constants'; * Telegram MiniApp safe area insets in fullscreen mode. * * Desktop: 56px (h-14). Mobile: 64px (h-16) + safe area + TG header when fullscreen. + * bottomSafeArea: TG SDK bottom inset (home indicator etc.), 0 outside TG. */ -export function useHeaderHeight(): { mobile: number; desktop: number } { +export function useHeaderHeight(): { + mobile: number; + desktop: number; + bottomSafeArea: number; + isMobileFullscreen: boolean; +} { const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } = useTelegramSDK(); const isMobileFullscreen = isFullscreen && isMobile; @@ -21,5 +27,9 @@ export function useHeaderHeight(): { mobile: number; desktop: number } { telegramHeaderHeight : UI.MOBILE_HEADER_HEIGHT_PX; - return { mobile, desktop: UI.DESKTOP_HEADER_HEIGHT_PX }; + const bottomSafeArea = isMobileFullscreen + ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) + : 0; + + return { mobile, desktop: UI.DESKTOP_HEADER_HEIGHT_PX, bottomSafeArea, isMobileFullscreen }; } diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 5fbe7dc..21ed65f 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -21,7 +21,7 @@ export function ReferralNetwork() { const removeScope = useReferralNetworkStore((s) => s.removeScope); const clearScope = useReferralNetworkStore((s) => s.clearScope); - const { mobile: mobileHeaderHeight } = useHeaderHeight(); + const { mobile: mobileHeaderHeight, bottomSafeArea } = useHeaderHeight(); const hasScope = scope.length > 0; @@ -41,8 +41,13 @@ export function ReferralNetwork() { return createPortal(
{/* Mobile: two rows — title on top, selector below */} @@ -122,15 +127,15 @@ export function ReferralNetwork() { <> -
+
-
+
-
+
diff --git a/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx b/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx index 6ac5327..84c1ac6 100644 --- a/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx +++ b/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx @@ -50,7 +50,7 @@ export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPan
{/* Content */} -
+
{isLoading && (
diff --git a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx index 3ec117f..653a4ed 100644 --- a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx +++ b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx @@ -50,7 +50,7 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
{/* Content */} -
+
{isLoading && (
From d34f5e85596e7f34804a4e78dcef06e61bd62cd9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 07:18:11 +0300 Subject: [PATCH 19/19] fix: hide trial banner when subscription expired banner is active --- src/pages/SubscriptionPurchase.tsx | 80 ++++++++++++++++-------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 84e84ba..967637f 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -409,48 +409,54 @@ export default function SubscriptionPurchase() { padding: '24px 28px', }} > - {/* Trial upgrade prompt */} - {subscription?.is_trial && ( -
-
-
-
+
- - -
-
-
- {t('subscription.trialUpgrade.title')} +
-
- {t('subscription.trialUpgrade.description')} +
+
+ {t('subscription.trialUpgrade.title')} +
+
+ {t('subscription.trialUpgrade.description')} +
-
- )} + )} {/* Expired subscription notice */} {isTariffsMode &&