diff --git a/package-lock.json b/package-lock.json index 44b4ede..c5fe6dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,9 @@ "cmdk": "^1.1.1", "dompurify": "^3.3.1", "framer-motion": "^12.29.2", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-types": "^0.24.8", "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", @@ -43,6 +46,7 @@ "react-router": "^7.13.0", "react-twemoji": "^0.7.2", "recharts": "^3.7.0", + "sigma": "^3.0.2", "simplex-noise": "^4.0.3", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" @@ -4445,6 +4449,15 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4806,6 +4819,45 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-layout-forceatlas2": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz", + "integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.1.0" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT" + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6427,6 +6479,16 @@ "node": ">=8" } }, + "node_modules/sigma": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz", + "integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "graphology-utils": "^2.5.2" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", diff --git a/package.json b/package.json index a10a7c5..1bda40e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "cmdk": "^1.1.1", "dompurify": "^3.3.1", "framer-motion": "^12.29.2", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-types": "^0.24.8", "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", @@ -51,6 +54,7 @@ "react-router": "^7.13.0", "react-twemoji": "^0.7.2", "recharts": "^3.7.0", + "sigma": "^3.0.2", "simplex-noise": "^4.0.3", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" diff --git a/src/App.tsx b/src/App.tsx index 295b137..80df1fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -115,6 +115,7 @@ const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog')); const AdminLandings = lazy(() => import('./pages/AdminLandings')); const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor')); const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats')); +const AdminReferralNetwork = lazy(() => import('./pages/ReferralNetwork')); function ProtectedRoute({ children, @@ -919,6 +920,16 @@ function App() { } /> + + + + + + } + /> => { - 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/api/referralNetwork.ts b/src/api/referralNetwork.ts new file mode 100644 index 0000000..9751d08 --- /dev/null +++ b/src/api/referralNetwork.ts @@ -0,0 +1,51 @@ +import apiClient from './client'; +import type { + NetworkGraphData, + NetworkUserDetail, + NetworkCampaignDetail, + NetworkSearchResult, + ScopeOptionsData, + ScopeSelection, +} from '@/types/referralNetwork'; + +export const referralNetworkApi = { + getScopeOptions: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/referral-network/scope-options'); + return response.data; + }, + + 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: { + campaign_ids: campaignIds, + partner_ids: partnerIds, + user_ids: userIds, + }, + paramsSerializer: { + indexes: null, + }, + }); + return response.data; + }, + + getUserDetail: async (userId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/referral-network/user/${userId}`); + return response.data; + }, + + getCampaignDetail: async (campaignId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/referral-network/campaign/${campaignId}`); + return response.data; + }, + + search: async (query: string): Promise => { + const response = await apiClient.get('/cabinet/admin/referral-network/search', { + params: { q: query }, + }); + return response.data; + }, +}; diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index 24018a8..88ef9b4 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, isMobileFullscreen } = 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..f9719f3 --- /dev/null +++ b/src/hooks/useHeaderHeight.ts @@ -0,0 +1,35 @@ +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. + * bottomSafeArea: TG SDK bottom inset (home indicator etc.), 0 outside TG. + */ +export function useHeaderHeight(): { + mobile: number; + desktop: number; + bottomSafeArea: number; + isMobileFullscreen: boolean; +} { + 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; + + const bottomSafeArea = isMobileFullscreen + ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) + : 0; + + return { mobile, desktop: UI.DESKTOP_HEADER_HEIGHT_PX, bottomSafeArea, isMobileFullscreen }; +} diff --git a/src/locales/en.json b/src/locales/en.json index 39a5bb3..6e338c3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1106,7 +1106,8 @@ "policies": "Access Policies", "auditLog": "Audit Log", "salesStats": "Sales Statistics", - "landings": "Landings" + "landings": "Landings", + "referralNetwork": "Referral Network" }, "panel": { "title": "Admin Panel", @@ -1140,7 +1141,8 @@ "policiesDesc": "Configure ABAC access policies", "auditLogDesc": "Review system activity and access history", "salesStatsDesc": "Sales analytics and trends", - "landingsDesc": "Quick purchase landing pages" + "landingsDesc": "Quick purchase landing pages", + "referralNetworkDesc": "Referral graph and campaign visualization" }, "salesStats": { "title": "Sales Statistics", @@ -1222,6 +1224,98 @@ }, "loadError": "Failed to load statistics" }, + "referralNetwork": { + "title": "Referral Network", + "subtitle": "Referral graph and advertising campaign visualization", + "search": { + "placeholder": "Search by TG ID, @username, email, campaign...", + "noResults": "Nothing found", + "resultsCount": "Found: {{count}}", + "clear": "Clear search" + }, + "filters": { + "title": "Filters", + "campaigns": "Campaigns", + "partnersOnly": "Partners only", + "minReferrals": "Min referrals", + "reset": "Reset" + }, + "stats": { + "totalUsers": "Users", + "totalReferrers": "Referrers", + "totalCampaigns": "Campaigns", + "totalEarnings": "Total earnings" + }, + "legend": { + "title": "Legend", + "regularUser": "Regular user", + "activeReferrer": "Active referrer", + "partner": "Partner", + "topReferrer": "Top referrer (10+)", + "campaignUser": "From campaign", + "partnerCampaignEdge": "Partner → Campaign", + "campaignNode": "Ad campaign" + }, + "user": { + "tgId": "TG ID", + "email": "Email", + "partner": "Partner", + "subscription": "Subscription", + "validUntil": "until {{date}}", + "noSubscription": "No subscription", + "personalStats": "Personal statistics", + "totalSpent": "Total spent", + "referralEarnings": "Referral earnings", + "referralBranch": "Referral branch", + "directReferrals": "Direct referrals", + "branchSize": "Total in branch", + "branchRevenue": "Branch revenue", + "source": "Source", + "referredBy": "Referred by", + "fromCampaign": "Campaign", + "organic": "Organic" + }, + "campaign": { + "startParam": "Parameter", + "active": "Active", + "inactive": "Inactive", + "directUsers": "Direct", + "totalNetwork": "Total network", + "totalRevenue": "Total revenue", + "conversionRate": "Conversion", + "avgCheck": "Avg check", + "topReferrers": "Top referrers from campaign" + }, + "controls": { + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "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", + "removeItem": "Remove {{label}}", + "clearAll": "Clear all selections", + "addScope": "Add scope", + "maxReached": "Maximum {{max}} items" + }, + "loading": "Loading graph...", + "error": "Failed to load network data", + "empty": "No data to display" + }, "trafficUsage": { "title": "Traffic Usage", "subtitle": "Per-user traffic statistics by nodes", @@ -1917,6 +2011,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..797c3e9 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -940,7 +940,8 @@ "policies": "سیاست‌های دسترسی", "auditLog": "گزارش بازرسی", "salesStats": "آمار فروش", - "landings": "صفحات فرود" + "landings": "صفحات فرود", + "referralNetwork": "شبکه ارجاع" }, "panel": { "title": "پنل مدیریت", @@ -974,7 +975,8 @@ "policiesDesc": "تنظیم سیاست‌های کنترل دسترسی ABAC", "auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی", "salesStatsDesc": "تحلیل فروش و روندها", - "landingsDesc": "صفحات خرید سریع" + "landingsDesc": "صفحات خرید سریع", + "referralNetworkDesc": "نمایش بصری گراف ارجاعات و کمپین‌ها" }, "salesStats": { "title": "آمار فروش", @@ -1056,6 +1058,98 @@ }, "loadError": "بارگذاری آمار ناموفق بود" }, + "referralNetwork": { + "title": "شبکه ارجاع", + "subtitle": "نمایش بصری گراف ارجاعات و کمپین‌های تبلیغاتی", + "search": { + "placeholder": "جستجو بر اساس TG ID، @نام‌کاربری، ایمیل، کمپین...", + "noResults": "نتیجه‌ای یافت نشد", + "resultsCount": "یافت شد: {{count}}", + "clear": "پاک کردن جستجو" + }, + "filters": { + "title": "فیلترها", + "campaigns": "کمپین‌ها", + "partnersOnly": "فقط شرکا", + "minReferrals": "حداقل ارجاعات", + "reset": "بازنشانی" + }, + "stats": { + "totalUsers": "کاربران", + "totalReferrers": "ارجاع‌دهندگان", + "totalCampaigns": "کمپین‌ها", + "totalEarnings": "درآمد کل" + }, + "legend": { + "title": "راهنما", + "regularUser": "کاربر عادی", + "activeReferrer": "ارجاع‌دهنده فعال", + "partner": "شریک", + "topReferrer": "ارجاع‌دهنده برتر (+۱۰)", + "campaignUser": "از کمپین", + "partnerCampaignEdge": "شریک → کمپین", + "campaignNode": "کمپین تبلیغاتی" + }, + "user": { + "tgId": "شناسه TG", + "email": "ایمیل", + "partner": "شریک", + "subscription": "اشتراک", + "validUntil": "تا {{date}}", + "noSubscription": "بدون اشتراک", + "personalStats": "آمار شخصی", + "totalSpent": "مجموع پرداختی", + "referralEarnings": "درآمد ارجاع", + "referralBranch": "شاخه ارجاع", + "directReferrals": "ارجاعات مستقیم", + "branchSize": "کل شاخه", + "branchRevenue": "درآمد شاخه", + "source": "منبع", + "referredBy": "معرفی شده توسط", + "fromCampaign": "کمپین", + "organic": "طبیعی" + }, + "campaign": { + "startParam": "پارامتر", + "active": "فعال", + "inactive": "غیرفعال", + "directUsers": "مستقیم", + "totalNetwork": "کل شبکه", + "totalRevenue": "درآمد کل", + "conversionRate": "نرخ تبدیل", + "avgCheck": "میانگین مبلغ", + "topReferrers": "ارجاع‌دهندگان برتر کمپین" + }, + "controls": { + "zoomIn": "بزرگ‌نمایی", + "zoomOut": "کوچک‌نمایی", + "resetZoom": "بازنشانی زوم", + "fullscreen": "تمام‌صفحه" + }, + "scope": { + "title": "محدوده", + "campaign": "کمپین", + "partner": "شریک", + "user": "کاربر", + "searchPlaceholder": "جستجو...", + "selectCampaign": "انتخاب کمپین", + "selectPartner": "انتخاب شریک", + "selectUser": "جستجوی کاربر", + "emptyState": "یک کمپین، شریک یا کاربر را برای مشاهده شبکه انتخاب کنید", + "users": "کاربران", + "campaigns": "کمپین‌ها", + "active": "فعال", + "inactive": "غیرفعال", + "noResults": "نتیجه‌ای یافت نشد", + "removeItem": "حذف {{label}}", + "clearAll": "پاک کردن همه انتخاب‌ها", + "addScope": "افزودن محدوده", + "maxReached": "حداکثر {{max}} مورد" + }, + "loading": "بارگذاری گراف...", + "error": "خطا در بارگذاری داده‌های شبکه", + "empty": "داده‌ای برای نمایش وجود ندارد" + }, "trafficUsage": { "title": "مصرف ترافیک", "subtitle": "آمار ترافیک کاربران بر اساس نودها", @@ -1579,6 +1673,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..5137d97 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1127,7 +1127,8 @@ "policies": "Политики доступа", "auditLog": "Журнал аудита", "salesStats": "Статистика продаж", - "landings": "Лендинги" + "landings": "Лендинги", + "referralNetwork": "Реферальная сеть" }, "panel": { "title": "Панель администратора", @@ -1161,7 +1162,8 @@ "policiesDesc": "Настройка политик контроля доступа ABAC", "auditLogDesc": "Просмотр активности системы и истории доступа", "salesStatsDesc": "Аналитика продаж и тренды", - "landingsDesc": "Страницы быстрой покупки" + "landingsDesc": "Страницы быстрой покупки", + "referralNetworkDesc": "Визуализация графа рефералов и РК" }, "salesStats": { "title": "Статистика продаж", @@ -1243,6 +1245,98 @@ }, "loadError": "Не удалось загрузить статистику" }, + "referralNetwork": { + "title": "Реферальная сеть", + "subtitle": "Визуализация графа рефералов и рекламных кампаний", + "search": { + "placeholder": "Поиск по TG ID, @username, email, кампании...", + "noResults": "Ничего не найдено", + "resultsCount": "Найдено: {{count}}", + "clear": "Очистить поиск" + }, + "filters": { + "title": "Фильтры", + "campaigns": "Кампании", + "partnersOnly": "Только партнёры", + "minReferrals": "Мин. рефералов", + "reset": "Сбросить" + }, + "stats": { + "totalUsers": "Пользователей", + "totalReferrers": "Рефереров", + "totalCampaigns": "Кампаний", + "totalEarnings": "Общий доход" + }, + "legend": { + "title": "Легенда", + "regularUser": "Обычный", + "activeReferrer": "Активный реферер", + "partner": "Партнёр", + "topReferrer": "Топ-реферер (10+)", + "campaignUser": "Из кампании", + "partnerCampaignEdge": "Партнёр → РК", + "campaignNode": "Рекламная кампания" + }, + "user": { + "tgId": "TG ID", + "email": "Email", + "partner": "Партнёр", + "subscription": "Подписка", + "validUntil": "до {{date}}", + "noSubscription": "Нет подписки", + "personalStats": "Личная статистика", + "totalSpent": "Оплачено всего", + "referralEarnings": "Реф. доход", + "referralBranch": "Реферальная ветка", + "directReferrals": "Привёл людей", + "branchSize": "Всего в ветке", + "branchRevenue": "Доход ветки", + "source": "Источник", + "referredBy": "Пришёл от", + "fromCampaign": "РК", + "organic": "Органический" + }, + "campaign": { + "startParam": "Параметр", + "active": "Активна", + "inactive": "Неактивна", + "directUsers": "Напрямую", + "totalNetwork": "Вся сеть", + "totalRevenue": "Общий доход", + "conversionRate": "Конверсия", + "avgCheck": "Средний чек", + "topReferrers": "Топ-рефереры из кампании" + }, + "controls": { + "zoomIn": "Приблизить", + "zoomOut": "Отдалить", + "resetZoom": "Сбросить зум", + "fullscreen": "Полный экран" + }, + "scope": { + "title": "Область", + "campaign": "Кампания", + "partner": "Партнёр", + "user": "Пользователь", + "searchPlaceholder": "Поиск...", + "selectCampaign": "Выберите кампанию", + "selectPartner": "Выберите партнёра", + "selectUser": "Найти пользователя", + "emptyState": "Выберите кампанию, партнёра или пользователя для просмотра сети", + "users": "пользователей", + "campaigns": "кампаний", + "active": "Активна", + "inactive": "Неактивна", + "noResults": "Ничего не найдено", + "removeItem": "Удалить {{label}}", + "clearAll": "Очистить все выбранные", + "addScope": "Добавить область", + "maxReached": "Максимум {{max}} элементов" + }, + "loading": "Загрузка графа...", + "error": "Ошибка загрузки данных сети", + "empty": "Нет данных для отображения" + }, "trafficUsage": { "title": "Расход трафика", "subtitle": "Статистика трафика по пользователям и нодам", @@ -2428,6 +2522,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..734a8bb 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -940,7 +940,8 @@ "policies": "访问策略", "auditLog": "审计日志", "salesStats": "销售统计", - "landings": "落地页" + "landings": "落地页", + "referralNetwork": "推荐网络" }, "panel": { "title": "管理面板", @@ -974,7 +975,8 @@ "policiesDesc": "配置ABAC访问控制策略", "auditLogDesc": "查看系统活动和访问历史", "salesStatsDesc": "销售分析与趋势", - "landingsDesc": "快速购买落地页" + "landingsDesc": "快速购买落地页", + "referralNetworkDesc": "推荐图表和广告活动可视化" }, "salesStats": { "title": "销售统计", @@ -1056,6 +1058,98 @@ }, "loadError": "加载统计失败" }, + "referralNetwork": { + "title": "推荐网络", + "subtitle": "推荐图表和广告活动可视化", + "search": { + "placeholder": "按TG ID、@用户名、邮箱、活动搜索...", + "noResults": "未找到", + "resultsCount": "找到: {{count}}", + "clear": "清除搜索" + }, + "filters": { + "title": "过滤器", + "campaigns": "活动", + "partnersOnly": "仅合作伙伴", + "minReferrals": "最少推荐数", + "reset": "重置" + }, + "stats": { + "totalUsers": "用户", + "totalReferrers": "推荐人", + "totalCampaigns": "活动", + "totalEarnings": "总收入" + }, + "legend": { + "title": "图例", + "regularUser": "普通用户", + "activeReferrer": "活跃推荐人", + "partner": "合作伙伴", + "topReferrer": "顶级推荐人 (10+)", + "campaignUser": "来自活动", + "partnerCampaignEdge": "合作伙伴 → 活动", + "campaignNode": "广告活动" + }, + "user": { + "tgId": "TG ID", + "email": "邮箱", + "partner": "合作伙伴", + "subscription": "订阅", + "validUntil": "有效至 {{date}}", + "noSubscription": "无订阅", + "personalStats": "个人统计", + "totalSpent": "总花费", + "referralEarnings": "推荐收入", + "referralBranch": "推荐分支", + "directReferrals": "直接推荐", + "branchSize": "分支总人数", + "branchRevenue": "分支收入", + "source": "来源", + "referredBy": "由...推荐", + "fromCampaign": "活动", + "organic": "自然流量" + }, + "campaign": { + "startParam": "参数", + "active": "活跃", + "inactive": "非活跃", + "directUsers": "直接", + "totalNetwork": "总网络", + "totalRevenue": "总收入", + "conversionRate": "转化率", + "avgCheck": "平均金额", + "topReferrers": "活动顶级推荐人" + }, + "controls": { + "zoomIn": "放大", + "zoomOut": "缩小", + "resetZoom": "重置缩放", + "fullscreen": "全屏" + }, + "scope": { + "title": "范围", + "campaign": "活动", + "partner": "合作伙伴", + "user": "用户", + "searchPlaceholder": "搜索...", + "selectCampaign": "选择活动", + "selectPartner": "选择合作伙伴", + "selectUser": "搜索用户", + "emptyState": "选择活动、合作伙伴或用户以查看其网络", + "users": "用户", + "campaigns": "活动", + "active": "活跃", + "inactive": "非活跃", + "noResults": "未找到", + "removeItem": "移除 {{label}}", + "clearAll": "清除所有选择", + "addScope": "添加范围", + "maxReached": "最多 {{max}} 个项目" + }, + "loading": "加载图表...", + "error": "加载网络数据失败", + "empty": "无数据显示" + }, "trafficUsage": { "title": "流量使用", "subtitle": "按节点的用户流量统计", @@ -1617,6 +1711,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/AdminPanel.tsx b/src/pages/AdminPanel.tsx index c95f1bd..f3fc46f 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -248,6 +248,17 @@ const ArrowsUpDownIcon = () => ( ); +const NetworkGraphIcon = () => ( + + + + +); + const ChevronRightIcon = () => ( @@ -569,6 +580,13 @@ export default function AdminPanel() { description: t('admin.panel.withdrawalsDesc'), permission: 'withdrawals:read', }, + { + to: '/admin/referral-network', + icon: , + title: t('admin.nav.referralNetwork'), + description: t('admin.panel.referralNetworkDesc'), + permission: 'stats:read', + }, ], }, { 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..13c3139 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkFilters.tsx @@ -0,0 +1,202 @@ +import { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useReferralNetworkStore } from '@/store/referralNetwork'; +import type { NetworkGraphData } from '@/types/referralNetwork'; + +interface NetworkFiltersProps { + data: NetworkGraphData; + className?: string; +} + +/** + * @deprecated No longer used in the main page — replaced by ScopeSelector. + * Kept for potential future reuse. + */ +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); + const resetFilters = useReferralNetworkStore((s) => s.resetFilters); + const [isOpen, setIsOpen] = useState(false); + + 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) + ? current.filter((id) => id !== campaignId) + : [...current, campaignId]; + updateFilters({ campaigns: next }); + } + + 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" + /> +
+ + {/* Reset */} + +
+ ); + + return ( +
+ {/* Trigger button — always rendered */} + + + {/* Desktop: absolute dropdown below button */} + {isOpen && ( +
+
+
+

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

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

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

+ +
+ {panelContent} +
+
+ )} +
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx new file mode 100644 index 0000000..1671e64 --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -0,0 +1,384 @@ +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'; +import { setSigmaInstance } from '../sigmaGlobals'; + +interface NetworkGraphProps { + data: NetworkGraphData; + className?: string; +} + +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: 16, + 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 === '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, + }); + } + } + }); + + return graph; +} + +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 = 3000; + +function clampOutlierPositions(graph: Graph): void { + const xs: number[] = []; + const ys: number[] = []; + + graph.forEachNode((_, attrs) => { + xs.push(attrs.x as number); + ys.push(attrs.y as number); + }); + + if (xs.length < 2) return; + + const meanX = xs.reduce((a, b) => a + b, 0) / xs.length; + const meanY = ys.reduce((a, b) => a + b, 0) / ys.length; + + const stdX = Math.sqrt(xs.reduce((sum, x) => sum + (x - meanX) ** 2, 0) / xs.length) || 1; + const stdY = Math.sqrt(ys.reduce((sum, y) => sum + (y - meanY) ** 2, 0) / ys.length) || 1; + + const MAX_STD = 3; + const minX = meanX - MAX_STD * stdX; + const maxX = meanX + MAX_STD * stdX; + const minY = meanY - MAX_STD * stdY; + const maxY = meanY + MAX_STD * stdY; + + graph.forEachNode((node, attrs) => { + const x = attrs.x as number; + const y = attrs.y as number; + const clampedX = Math.max(minX, Math.min(maxX, x)); + const clampedY = Math.max(minY, Math.min(maxY, y)); + + if (clampedX !== x || clampedY !== y) { + graph.setNodeAttribute(node, 'x', clampedX); + graph.setNodeAttribute(node, 'y', clampedY); + } + }); +} + +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 setHighlightedNodes = useReferralNetworkStore((s) => s.setHighlightedNodes); + const hoveredNodeId = useReferralNetworkStore((s) => s.hoveredNodeId); + const highlightedNodes = useReferralNetworkStore((s) => s.highlightedNodes); + const filters = useReferralNetworkStore((s) => s.filters); + + const killFA2 = useCallback(() => { + if (fa2TimerRef.current !== null) { + clearTimeout(fa2TimerRef.current); + fa2TimerRef.current = null; + } + if (fa2Ref.current) { + fa2Ref.current.kill(); + fa2Ref.current = null; + } + }, []); + + useEffect(() => { + if (!containerRef.current || !data) return; + + const container = containerRef.current; + let rafId: number; + let cancelled = false; + + function tryInit() { + if (cancelled || !container.isConnected) return; + if (container.offsetWidth === 0 || container.offsetHeight === 0) { + rafId = requestAnimationFrame(tryInit); + return; + } + + killFA2(); + if (sigmaRef.current) { + sigmaRef.current.kill(); + sigmaRef.current = null; + } + + const graph = buildFullGraph(data); + graphRef.current = graph; + + const initialFilters = useReferralNetworkStore.getState().filters; + hiddenNodesRef.current = computeHiddenNodes(graph, initialFilters); + + const sigma = new Sigma(graph, container, { + renderEdgeLabels: false, + labelDensity: 0.12, + labelRenderedSizeThreshold: 14, + zIndex: true, + defaultEdgeColor: '#ffffff06', + defaultNodeColor: '#6b7280', + labelColor: { color: '#e8e6f0' }, + labelFont: 'Inter, system-ui, sans-serif', + labelSize: 11, + stagePadding: 40, + nodeReducer: (node, attrs) => { + const res = { ...attrs }; + + if (hiddenNodesRef.current.has(node)) { + res.hidden = true; + return res; + } + + const store = useReferralNetworkStore.getState(); + const hovered = store.hoveredNodeId; + const highlighted = store.highlightedNodes; + + 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; + } + } + + 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 }; + + 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; + setSigmaInstance(sigma); + + if (graph.order > 0) { + const inferred = inferSettings(graph); + const supervisor = new FA2LayoutSupervisor(graph, { + settings: { + ...inferred, + linLogMode: true, + adjustSizes: true, + barnesHutOptimize: graph.order > 100, + gravity: 1, + slowDown: 2, + }, + }); + fa2Ref.current = supervisor; + supervisor.start(); + + fa2TimerRef.current = setTimeout(() => { + if (fa2Ref.current === supervisor) { + supervisor.kill(); + fa2Ref.current = null; + } + fa2TimerRef.current = null; + + if (graphRef.current) { + clampOutlierPositions(graphRef.current); + } + + if (sigmaRef.current) { + sigmaRef.current.resize(); + sigmaRef.current.getCamera().animatedReset({ duration: 400 }); + } + }, FA2_DURATION_MS); + } + + 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 }); + } + }); + + sigma.on('clickStage', () => { + setSelectedNode(null); + }); + + 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'; + } + }); + } + + rafId = requestAnimationFrame(tryInit); + + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + killFA2(); + if (sigmaRef.current) { + sigmaRef.current.kill(); + sigmaRef.current = null; + } + graphRef.current = null; + setSigmaInstance(null); + + setHoveredNode(null); + setHighlightedNodes(new Set()); + }; + }, [data, setSelectedNode, setHoveredNode, setHighlightedNodes, killFA2]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver(() => { + if (sigmaRef.current) { + sigmaRef.current.resize(); + } + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!graphRef.current || !sigmaRef.current) return; + hiddenNodesRef.current = computeHiddenNodes(graphRef.current, filters); + sigmaRef.current.refresh(); + }, [filters]); + + 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..4bd766a --- /dev/null +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -0,0 +1,58 @@ +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)} +
+ ))} + {/* Partner → Campaign edge */} +
+
+ + {t('admin.referralNetwork.legend.partnerCampaignEdge')} + +
+ {/* Campaign node with gradient to represent varying colors */} +
+
+ + {t('admin.referralNetwork.legend.campaignNode')} + +
+
+
+ ); +} diff --git a/src/pages/ReferralNetwork/components/NetworkStats.tsx b/src/pages/ReferralNetwork/components/NetworkStats.tsx new file mode 100644 index 0000000..e715bd2 --- /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/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx new file mode 100644 index 0000000..e35f9e0 --- /dev/null +++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx @@ -0,0 +1,467 @@ +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 { + value: ScopeSelection[]; + onAdd: (selection: ScopeSelection) => void; + onRemove: (type: ScopeSelection['type'], id: number) => void; + onClear: () => void; + className?: string; +} + +const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user']; + +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', +}; + +// 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'); + const [searchInput, setSearchInput] = useState(''); + const [debouncedUserQuery, setDebouncedUserQuery] = useState(''); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + 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, + 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, + }); + + // 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); + } + } + function handleEscape(e: KeyboardEvent) { + if (e.key === 'Escape') { + setIsDropdownOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [isDropdownOpen]); + + 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(() => { + 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 handleToggle(type: ScopeType, id: number, label: string) { + if (isSelected(type, id)) { + onRemove(type, id); + } else { + if (isMaxReached) return; + onAdd({ type, id, label }); + if (type === 'user') { + setSearchInput(''); + setDebouncedUserQuery(''); + } + } + } + + const tabLabels = useMemo>( + () => ({ + campaign: t('admin.referralNetwork.scope.campaign'), + partner: t('admin.referralNetwork.scope.partner'), + user: t('admin.referralNetwork.scope.user'), + }), + [t], + ); + + 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 + add trigger */} +
+ {/* Selected chips */} + {value.length > 0 && ( +
+ {value.map((item) => ( + + {item.label} + + + ))} + {/* Clear all (only when 2+ items) */} + {value.length > 1 && ( + + )} +
+ )} + + {/* Add button */} + +
+ + {/* Dropdown */} + {isDropdownOpen && ( +
+ {/* Max reached banner */} + {isMaxReached && ( +
+ {t('admin.referralNetwork.scope.maxReached', { max: MAX_SCOPE_ITEMS })} +
+ )} + + {/* 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()} +
+
+ )} +
+ ); + + function renderCampaignList() { + if (isScopeLoading) { + return ( +
+ +
+ ); + } + + if (filteredCampaigns.length === 0) { + return ; + } + + return filteredCampaigns.map((campaign) => ( + handleToggle('campaign', campaign.id, campaign.name)} + title={campaign.name} + subtitle={`${campaign.start_parameter} / ${campaign.direct_users} ${t('admin.referralNetwork.scope.users')}`} + badge={ + + {campaign.is_active + ? t('admin.referralNetwork.scope.active') + : t('admin.referralNetwork.scope.inactive')} + + } + /> + )); + } + + function renderPartnerList() { + if (isScopeLoading) { + return ( +
+ +
+ ); + } + + if (filteredPartners.length === 0) { + 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 ; + } + + if (isUserSearching) { + return ( +
+ +
+ ); + } + + const users = userSearchResults?.users ?? []; + + if (users.length === 0) { + return ; + } + + return users.map((user) => ( + handleToggle('user', user.id, user.display_name)} + title={user.display_name} + subtitle={`${user.username ? `@${user.username}` : ''}${user.tg_id ? ` #${user.tg_id}` : ''}`} + badge={ + user.is_partner ? ( + + {t('admin.referralNetwork.user.partner')} + + ) : undefined + } + /> + )); + } +} diff --git a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx new file mode 100644 index 0000000..653a4ed --- /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..1052628 --- /dev/null +++ b/src/pages/ReferralNetwork/sigmaGlobals.ts @@ -0,0 +1,15 @@ +import type Sigma from 'sigma'; + +/** + * We store the sigma instance on a module-level variable + * so that controls can access it without prop drilling. + */ +let sigmaInstance: Sigma | null = null; + +export function setSigmaInstance(sigma: Sigma | null): void { + sigmaInstance = sigma; +} + +export function getSigmaInstance(): Sigma | null { + return sigmaInstance; +} diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts new file mode 100644 index 0000000..f1a89ae --- /dev/null +++ b/src/pages/ReferralNetwork/utils.ts @@ -0,0 +1,52 @@ +/** + * 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 { + if (directReferrals === 0) return 5; + if (directReferrals <= 2) return 10; + return Math.min(40, 10 + Math.sqrt(directReferrals) * 4); +} 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 && diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts new file mode 100644 index 0000000..8501fdc --- /dev/null +++ b/src/store/referralNetwork.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand'; +import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork'; + +export const MAX_SCOPE_ITEMS = 50; + +const DEFAULT_FILTERS: NetworkFilters = { + campaigns: [], + partnersOnly: false, + 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; + highlightedNodes: Set; + filters: NetworkFilters; + scope: ScopeSelection[]; + + setSelectedNode: (node: SelectedNode) => void; + setHoveredNode: (id: string | null) => void; + setHighlightedNodes: (nodes: Set) => void; + updateFilters: (filters: Partial) => void; + resetFilters: () => void; + addScope: (item: ScopeSelection) => void; + removeScope: (type: ScopeSelection['type'], id: number) => void; + clearScope: () => void; +} + +export const useReferralNetworkStore = create()((set) => ({ + selectedNode: null, + hoveredNodeId: null, + highlightedNodes: new Set(), + filters: { ...DEFAULT_FILTERS }, + scope: [], + + setSelectedNode: (node) => set({ selectedNode: node }), + setHoveredNode: (id) => set({ hoveredNodeId: id }), + setHighlightedNodes: (nodes) => set({ highlightedNodes: nodes }), + + updateFilters: (partial) => + set((state) => ({ + filters: { ...state.filters, ...partial }, + })), + + resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), + + 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], + ...resetGraphState(), + }; + }), + + removeScope: (type, id) => + 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: [], + ...resetGraphState(), + }), +})); diff --git a/src/types/referralNetwork.ts b/src/types/referralNetwork.ts new file mode 100644 index 0000000..10802b4 --- /dev/null +++ b/src/types/referralNetwork.ts @@ -0,0 +1,130 @@ +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' | 'partner_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; +} + +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; +}