mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 10:03:46 +00:00
62
package-lock.json
generated
62
package-lock.json
generated
@@ -33,6 +33,9 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"framer-motion": "^12.29.2",
|
"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": "^25.8.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
"sigma": "^3.0.2",
|
||||||
"simplex-noise": "^4.0.3",
|
"simplex-noise": "^4.0.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
@@ -4445,6 +4449,15 @@
|
|||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"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==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -6427,6 +6479,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"framer-motion": "^12.29.2",
|
"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": "^25.8.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
"sigma": "^3.0.2",
|
||||||
"simplex-noise": "^4.0.3",
|
"simplex-noise": "^4.0.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
|
|||||||
11
src/App.tsx
11
src/App.tsx
@@ -115,6 +115,7 @@ const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
|
|||||||
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
|
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
|
||||||
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
|
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
|
||||||
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
|
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
|
||||||
|
const AdminReferralNetwork = lazy(() => import('./pages/ReferralNetwork'));
|
||||||
|
|
||||||
function ProtectedRoute({
|
function ProtectedRoute({
|
||||||
children,
|
children,
|
||||||
@@ -919,6 +920,16 @@ function App() {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/referral-network"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="stats:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminReferralNetwork />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/payment-methods"
|
path="/admin/payment-methods"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -115,8 +115,15 @@ export const adminApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Reply to ticket
|
// Reply to ticket
|
||||||
replyToTicket: async (ticketId: number, message: string): Promise<AdminTicketMessage> => {
|
replyToTicket: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message });
|
ticketId: number,
|
||||||
|
message: string,
|
||||||
|
media?: { media_type?: string; media_file_id?: string; media_caption?: string },
|
||||||
|
): Promise<AdminTicketMessage> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, {
|
||||||
|
message,
|
||||||
|
...media,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
51
src/api/referralNetwork.ts
Normal file
51
src/api/referralNetwork.ts
Normal file
@@ -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<ScopeOptionsData> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/referral-network/scope-options');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getScopedGraph: async (selections: ScopeSelection[]): Promise<NetworkGraphData> => {
|
||||||
|
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<NetworkUserDetail> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/referral-network/user/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCampaignDetail: async (campaignId: number): Promise<NetworkCampaignDetail> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/referral-network/campaign/${campaignId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
search: async (query: string): Promise<NetworkSearchResult> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/referral-network/search', {
|
||||||
|
params: { q: query },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@ import { ticketNotificationsApi } from '../api/ticketNotifications';
|
|||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { useToast } from './Toast';
|
import { useToast } from './Toast';
|
||||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
|
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
|
||||||
import { useTelegramSDK } from '../hooks/useTelegramSDK';
|
import { useHeaderHeight } from '../hooks/useHeaderHeight';
|
||||||
import type { TicketNotification } from '../types';
|
import type { TicketNotification } from '../types';
|
||||||
|
|
||||||
const BellIcon = () => (
|
const BellIcon = () => (
|
||||||
@@ -37,12 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
const { mobile: dropdownTop, isMobileFullscreen } = useHeaderHeight();
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// Show toast for WebSocket notification
|
// Show toast for WebSocket notification
|
||||||
const showWSNotificationToast = useCallback(
|
const showWSNotificationToast = useCallback(
|
||||||
@@ -259,10 +254,8 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className={`fixed left-4 right-4 z-50 mt-0 w-auto animate-scale-in overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/95 shadow-2xl shadow-black/30 backdrop-blur-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-96 ${
|
className="fixed left-4 right-4 z-50 mt-0 w-auto animate-scale-in overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/95 shadow-2xl shadow-black/30 backdrop-blur-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-96"
|
||||||
!isFullscreen ? 'top-16' : ''
|
style={isMobileFullscreen ? { top: dropdownTop } : undefined}
|
||||||
}`}
|
|
||||||
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-dark-700/50 bg-dark-800/30 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-dark-700/50 bg-dark-800/30 px-4 py-3">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
import { useHaptic } from '@/platform';
|
import { useHaptic } from '@/platform';
|
||||||
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
|
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
|
||||||
|
import { useHeaderHeight } from '@/hooks/useHeaderHeight';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useBranding } from '@/hooks/useBranding';
|
import { useBranding } from '@/hooks/useBranding';
|
||||||
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
|
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
|
||||||
@@ -13,7 +14,6 @@ import { useScrollRestoration } from '@/hooks/useScrollRestoration';
|
|||||||
import { themeColorsApi } from '@/api/themeColors';
|
import { themeColorsApi } from '@/api/themeColors';
|
||||||
import { isLogoPreloaded } from '@/api/branding';
|
import { isLogoPreloaded } from '@/api/branding';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { UI } from '@/config/constants';
|
|
||||||
|
|
||||||
import WebSocketNotifications from '@/components/WebSocketNotifications';
|
import WebSocketNotifications from '@/components/WebSocketNotifications';
|
||||||
import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
|
import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
|
||||||
@@ -198,6 +198,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
|
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
|
||||||
useTelegramSDK();
|
useTelegramSDK();
|
||||||
|
const { mobile: headerHeight } = useHeaderHeight();
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
const { toggleTheme, isDark } = useTheme();
|
const { toggleTheme, isDark } = useTheme();
|
||||||
|
|
||||||
@@ -269,14 +270,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
haptic.impact('light');
|
haptic.impact('light');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate header height based on fullscreen mode (only on mobile Telegram)
|
// headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen
|
||||||
// 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export const UI = {
|
|||||||
RESEND_COOLDOWN_SEC: 60,
|
RESEND_COOLDOWN_SEC: 60,
|
||||||
TELEGRAM_HEADER_ANDROID_PX: 48,
|
TELEGRAM_HEADER_ANDROID_PX: 48,
|
||||||
TELEGRAM_HEADER_IOS_PX: 45,
|
TELEGRAM_HEADER_IOS_PX: 45,
|
||||||
|
MOBILE_HEADER_HEIGHT_PX: 64,
|
||||||
|
DESKTOP_HEADER_HEIGHT_PX: 56,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// API
|
// API
|
||||||
|
|||||||
35
src/hooks/useHeaderHeight.ts
Normal file
35
src/hooks/useHeaderHeight.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1106,7 +1106,8 @@
|
|||||||
"policies": "Access Policies",
|
"policies": "Access Policies",
|
||||||
"auditLog": "Audit Log",
|
"auditLog": "Audit Log",
|
||||||
"salesStats": "Sales Statistics",
|
"salesStats": "Sales Statistics",
|
||||||
"landings": "Landings"
|
"landings": "Landings",
|
||||||
|
"referralNetwork": "Referral Network"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -1140,7 +1141,8 @@
|
|||||||
"policiesDesc": "Configure ABAC access policies",
|
"policiesDesc": "Configure ABAC access policies",
|
||||||
"auditLogDesc": "Review system activity and access history",
|
"auditLogDesc": "Review system activity and access history",
|
||||||
"salesStatsDesc": "Sales analytics and trends",
|
"salesStatsDesc": "Sales analytics and trends",
|
||||||
"landingsDesc": "Quick purchase landing pages"
|
"landingsDesc": "Quick purchase landing pages",
|
||||||
|
"referralNetworkDesc": "Referral graph and campaign visualization"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "Sales Statistics",
|
"title": "Sales Statistics",
|
||||||
@@ -1222,6 +1224,98 @@
|
|||||||
},
|
},
|
||||||
"loadError": "Failed to load statistics"
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "Traffic Usage",
|
"title": "Traffic Usage",
|
||||||
"subtitle": "Per-user traffic statistics by nodes",
|
"subtitle": "Per-user traffic statistics by nodes",
|
||||||
@@ -1917,6 +2011,12 @@
|
|||||||
"adminLabel": "Admin",
|
"adminLabel": "Admin",
|
||||||
"replyPlaceholder": "Type your reply...",
|
"replyPlaceholder": "Type your reply...",
|
||||||
"sendReply": "Send 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",
|
"settings": "Settings",
|
||||||
"settingsSubtitle": "Configure ticket system and SLA",
|
"settingsSubtitle": "Configure ticket system and SLA",
|
||||||
"settingsLoadError": "Failed to load settings",
|
"settingsLoadError": "Failed to load settings",
|
||||||
|
|||||||
@@ -940,7 +940,8 @@
|
|||||||
"policies": "سیاستهای دسترسی",
|
"policies": "سیاستهای دسترسی",
|
||||||
"auditLog": "گزارش بازرسی",
|
"auditLog": "گزارش بازرسی",
|
||||||
"salesStats": "آمار فروش",
|
"salesStats": "آمار فروش",
|
||||||
"landings": "صفحات فرود"
|
"landings": "صفحات فرود",
|
||||||
|
"referralNetwork": "شبکه ارجاع"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
@@ -974,7 +975,8 @@
|
|||||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
||||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
||||||
"salesStatsDesc": "تحلیل فروش و روندها",
|
"salesStatsDesc": "تحلیل فروش و روندها",
|
||||||
"landingsDesc": "صفحات خرید سریع"
|
"landingsDesc": "صفحات خرید سریع",
|
||||||
|
"referralNetworkDesc": "نمایش بصری گراف ارجاعات و کمپینها"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "آمار فروش",
|
"title": "آمار فروش",
|
||||||
@@ -1056,6 +1058,98 @@
|
|||||||
},
|
},
|
||||||
"loadError": "بارگذاری آمار ناموفق بود"
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "مصرف ترافیک",
|
"title": "مصرف ترافیک",
|
||||||
"subtitle": "آمار ترافیک کاربران بر اساس نودها",
|
"subtitle": "آمار ترافیک کاربران بر اساس نودها",
|
||||||
@@ -1579,6 +1673,12 @@
|
|||||||
"adminLabel": "مدیر",
|
"adminLabel": "مدیر",
|
||||||
"replyPlaceholder": "پاسخ خود را بنویسید...",
|
"replyPlaceholder": "پاسخ خود را بنویسید...",
|
||||||
"sendReply": "ارسال پاسخ",
|
"sendReply": "ارسال پاسخ",
|
||||||
|
"attachMedia": "پیوست رسانه",
|
||||||
|
"invalidFileType": "نوع فایل نامعتبر. از تصاویر، ویدئو (MP4, WebM) یا اسناد (PDF, DOC, TXT, ZIP) استفاده کنید.",
|
||||||
|
"fileTooLarge": "فایل بسیار بزرگ است. حداکثر اندازه ۱۰ مگابایت.",
|
||||||
|
"uploadFailed": "آپلود فایل ناموفق بود",
|
||||||
|
"uploading": "در حال آپلود...",
|
||||||
|
"uploadComplete": "آپلود شد",
|
||||||
"settings": "تنظیمات",
|
"settings": "تنظیمات",
|
||||||
"settingsSubtitle": "پیکربندی سیستم تیکت و SLA",
|
"settingsSubtitle": "پیکربندی سیستم تیکت و SLA",
|
||||||
"settingsLoadError": "خطا در بارگذاری تنظیمات",
|
"settingsLoadError": "خطا در بارگذاری تنظیمات",
|
||||||
|
|||||||
@@ -1127,7 +1127,8 @@
|
|||||||
"policies": "Политики доступа",
|
"policies": "Политики доступа",
|
||||||
"auditLog": "Журнал аудита",
|
"auditLog": "Журнал аудита",
|
||||||
"salesStats": "Статистика продаж",
|
"salesStats": "Статистика продаж",
|
||||||
"landings": "Лендинги"
|
"landings": "Лендинги",
|
||||||
|
"referralNetwork": "Реферальная сеть"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -1161,7 +1162,8 @@
|
|||||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
||||||
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
||||||
"salesStatsDesc": "Аналитика продаж и тренды",
|
"salesStatsDesc": "Аналитика продаж и тренды",
|
||||||
"landingsDesc": "Страницы быстрой покупки"
|
"landingsDesc": "Страницы быстрой покупки",
|
||||||
|
"referralNetworkDesc": "Визуализация графа рефералов и РК"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "Статистика продаж",
|
"title": "Статистика продаж",
|
||||||
@@ -1243,6 +1245,98 @@
|
|||||||
},
|
},
|
||||||
"loadError": "Не удалось загрузить статистику"
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "Расход трафика",
|
"title": "Расход трафика",
|
||||||
"subtitle": "Статистика трафика по пользователям и нодам",
|
"subtitle": "Статистика трафика по пользователям и нодам",
|
||||||
@@ -2428,6 +2522,12 @@
|
|||||||
"adminLabel": "Админ",
|
"adminLabel": "Админ",
|
||||||
"replyPlaceholder": "Введите ваш ответ...",
|
"replyPlaceholder": "Введите ваш ответ...",
|
||||||
"sendReply": "Отправить",
|
"sendReply": "Отправить",
|
||||||
|
"attachMedia": "Прикрепить медиа",
|
||||||
|
"invalidFileType": "Неверный тип файла. Используйте изображения, видео (MP4, WebM) или документы (PDF, DOC, TXT, ZIP).",
|
||||||
|
"fileTooLarge": "Файл слишком большой. Максимальный размер 10МБ.",
|
||||||
|
"uploadFailed": "Не удалось загрузить файл",
|
||||||
|
"uploading": "Загрузка...",
|
||||||
|
"uploadComplete": "Загружено",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"settingsSubtitle": "Настройка тикетной системы и SLA",
|
"settingsSubtitle": "Настройка тикетной системы и SLA",
|
||||||
"settingsLoadError": "Не удалось загрузить настройки",
|
"settingsLoadError": "Не удалось загрузить настройки",
|
||||||
|
|||||||
@@ -940,7 +940,8 @@
|
|||||||
"policies": "访问策略",
|
"policies": "访问策略",
|
||||||
"auditLog": "审计日志",
|
"auditLog": "审计日志",
|
||||||
"salesStats": "销售统计",
|
"salesStats": "销售统计",
|
||||||
"landings": "落地页"
|
"landings": "落地页",
|
||||||
|
"referralNetwork": "推荐网络"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
@@ -974,7 +975,8 @@
|
|||||||
"policiesDesc": "配置ABAC访问控制策略",
|
"policiesDesc": "配置ABAC访问控制策略",
|
||||||
"auditLogDesc": "查看系统活动和访问历史",
|
"auditLogDesc": "查看系统活动和访问历史",
|
||||||
"salesStatsDesc": "销售分析与趋势",
|
"salesStatsDesc": "销售分析与趋势",
|
||||||
"landingsDesc": "快速购买落地页"
|
"landingsDesc": "快速购买落地页",
|
||||||
|
"referralNetworkDesc": "推荐图表和广告活动可视化"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "销售统计",
|
"title": "销售统计",
|
||||||
@@ -1056,6 +1058,98 @@
|
|||||||
},
|
},
|
||||||
"loadError": "加载统计失败"
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "流量使用",
|
"title": "流量使用",
|
||||||
"subtitle": "按节点的用户流量统计",
|
"subtitle": "按节点的用户流量统计",
|
||||||
@@ -1617,6 +1711,12 @@
|
|||||||
"adminLabel": "管理员",
|
"adminLabel": "管理员",
|
||||||
"replyPlaceholder": "输入您的回复...",
|
"replyPlaceholder": "输入您的回复...",
|
||||||
"sendReply": "发送回复",
|
"sendReply": "发送回复",
|
||||||
|
"attachMedia": "附加媒体",
|
||||||
|
"invalidFileType": "文件类型无效。请使用图片、视频 (MP4, WebM) 或文档 (PDF, DOC, TXT, ZIP)。",
|
||||||
|
"fileTooLarge": "文件太大。最大 10MB。",
|
||||||
|
"uploadFailed": "上传文件失败",
|
||||||
|
"uploading": "上传中...",
|
||||||
|
"uploadComplete": "已上传",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"settingsSubtitle": "配置工单系统和SLA",
|
"settingsSubtitle": "配置工单系统和SLA",
|
||||||
"settingsLoadError": "加载设置失败",
|
"settingsLoadError": "加载设置失败",
|
||||||
|
|||||||
@@ -248,6 +248,17 @@ const ArrowsUpDownIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const NetworkGraphIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
@@ -569,6 +580,13 @@ export default function AdminPanel() {
|
|||||||
description: t('admin.panel.withdrawalsDesc'),
|
description: t('admin.panel.withdrawalsDesc'),
|
||||||
permission: 'withdrawals:read',
|
permission: 'withdrawals:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/referral-network',
|
||||||
|
icon: <NetworkGraphIcon />,
|
||||||
|
title: t('admin.nav.referralNetwork'),
|
||||||
|
description: t('admin.panel.referralNetworkDesc'),
|
||||||
|
permission: 'stats:read',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -6,6 +6,34 @@ import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '..
|
|||||||
import { ticketsApi } from '../api/tickets';
|
import { ticketsApi } from '../api/tickets';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
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<string, string> = {
|
||||||
|
'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({
|
function AdminMessageMedia({
|
||||||
message,
|
message,
|
||||||
t,
|
t,
|
||||||
@@ -80,6 +108,22 @@ function AdminMessageMedia({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.media_type === 'video') {
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<video
|
||||||
|
src={mediaUrl}
|
||||||
|
controls
|
||||||
|
className="max-h-64 max-w-full rounded-lg"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
{message.media_caption && (
|
||||||
|
<p className="mt-1 text-xs text-dark-400">{message.media_caption}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<a
|
<a
|
||||||
@@ -93,9 +137,13 @@ function AdminMessageMedia({
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={1.5}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{message.media_caption || `Download ${message.media_type}`}
|
{message.media_caption || `Download ${message.media_type}`}
|
||||||
</a>
|
</a>
|
||||||
@@ -126,6 +174,20 @@ export default function AdminTickets() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
const [replyText, setReplyText] = useState('');
|
const [replyText, setReplyText] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [attachment, setAttachment] = useState<MediaAttachment | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const previewRef = useRef<string | null>(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({
|
const { data: stats } = useQuery({
|
||||||
queryKey: ['admin-ticket-stats'],
|
queryKey: ['admin-ticket-stats'],
|
||||||
@@ -149,10 +211,18 @@ export default function AdminTickets() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const replyMutation = useMutation({
|
const replyMutation = useMutation({
|
||||||
mutationFn: ({ ticketId, message }: { ticketId: number; message: string }) =>
|
mutationFn: ({
|
||||||
adminApi.replyToTicket(ticketId, message),
|
ticketId,
|
||||||
|
message,
|
||||||
|
media,
|
||||||
|
}: {
|
||||||
|
ticketId: number;
|
||||||
|
message: string;
|
||||||
|
media?: { media_type?: string; media_file_id?: string; media_caption?: string };
|
||||||
|
}) => adminApi.replyToTicket(ticketId, message, media),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
|
clearAttachment();
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] });
|
queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-tickets'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-tickets'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] });
|
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<HTMLInputElement>) => {
|
||||||
|
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) => {
|
const handleReply = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedTicketId || !replyText.trim()) return;
|
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) => {
|
const getStatusBadge = (status: string) => {
|
||||||
@@ -321,7 +465,11 @@ export default function AdminTickets() {
|
|||||||
{ticketsData?.items.map((ticket) => (
|
{ticketsData?.items.map((ticket) => (
|
||||||
<button
|
<button
|
||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
onClick={() => setSelectedTicketId(ticket.id)}
|
onClick={() => {
|
||||||
|
setSelectedTicketId(ticket.id);
|
||||||
|
setReplyText('');
|
||||||
|
clearAttachment();
|
||||||
|
}}
|
||||||
className={`w-full rounded-xl border p-4 text-left transition-all ${
|
className={`w-full rounded-xl border p-4 text-left transition-all ${
|
||||||
selectedTicketId === ticket.id
|
selectedTicketId === ticket.id
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
@@ -359,7 +507,12 @@ export default function AdminTickets() {
|
|||||||
{ticket.last_message.is_from_admin
|
{ticket.last_message.is_from_admin
|
||||||
? t('admin.tickets.you')
|
? t('admin.tickets.you')
|
||||||
: t('admin.tickets.user')}
|
: t('admin.tickets.user')}
|
||||||
: {ticket.last_message.message_text.substring(0, 50)}...
|
:{' '}
|
||||||
|
{ticket.last_message.message_text
|
||||||
|
? `${ticket.last_message.message_text.substring(0, 50)}${ticket.last_message.message_text.length > 50 ? '...' : ''}`
|
||||||
|
: ticket.last_message.has_media
|
||||||
|
? `[${ticket.last_message.media_type}]`
|
||||||
|
: '...'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -501,7 +654,9 @@ export default function AdminTickets() {
|
|||||||
{new Date(msg.created_at).toLocaleString()}
|
{new Date(msg.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap text-dark-200">{msg.message_text}</p>
|
{msg.message_text && (
|
||||||
|
<p className="whitespace-pre-wrap text-dark-200">{msg.message_text}</p>
|
||||||
|
)}
|
||||||
<AdminMessageMedia message={msg} t={t} />
|
<AdminMessageMedia message={msg} t={t} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -517,10 +672,118 @@ export default function AdminTickets() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
className="input resize-none"
|
className="input resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 flex justify-end">
|
|
||||||
|
{/* Attachment preview */}
|
||||||
|
{attachment && (
|
||||||
|
<div className="mt-2 flex items-center gap-3 rounded-lg border border-dark-700/50 bg-dark-800/50 p-2">
|
||||||
|
{attachment.mediaType === 'photo' && attachment.preview ? (
|
||||||
|
<img
|
||||||
|
src={attachment.preview}
|
||||||
|
alt="Preview"
|
||||||
|
className="h-12 w-12 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-dark-700">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
{attachment.mediaType === 'video' ? (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm text-dark-200">{attachment.file.name}</div>
|
||||||
|
{attachment.uploading && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-accent-400">
|
||||||
|
<span className="h-3 w-3 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
{t('admin.tickets.uploading')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{attachment.error && (
|
||||||
|
<div className="text-xs text-red-400">{attachment.error}</div>
|
||||||
|
)}
|
||||||
|
{attachment.fileId && !attachment.uploading && (
|
||||||
|
<div className="text-xs text-success-400">
|
||||||
|
{t('admin.tickets.uploadComplete')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearAttachment}
|
||||||
|
className="shrink-0 rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPT_STRING}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={!!attachment?.uploading}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-dark-700/50 px-3 py-2 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('admin.tickets.attachMedia')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!replyText.trim() || replyMutation.isPending}
|
disabled={
|
||||||
|
!replyText.trim() ||
|
||||||
|
replyMutation.isPending ||
|
||||||
|
!!attachment?.uploading ||
|
||||||
|
!!attachment?.error
|
||||||
|
}
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
>
|
>
|
||||||
{replyMutation.isPending ? (
|
{replyMutation.isPending ? (
|
||||||
|
|||||||
167
src/pages/ReferralNetwork/ReferralNetwork.tsx
Normal file
167
src/pages/ReferralNetwork/ReferralNetwork.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
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';
|
||||||
|
import { UserDetailPanel } from './components/UserDetailPanel';
|
||||||
|
import { CampaignDetailPanel } from './components/CampaignDetailPanel';
|
||||||
|
import { NetworkStats } from './components/NetworkStats';
|
||||||
|
import { NetworkLegend } from './components/NetworkLegend';
|
||||||
|
import { NetworkControls } from './components/NetworkControls';
|
||||||
|
|
||||||
|
export function ReferralNetwork() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const selectedNode = useReferralNetworkStore((s) => s.selectedNode);
|
||||||
|
const scope = useReferralNetworkStore((s) => s.scope);
|
||||||
|
const addScope = useReferralNetworkStore((s) => s.addScope);
|
||||||
|
const removeScope = useReferralNetworkStore((s) => s.removeScope);
|
||||||
|
const clearScope = useReferralNetworkStore((s) => s.clearScope);
|
||||||
|
|
||||||
|
const { mobile: mobileHeaderHeight, bottomSafeArea } = useHeaderHeight();
|
||||||
|
|
||||||
|
const hasScope = scope.length > 0;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: networkData,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()],
|
||||||
|
queryFn: () => referralNetworkApi.getScopedGraph(scope),
|
||||||
|
enabled: hasScope,
|
||||||
|
staleTime: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPanelOpen = selectedNode !== null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
id="referral-network-container"
|
||||||
|
className="fixed inset-x-0 bottom-0 z-50 grid grid-rows-[auto_1fr] bg-[#0a0a0f] lg:!top-14"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
top: mobileHeaderHeight,
|
||||||
|
'--safe-bottom': `${bottomSafeArea}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="z-20 border-b border-dark-700/50 bg-dark-900/90 backdrop-blur-md">
|
||||||
|
{/* Mobile: two rows — title on top, selector below */}
|
||||||
|
<div className="flex flex-col gap-1.5 px-3 py-2 sm:flex-row sm:items-center sm:gap-3 sm:px-4 sm:py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AdminBackButton />
|
||||||
|
<h1 className="shrink-0 text-sm font-bold text-dark-100 sm:text-base">
|
||||||
|
{t('admin.referralNetwork.title')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<ScopeSelector
|
||||||
|
value={scope}
|
||||||
|
onAdd={addScope}
|
||||||
|
onRemove={removeScope}
|
||||||
|
onClear={clearScope}
|
||||||
|
className="min-w-0 flex-1 sm:max-w-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
{/* Empty state: no scope selected */}
|
||||||
|
{!hasScope && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="mx-auto mb-4 h-12 w-12 text-dark-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="max-w-xs text-sm text-dark-500">
|
||||||
|
{t('admin.referralNetwork.scope.emptyState')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{hasScope && isLoading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.referralNetwork.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{hasScope && isError && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-error-400">{t('admin.referralNetwork.error')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty data */}
|
||||||
|
{hasScope &&
|
||||||
|
networkData &&
|
||||||
|
networkData.users.length === 0 &&
|
||||||
|
networkData.campaigns.length === 0 && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-dark-500">{t('admin.referralNetwork.empty')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Graph + overlays */}
|
||||||
|
{hasScope &&
|
||||||
|
networkData &&
|
||||||
|
(networkData.users.length > 0 || networkData.campaigns.length > 0) && (
|
||||||
|
<>
|
||||||
|
<NetworkGraph data={networkData} className="absolute inset-0 h-full w-full" />
|
||||||
|
|
||||||
|
<div className="absolute bottom-[calc(12px+var(--safe-bottom,0px))] left-3 z-10 sm:bottom-4 sm:left-4">
|
||||||
|
<NetworkStats data={networkData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-[calc(12px+var(--safe-bottom,0px))] right-3 z-10 hidden sm:bottom-4 sm:right-4 sm:block">
|
||||||
|
<NetworkLegend />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-[calc(12px+var(--safe-bottom,0px))] left-1/2 z-10 -translate-x-1/2 sm:bottom-4">
|
||||||
|
<NetworkControls />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail panel (slide-in from right) */}
|
||||||
|
<div
|
||||||
|
className={`absolute right-0 top-0 z-30 flex h-full w-full flex-col border-l border-dark-700/50 bg-dark-900/95 backdrop-blur-md transition-transform duration-300 ease-in-out sm:w-[400px] ${
|
||||||
|
isPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedNode?.type === 'user' && (
|
||||||
|
<UserDetailPanel
|
||||||
|
userId={selectedNode.id}
|
||||||
|
className="flex flex-1 flex-col overflow-hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedNode?.type === 'campaign' && (
|
||||||
|
<CampaignDetailPanel
|
||||||
|
campaignId={selectedNode.id}
|
||||||
|
className="flex flex-1 flex-col overflow-hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx
Normal file
164
src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { referralNetworkApi } from '@/api/referralNetwork';
|
||||||
|
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||||
|
import { formatKopeksToRubles } from '../utils';
|
||||||
|
|
||||||
|
interface CampaignDetailPanelProps {
|
||||||
|
campaignId: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: campaign,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['referral-network-campaign', campaignId],
|
||||||
|
queryFn: () => referralNetworkApi.getCampaignDetail(campaignId),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-dark-700/50 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-dark-100">{campaign?.name ?? '...'}</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="overflow-y-auto p-4 pb-[calc(1rem+var(--safe-bottom,0px))]">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="py-8 text-center text-sm text-error-400">
|
||||||
|
{t('admin.referralNetwork.error')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{campaign && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-500">
|
||||||
|
{t('admin.referralNetwork.campaign.startParam')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-200">{campaign.start_parameter}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||||
|
campaign.is_active
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-dark-700/50 text-dark-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{campaign.is_active
|
||||||
|
? t('admin.referralNetwork.campaign.active')
|
||||||
|
: t('admin.referralNetwork.campaign.inactive')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.campaign.directUsers')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-100">{campaign.direct_users}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.campaign.totalNetwork')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-100">{campaign.total_network_users}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.campaign.totalRevenue')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-accent-400">
|
||||||
|
{formatKopeksToRubles(campaign.total_revenue_kopeks)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.campaign.conversionRate')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-100">
|
||||||
|
{campaign.conversion_rate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.campaign.avgCheck')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-100">
|
||||||
|
{formatKopeksToRubles(campaign.avg_check_kopeks)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top referrers */}
|
||||||
|
{campaign.top_referrers.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||||
|
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.campaign.topReferrers')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{campaign.top_referrers.map((referrer, index) => (
|
||||||
|
<div
|
||||||
|
key={referrer.user_id}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-dark-700 text-[10px] font-medium text-dark-300">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-dark-200">
|
||||||
|
{referrer.username ? `@${referrer.username}` : `#${referrer.user_id}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-dark-300">{referrer.referral_count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/pages/ReferralNetwork/components/NetworkControls.tsx
Normal file
163
src/pages/ReferralNetwork/components/NetworkControls.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getSigmaInstance } from '../sigmaGlobals';
|
||||||
|
|
||||||
|
interface NetworkControlsProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkControls({ className }: NetworkControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
// Sync fullscreen state when user exits via Escape key or browser UI
|
||||||
|
const handleFullscreenChange = useCallback(() => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
}, [handleFullscreenChange]);
|
||||||
|
|
||||||
|
function handleZoomIn() {
|
||||||
|
const sigma = getSigmaInstance();
|
||||||
|
if (!sigma) return;
|
||||||
|
const camera = sigma.getCamera();
|
||||||
|
camera.animate({ ratio: camera.ratio / 1.5 }, { duration: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomOut() {
|
||||||
|
const sigma = getSigmaInstance();
|
||||||
|
if (!sigma) return;
|
||||||
|
const camera = sigma.getCamera();
|
||||||
|
camera.animate({ ratio: camera.ratio * 1.5 }, { duration: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetZoom() {
|
||||||
|
const sigma = getSigmaInstance();
|
||||||
|
if (!sigma) return;
|
||||||
|
const camera = sigma.getCamera();
|
||||||
|
camera.animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFullscreen() {
|
||||||
|
const container = document.getElementById('referral-network-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
container
|
||||||
|
.requestFullscreen()
|
||||||
|
.then(() => setIsFullscreen(true))
|
||||||
|
.catch(() => {});
|
||||||
|
} else {
|
||||||
|
document
|
||||||
|
.exitFullscreen()
|
||||||
|
.then(() => setIsFullscreen(false))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
label: t('admin.referralNetwork.controls.zoomIn'),
|
||||||
|
onClick: handleZoomIn,
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.referralNetwork.controls.zoomOut'),
|
||||||
|
onClick: handleZoomOut,
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.referralNetwork.controls.resetZoom'),
|
||||||
|
onClick: handleResetZoom,
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.referralNetwork.controls.fullscreen'),
|
||||||
|
onClick: handleFullscreen,
|
||||||
|
icon: isFullscreen ? (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex gap-1 rounded-xl border border-dark-700/50 bg-dark-900/80 p-1.5 backdrop-blur-md ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
{buttons.map((btn) => (
|
||||||
|
<button
|
||||||
|
key={btn.label}
|
||||||
|
onClick={btn.onClick}
|
||||||
|
title={btn.label}
|
||||||
|
aria-label={btn.label}
|
||||||
|
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
{btn.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/pages/ReferralNetwork/components/NetworkFilters.tsx
Normal file
202
src/pages/ReferralNetwork/components/NetworkFilters.tsx
Normal file
@@ -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<HTMLDivElement>(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 = (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Campaigns */}
|
||||||
|
{data.campaigns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-dark-400">
|
||||||
|
{t('admin.referralNetwork.filters.campaigns')}
|
||||||
|
</label>
|
||||||
|
<div className="max-h-32 space-y-1 overflow-y-auto">
|
||||||
|
{data.campaigns.map((campaign) => (
|
||||||
|
<label
|
||||||
|
key={campaign.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-dark-800/50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.campaigns.includes(campaign.id)}
|
||||||
|
onChange={() => toggleCampaign(campaign.id)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500/30"
|
||||||
|
/>
|
||||||
|
<span className="truncate text-dark-200">{campaign.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Partners only */}
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.partnersOnly}
|
||||||
|
onChange={(e) => updateFilters({ partnersOnly: e.target.checked })}
|
||||||
|
className="h-3.5 w-3.5 rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500/30"
|
||||||
|
/>
|
||||||
|
<span className="text-dark-200">{t('admin.referralNetwork.filters.partnersOnly')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Min referrals */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 flex items-center justify-between text-xs font-medium text-dark-400">
|
||||||
|
<span>{t('admin.referralNetwork.filters.minReferrals')}</span>
|
||||||
|
<span className="font-mono text-dark-300">{filters.minReferrals}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
value={filters.minReferrals}
|
||||||
|
onChange={(e) => updateFilters({ minReferrals: Number(e.target.value) })}
|
||||||
|
className="w-full accent-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset */}
|
||||||
|
<button
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="w-full rounded-lg border border-dark-700/50 py-1.5 text-xs font-medium text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
{t('admin.referralNetwork.filters.reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={panelRef} className={`relative shrink-0 ${className ?? ''}`}>
|
||||||
|
{/* Trigger button — always rendered */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-label={t('admin.referralNetwork.filters.title')}
|
||||||
|
className={`relative flex items-center gap-2 rounded-lg border px-2.5 py-2 text-sm transition-colors ${
|
||||||
|
isOpen
|
||||||
|
? 'border-accent-500/50 bg-dark-800 text-dark-100'
|
||||||
|
: 'border-dark-700/50 bg-dark-800/80 text-dark-300 hover:border-dark-600 hover:text-dark-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">{t('admin.referralNetwork.filters.title')}</span>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Desktop: absolute dropdown below button */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 hidden sm:block">
|
||||||
|
<div className="w-64 rounded-xl border border-dark-700/50 bg-dark-900/95 p-4 shadow-xl backdrop-blur-md">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-dark-100">
|
||||||
|
{t('admin.referralNetwork.filters.title')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
className="text-dark-500 transition-colors hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{panelContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile: full-screen overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-x-0 top-0 z-50 sm:hidden">
|
||||||
|
<div className="fixed inset-0 bg-black/60" onClick={() => setIsOpen(false)} />
|
||||||
|
<div className="relative mx-3 mt-3 rounded-xl border border-dark-700/50 bg-dark-900/95 p-4 backdrop-blur-md">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-dark-100">
|
||||||
|
{t('admin.referralNetwork.filters.title')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{panelContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
src/pages/ReferralNetwork/components/NetworkGraph.tsx
Normal file
384
src/pages/ReferralNetwork/components/NetworkGraph.tsx
Normal file
@@ -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<string> {
|
||||||
|
const hidden = new Set<string>();
|
||||||
|
const filterCampaignSet = new Set(filters.campaigns);
|
||||||
|
const hasCampaignFilter = filterCampaignSet.size > 0;
|
||||||
|
|
||||||
|
graph.forEachNode((node, attrs) => {
|
||||||
|
if (attrs.nodeType === 'user') {
|
||||||
|
if (filters.partnersOnly && !attrs.isPartner) {
|
||||||
|
hidden.add(node);
|
||||||
|
} else if ((attrs.directReferrals ?? 0) < filters.minReferrals) {
|
||||||
|
hidden.add(node);
|
||||||
|
} else if (
|
||||||
|
hasCampaignFilter &&
|
||||||
|
attrs.campaignId !== null &&
|
||||||
|
!filterCampaignSet.has(attrs.campaignId)
|
||||||
|
) {
|
||||||
|
hidden.add(node);
|
||||||
|
}
|
||||||
|
} else if (attrs.nodeType === 'campaign') {
|
||||||
|
if (hasCampaignFilter && !filterCampaignSet.has(attrs.nodeId)) {
|
||||||
|
hidden.add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FA2_DURATION_MS = 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<HTMLDivElement>(null);
|
||||||
|
const sigmaRef = useRef<Sigma | null>(null);
|
||||||
|
const graphRef = useRef<Graph | null>(null);
|
||||||
|
const hiddenNodesRef = useRef<Set<string>>(new Set());
|
||||||
|
const fa2Ref = useRef<FA2LayoutSupervisor | null>(null);
|
||||||
|
const fa2TimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
||||||
|
const setHoveredNode = useReferralNetworkStore((s) => s.setHoveredNode);
|
||||||
|
const 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 <div ref={containerRef} className={`bg-[#0a0a0f] ${className ?? ''}`} />;
|
||||||
|
}
|
||||||
58
src/pages/ReferralNetwork/components/NetworkLegend.tsx
Normal file
58
src/pages/ReferralNetwork/components/NetworkLegend.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-3 backdrop-blur-md ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
<h4 className="mb-2 text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.legend.title')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{USER_LEGEND_ITEMS.map((item) => (
|
||||||
|
<div key={item.labelKey} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: item.colorKey }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-dark-300">{t(item.labelKey)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Partner → Campaign edge */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-0.5 w-4 shrink-0 rounded-full" style={{ backgroundColor: '#ff8a65' }} />
|
||||||
|
<span className="text-xs text-dark-300">
|
||||||
|
{t('admin.referralNetwork.legend.partnerCampaignEdge')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Campaign node with gradient to represent varying colors */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2.5 w-2.5 shrink-0 rounded-full" style={gradientStyle} />
|
||||||
|
<span className="text-xs text-dark-300">
|
||||||
|
{t('admin.referralNetwork.legend.campaignNode')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/pages/ReferralNetwork/components/NetworkStats.tsx
Normal file
48
src/pages/ReferralNetwork/components/NetworkStats.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { NetworkGraphData } from '@/types/referralNetwork';
|
||||||
|
import { formatKopeksToRubles } from '../utils';
|
||||||
|
|
||||||
|
interface NetworkStatsProps {
|
||||||
|
data: NetworkGraphData;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkStats({ data, className }: NetworkStatsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: t('admin.referralNetwork.stats.totalUsers'),
|
||||||
|
value: data.total_users.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.referralNetwork.stats.totalReferrers'),
|
||||||
|
value: data.total_referrers.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.referralNetwork.stats.totalCampaigns'),
|
||||||
|
value: data.total_campaigns.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.referralNetwork.stats.totalEarnings'),
|
||||||
|
value: `${formatKopeksToRubles(data.total_earnings_kopeks)} ₽`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-2 backdrop-blur-md sm:p-3 ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 sm:gap-3">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div key={stat.label}>
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{stat.label}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-sm font-semibold text-dark-100">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
467
src/pages/ReferralNetwork/components/ScopeSelector.tsx
Normal file
467
src/pages/ReferralNetwork/components/ScopeSelector.tsx
Normal file
@@ -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<ScopeType, string> = {
|
||||||
|
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<ScopeType, string> = {
|
||||||
|
campaign: 'C',
|
||||||
|
partner: 'P',
|
||||||
|
user: 'U',
|
||||||
|
};
|
||||||
|
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon({ className = 'h-3 w-3' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner({ size = 'h-5 w-5' }: { size?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${size} animate-spin rounded-full border-2 border-dark-600 border-t-accent-400`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyMessage({ text }: { text: string }) {
|
||||||
|
return <div className="px-4 py-3 text-center text-sm text-dark-500">{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-dark-700/50 focus-visible:bg-dark-700/50 focus-visible:outline-none ${
|
||||||
|
selected ? 'bg-dark-700/30' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${AVATAR_COLORS[type]}`}
|
||||||
|
>
|
||||||
|
{selected ? <CheckIcon /> : AVATAR_LETTERS[type]}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-dark-100">{title}</p>
|
||||||
|
<p className="truncate text-xs text-dark-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
{badge}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState<ScopeType>('campaign');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [debouncedUserQuery, setDebouncedUserQuery] = useState('');
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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<Record<ScopeType, string>>(
|
||||||
|
() => ({
|
||||||
|
campaign: t('admin.referralNetwork.scope.campaign'),
|
||||||
|
partner: t('admin.referralNetwork.scope.partner'),
|
||||||
|
user: t('admin.referralNetwork.scope.user'),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const placeholders = useMemo<Record<ScopeType, string>>(
|
||||||
|
() => ({
|
||||||
|
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 (
|
||||||
|
<div ref={containerRef} className={`relative ${className ?? ''}`}>
|
||||||
|
{/* Chips row + add trigger */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Selected chips */}
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
|
||||||
|
{value.map((item) => (
|
||||||
|
<span
|
||||||
|
key={`${item.type}:${item.id}`}
|
||||||
|
className={`inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${CHIP_COLORS[item.type]}`}
|
||||||
|
>
|
||||||
|
<span className="max-w-[120px] truncate">{item.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(item.type, item.id)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{/* Clear all (only when 2+ items) */}
|
||||||
|
{value.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
aria-label={t('admin.referralNetwork.scope.clearAll')}
|
||||||
|
className="shrink-0 rounded-md p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<CloseIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||||
|
aria-label={t('admin.referralNetwork.scope.addScope')}
|
||||||
|
aria-expanded={isDropdownOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
className={`shrink-0 rounded-lg border border-dark-700/50 bg-dark-800 p-1.5 transition-colors hover:border-accent-500/50 hover:text-accent-400 ${
|
||||||
|
isMaxReached ? 'cursor-not-allowed text-dark-600 opacity-50' : 'text-dark-400'
|
||||||
|
}`}
|
||||||
|
disabled={isMaxReached && !isDropdownOpen}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-full z-50 mt-1 rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl backdrop-blur-md"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('admin.referralNetwork.scope.addScope')}
|
||||||
|
>
|
||||||
|
{/* Max reached banner */}
|
||||||
|
{isMaxReached && (
|
||||||
|
<div className="border-b border-dark-700/50 px-3 py-1.5 text-center text-xs text-warning-400">
|
||||||
|
{t('admin.referralNetwork.scope.maxReached', { max: MAX_SCOPE_ITEMS })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab bar + search input */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-dark-700/50 px-3 py-2">
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 rounded-lg border border-dark-700/50 bg-dark-900 p-0.5"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{SCOPE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
|
onClick={() => handleTabChange(tab)}
|
||||||
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'text-dark-400 hover:text-dark-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tabLabels[tab]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<svg
|
||||||
|
className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-dark-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||||
|
<Spinner size="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="max-h-64 overflow-y-auto" role="listbox" aria-multiselectable="true">
|
||||||
|
{activeTab === 'campaign' && renderCampaignList()}
|
||||||
|
{activeTab === 'partner' && renderPartnerList()}
|
||||||
|
{activeTab === 'user' && renderUserList()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderCampaignList() {
|
||||||
|
if (isScopeLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center px-4 py-6">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredCampaigns.length === 0) {
|
||||||
|
return <EmptyMessage text={t('admin.referralNetwork.scope.noResults')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredCampaigns.map((campaign) => (
|
||||||
|
<ScopeListItem
|
||||||
|
key={campaign.id}
|
||||||
|
type="campaign"
|
||||||
|
selected={isSelected('campaign', campaign.id)}
|
||||||
|
onClick={() => handleToggle('campaign', campaign.id, campaign.name)}
|
||||||
|
title={campaign.name}
|
||||||
|
subtitle={`${campaign.start_parameter} / ${campaign.direct_users} ${t('admin.referralNetwork.scope.users')}`}
|
||||||
|
badge={
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
|
campaign.is_active
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-dark-700/50 text-dark-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{campaign.is_active
|
||||||
|
? t('admin.referralNetwork.scope.active')
|
||||||
|
: t('admin.referralNetwork.scope.inactive')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPartnerList() {
|
||||||
|
if (isScopeLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center px-4 py-6">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredPartners.length === 0) {
|
||||||
|
return <EmptyMessage text={t('admin.referralNetwork.scope.noResults')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredPartners.map((partner) => (
|
||||||
|
<ScopeListItem
|
||||||
|
key={partner.id}
|
||||||
|
type="partner"
|
||||||
|
selected={isSelected('partner', partner.id)}
|
||||||
|
onClick={() => 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 <EmptyMessage text={t('admin.referralNetwork.scope.selectUser')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUserSearching) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center px-4 py-6">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = userSearchResults?.users ?? [];
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return <EmptyMessage text={t('admin.referralNetwork.scope.noResults')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.map((user) => (
|
||||||
|
<ScopeListItem
|
||||||
|
key={user.id}
|
||||||
|
type="user"
|
||||||
|
selected={isSelected('user', user.id)}
|
||||||
|
onClick={() => 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 ? (
|
||||||
|
<span className="shrink-0 rounded bg-warning-500/20 px-1.5 py-0.5 text-[10px] font-medium text-warning-400">
|
||||||
|
{t('admin.referralNetwork.user.partner')}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/pages/ReferralNetwork/components/UserDetailPanel.tsx
Normal file
203
src/pages/ReferralNetwork/components/UserDetailPanel.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { referralNetworkApi } from '@/api/referralNetwork';
|
||||||
|
import { useReferralNetworkStore } from '@/store/referralNetwork';
|
||||||
|
import { formatKopeksToRubles } from '../utils';
|
||||||
|
|
||||||
|
interface UserDetailPanelProps {
|
||||||
|
userId: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setSelectedNode = useReferralNetworkStore((s) => s.setSelectedNode);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: user,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['referral-network-user', userId],
|
||||||
|
queryFn: () => referralNetworkApi.getUserDetail(userId),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-dark-700/50 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-dark-100">{user?.display_name ?? '...'}</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="overflow-y-auto p-4 pb-[calc(1rem+var(--safe-bottom,0px))]">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="py-8 text-center text-sm text-error-400">
|
||||||
|
{t('admin.referralNetwork.error')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Identity */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{user.username && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-500">@</span>
|
||||||
|
<span className="font-mono text-dark-200">{user.username}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.tg_id && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-500">{t('admin.referralNetwork.user.tgId')}</span>
|
||||||
|
<span className="font-mono text-dark-200">{user.tg_id}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.email && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-500">{t('admin.referralNetwork.user.email')}</span>
|
||||||
|
<span className="truncate pl-4 font-mono text-dark-200">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.is_partner && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-400">
|
||||||
|
{t('admin.referralNetwork.user.partner')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscription */}
|
||||||
|
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||||
|
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.user.subscription')}
|
||||||
|
</h4>
|
||||||
|
{user.subscription_name ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-dark-100">{user.subscription_name}</p>
|
||||||
|
{user.subscription_end && (
|
||||||
|
<p className="mt-0.5 text-xs text-dark-400">
|
||||||
|
{t('admin.referralNetwork.user.validUntil', {
|
||||||
|
date: new Date(user.subscription_end).toLocaleDateString(),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-dark-500">
|
||||||
|
{t('admin.referralNetwork.user.noSubscription')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Personal stats */}
|
||||||
|
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||||
|
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.user.personalStats')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.user.totalSpent')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-100">
|
||||||
|
{formatKopeksToRubles(user.personal_spent_kopeks)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.user.referralEarnings')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-accent-400">
|
||||||
|
{formatKopeksToRubles(user.personal_revenue_kopeks)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Referral branch */}
|
||||||
|
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||||
|
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.user.referralBranch')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.user.directReferrals')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-100">{user.direct_referrals}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.user.branchSize')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-100">{user.total_branch_users}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.user.branchRevenue')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-dark-100">
|
||||||
|
{formatKopeksToRubles(user.branch_revenue_kopeks)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<div className="rounded-lg border border-dark-700/50 bg-dark-800/40 p-3">
|
||||||
|
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('admin.referralNetwork.user.source')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.user.referredBy')}
|
||||||
|
</span>
|
||||||
|
<span className="text-dark-200">
|
||||||
|
{user.referrer_display_name ?? t('admin.referralNetwork.user.organic')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{user.campaign_name && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('admin.referralNetwork.user.fromCampaign')}
|
||||||
|
</span>
|
||||||
|
<span className="text-dark-200">{user.campaign_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/pages/ReferralNetwork/index.tsx
Normal file
3
src/pages/ReferralNetwork/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { ReferralNetwork } from './ReferralNetwork';
|
||||||
|
|
||||||
|
export default ReferralNetwork;
|
||||||
15
src/pages/ReferralNetwork/sigmaGlobals.ts
Normal file
15
src/pages/ReferralNetwork/sigmaGlobals.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
52
src/pages/ReferralNetwork/utils.ts
Normal file
52
src/pages/ReferralNetwork/utils.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -409,48 +409,54 @@ export default function SubscriptionPurchase() {
|
|||||||
padding: '24px 28px',
|
padding: '24px 28px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Trial upgrade prompt */}
|
{/* Trial upgrade prompt — hidden when expired banner is active */}
|
||||||
{subscription?.is_trial && (
|
{subscription?.is_trial &&
|
||||||
<div
|
!(
|
||||||
className="mb-6 rounded-[14px] p-4"
|
isTariffsMode &&
|
||||||
style={{
|
purchaseOptions &&
|
||||||
background:
|
'subscription_is_expired' in purchaseOptions &&
|
||||||
'linear-gradient(135deg, rgba(255,184,0,0.08), rgba(var(--color-accent-400),0.06))',
|
purchaseOptions.subscription_is_expired
|
||||||
border: '1px solid rgba(255,184,0,0.15)',
|
) && (
|
||||||
}}
|
<div
|
||||||
>
|
className="mb-6 rounded-[14px] p-4"
|
||||||
<div className="flex items-start gap-3">
|
style={{
|
||||||
<div
|
background:
|
||||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
'linear-gradient(135deg, rgba(255,184,0,0.08), rgba(var(--color-accent-400),0.06))',
|
||||||
style={{ background: 'rgba(255,184,0,0.12)' }}
|
border: '1px solid rgba(255,184,0,0.15)',
|
||||||
>
|
}}
|
||||||
<svg
|
>
|
||||||
width="16"
|
<div className="flex items-start gap-3">
|
||||||
height="16"
|
<div
|
||||||
viewBox="0 0 24 24"
|
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
||||||
fill="none"
|
style={{ background: 'rgba(255,184,0,0.12)' }}
|
||||||
stroke="#FFB800"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
width="16"
|
||||||
strokeLinejoin="round"
|
height="16"
|
||||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
viewBox="0 0 24 24"
|
||||||
/>
|
fill="none"
|
||||||
</svg>
|
stroke="#FFB800"
|
||||||
</div>
|
strokeWidth="1.5"
|
||||||
<div>
|
aria-hidden="true"
|
||||||
<div className="text-sm font-semibold" style={{ color: '#FFB800' }}>
|
>
|
||||||
{t('subscription.trialUpgrade.title')}
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-dark-50/40">
|
<div>
|
||||||
{t('subscription.trialUpgrade.description')}
|
<div className="text-sm font-semibold" style={{ color: '#FFB800' }}>
|
||||||
|
{t('subscription.trialUpgrade.title')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[12px] text-dark-50/40">
|
||||||
|
{t('subscription.trialUpgrade.description')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Expired subscription notice */}
|
{/* Expired subscription notice */}
|
||||||
{isTariffsMode &&
|
{isTariffsMode &&
|
||||||
|
|||||||
81
src/store/referralNetwork.ts
Normal file
81
src/store/referralNetwork.ts
Normal file
@@ -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<string>(),
|
||||||
|
filters: { ...DEFAULT_FILTERS },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReferralNetworkState {
|
||||||
|
selectedNode: SelectedNode;
|
||||||
|
hoveredNodeId: string | null;
|
||||||
|
highlightedNodes: Set<string>;
|
||||||
|
filters: NetworkFilters;
|
||||||
|
scope: ScopeSelection[];
|
||||||
|
|
||||||
|
setSelectedNode: (node: SelectedNode) => void;
|
||||||
|
setHoveredNode: (id: string | null) => void;
|
||||||
|
setHighlightedNodes: (nodes: Set<string>) => void;
|
||||||
|
updateFilters: (filters: Partial<NetworkFilters>) => void;
|
||||||
|
resetFilters: () => void;
|
||||||
|
addScope: (item: ScopeSelection) => void;
|
||||||
|
removeScope: (type: ScopeSelection['type'], id: number) => void;
|
||||||
|
clearScope: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => ({
|
||||||
|
selectedNode: null,
|
||||||
|
hoveredNodeId: null,
|
||||||
|
highlightedNodes: new Set<string>(),
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
130
src/types/referralNetwork.ts
Normal file
130
src/types/referralNetwork.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user