diff --git a/.cursorrules b/.cursorrules index 721547acd..659e694f0 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,12 +1,15 @@ # peanut-ui Development Rules -**Version:** 0.0.1 | **Updated:** October 17, 2025 +**Version:** 0.0.2 | **Updated:** December 16, 2025 ## 🚫 Random - **Never open SVG files** - it crashes you. Only read jpeg, png, gif, or webp. - **Never run jq command** - it crashes you. - **Never run sleep** from command line - it hibernates pc. +- **Do not generate .md files** unless explicity told to do so. +- **Comments** should always be made in all lowercase and simple english +- **Error messages**, any error being shown in the ui should be user friendly and easy to understand, and any error being logged in consoles and sentry should be descriptive for developers to help with debugging ## πŸ’» Code Quality @@ -14,13 +17,28 @@ - **DRY** - do not repeat yourself. Reuse existing code and abstract shared functionality. Less code is better code. - this also means to use shared consts (e.g. check src/constants) - **Separate business logic from interface** - this is important for readability, debugging and testability. -- **Use explicit imports** where possible. - **Reuse existing components and functions** - don't hardcode hacky solutions. - **Warn about breaking changes** - when making changes, ensure you're not breaking existing functionality, and if there's a risk, explicitly WARN about it. - **Mention refactor opportunities** - if you notice an opportunity to refactor or improve existing code, mention it. DO NOT make any changes you were not explicitly told to do. Only mention the potential change to the user. - **Performance is important** - cache where possible, make sure to not make unnecessary re-renders or data fetching. - **Flag breaking changes** - always flag if changes done in Frontend are breaking and require action on Backend (or viceversa) +## πŸ”— URL as State (Critical for UX) + +- **URL is source of truth** - use query parameters for user-facing state that should survive navigation, refresh, or sharing (step indicators, amounts, filters, view modes, selected items) +- **Use nuqs library** - always use `useQueryStates` from [nuqs](https://nuqs.dev) for type-safe URL state management. never manually parse/set query params with router.push or URLSearchParams +- **Enable deep-linking** - users should be able to share or bookmark URLs mid-flow (e.g. `?step=inputAmount&amount=500¤cy=ARS`) +- **Proper navigation** - URL state enables correct back/forward browser button behavior +- **Multi-step flows** - the URL should always reflect current step and relevant data, making the app behave like a proper web app, not a trapped SPA +- **Reserve useState for ephemeral UI** - only use React useState for truly transient state: + - loading spinners and skeleton states + - modal open/close state + - form validation errors (unless they should persist) + - hover/focus states + - temporary UI animations +- **Don't URL-ify everything** - API responses, user authentication state, and internal component state generally shouldn't be in the URL unless they're user-facing and shareable +- **Type safety** - define parsers for query params (e.g. `parseAsInteger`, `parseAsStringEnum`) to ensure type safety and validation + ## 🚫 Import Rules (critical for build performance) - **No barrel imports** - never use `import * as X from '@/constants'` or create index.ts barrel files. always import from specific files (e.g. `import { PEANUT_API_URL } from '@/constants/general.consts'`). barrel imports slow down builds and cause bundling issues. @@ -28,6 +46,14 @@ - **No node.js packages in client components** - packages like `web-push`, `fs`, `crypto` (node) can't be used in `'use client'` files. use server actions or api routes instead. - **Check for legacy code** - before importing from a file, check if it has TODO comments marking it as legacy/deprecated. prefer newer implementations. +## 🚫 Export Rules (critical for build performance) + +- **Do not export multiple stuff from same component**: + - never export types or other utility methods from a component or a hook + - for types always use a separate file if they need to be reused + - and for utility/helper functions use a separate utils file to export them and use if they need to be reused + - same for files with multiple components exported, do not export multiple components from same file and if you see this done anywhere in the code, abstract it to other file + ## πŸ§ͺ Testing - **Test new code** - where tests make sense, test new code. Especially with fast unit tests. diff --git a/src/app/(mobile-ui)/dev/invite-graph/page.tsx b/src/app/(mobile-ui)/dev/full-graph/page.tsx similarity index 88% rename from src/app/(mobile-ui)/dev/invite-graph/page.tsx rename to src/app/(mobile-ui)/dev/full-graph/page.tsx index 25490770e..9f700d38b 100644 --- a/src/app/(mobile-ui)/dev/invite-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/full-graph/page.tsx @@ -2,13 +2,19 @@ import { useState, useCallback } from 'react' import { Button } from '@/components/0_Bruddle/Button' +import { useAuth } from '@/context/authContext' +import { IS_DEV } from '@/constants/general.consts' import InvitesGraph, { DEFAULT_FORCE_CONFIG, DEFAULT_VISIBILITY_CONFIG, DEFAULT_EXTERNAL_NODES_CONFIG, } from '@/components/Global/InvitesGraph' -export default function InviteGraphPage() { +// Allowed users for full graph access (frontend check - backend also validates) +const ALLOWED_USERNAMES = ['squirrel', 'kkonrad', 'hugo'] + +export default function FullGraphPage() { + const { user, isFetchingUser } = useAuth() const [apiKey, setApiKey] = useState('') const [apiKeySubmitted, setApiKeySubmitted] = useState(false) const [error, setError] = useState(null) @@ -26,6 +32,44 @@ export default function InviteGraphPage() { window.location.href = '/dev' }, []) + // Check if user is allowed (frontend defense - backend also validates) + // In dev mode, allow all users; in prod, restrict to allowed usernames + const isAllowedUser = + IS_DEV || (user?.user?.username && ALLOWED_USERNAMES.includes(user.user.username.toLowerCase())) + + // Loading state + if (isFetchingUser) { + return ( +
+
Loading...
+
+ ) + } + + // Access denied screen + if (!isAllowedUser) { + return ( +
+
+
+
πŸ”’
+

Access Restricted

+

This tool is only available to authorized users.

+ {user?.user?.username && ( +

Logged in as: {user.user.username}

+ )} +
+ +
+
+ ) + } + // API key input screen if (!apiKeySubmitted) { return ( @@ -33,7 +77,7 @@ export default function InviteGraphPage() {
πŸ•ΈοΈ
-

Invite Graph

+

Full Graph

Admin tool - Enter your API key to visualize the network

@@ -76,8 +120,8 @@ export default function InviteGraphPage() { renderOverlays={({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, @@ -433,27 +477,31 @@ export default function InviteGraphPage() { )} {!externalNodesError && externalNodesConfig.enabled && (
- {/* Min connections slider - show only external addresses used by N+ users */} + {/* Min connections - discrete options */}
- - Show if β‰₯{externalNodesConfig.minConnections} users - + Min users: +
+ {[1, 2, 3, 5, 10, 20, 50].map((val) => ( + + ))} +
- - setExternalNodesConfig({ - ...externalNodesConfig, - minConnections: parseInt(e.target.value), - }) - } - className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-orange-600" - />
{/* Type filters */}
@@ -613,7 +661,7 @@ export default function InviteGraphPage() {
{/* Other options */} -
+
- +
+ + {/* Top nodes slider */} +
+
+ Top nodes: + + {topNodes === 0 ? 'All' : topNodes.toLocaleString()} + +
+ setTopNodes(parseInt(e.target.value))} + className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-purple-600" + /> +
+ All + 5k + 10k +
{/* Activity window */} @@ -680,6 +743,10 @@ export default function InviteGraphPage() {
{/* Nodes */}
+ + + New + Active @@ -728,7 +795,9 @@ export default function InviteGraphPage() {

Click β†’ Grafana | Right-click β†’ Focus

- {!showAllNodes &&

Showing top 5000 nodes

} + {topNodes > 0 && ( +

Showing top {topNodes.toLocaleString()} nodes

+ )}
diff --git a/src/app/(mobile-ui)/dev/layout.tsx b/src/app/(mobile-ui)/dev/layout.tsx new file mode 100644 index 000000000..087150b36 --- /dev/null +++ b/src/app/(mobile-ui)/dev/layout.tsx @@ -0,0 +1,23 @@ +'use client' + +import { usePathname } from 'next/navigation' +import { notFound } from 'next/navigation' +import { IS_DEV } from '@/constants/general.consts' + +// Routes that are allowed in production (protected by API key / user check) +const PRODUCTION_ALLOWED_ROUTES = ['/dev/full-graph', '/dev/payment-graph'] + +export default function DevLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + + // In production, only allow specific routes (full-graph, payment-graph) + // Other dev tools (leaderboard, shake-test, dev index) are dev-only + if (!IS_DEV) { + const isAllowedInProd = PRODUCTION_ALLOWED_ROUTES.some((route) => pathname?.startsWith(route)) + if (!isAllowedInProd) { + notFound() + } + } + + return <>{children} +} diff --git a/src/app/(mobile-ui)/dev/page.tsx b/src/app/(mobile-ui)/dev/page.tsx index 193367576..90088522b 100644 --- a/src/app/(mobile-ui)/dev/page.tsx +++ b/src/app/(mobile-ui)/dev/page.tsx @@ -15,12 +15,20 @@ export default function DevToolsPage() { status: 'active', }, { - name: 'Invite Graph', - description: 'Interactive force-directed graph visualization of all user invites (admin only)', - path: '/dev/invite-graph', + name: 'Full Graph', + description: + 'Interactive force-directed graph visualization of all users, invites, and P2P activity (admin only)', + path: '/dev/full-graph', icon: 'πŸ•ΈοΈ', status: 'active', }, + { + name: 'Payment Graph', + description: 'P2P payment flow visualization', + path: '/dev/payment-graph', + icon: 'πŸ’Έ', + status: 'active', + }, { name: 'Shake Test', description: 'Test progressive shake animation and confetti for perk claiming', @@ -70,7 +78,7 @@ export default function DevToolsPage() {

ℹ️ Info

    -
  • β€’ These tools are publicly accessible (no login required)
  • +
  • β€’ These tools are only available in development mode
  • β€’ Perfect for testing on multiple devices
  • β€’ Share the URL with team members for testing
diff --git a/src/app/(mobile-ui)/dev/payment-graph/page.tsx b/src/app/(mobile-ui)/dev/payment-graph/page.tsx new file mode 100644 index 000000000..41354f2eb --- /dev/null +++ b/src/app/(mobile-ui)/dev/payment-graph/page.tsx @@ -0,0 +1,583 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' +import { Button } from '@/components/0_Bruddle/Button' +import InvitesGraph, { DEFAULT_FORCE_CONFIG } from '@/components/Global/InvitesGraph' + +export default function PaymentGraphPage() { + const searchParams = useSearchParams() + const [password, setPassword] = useState('') + const [passwordSubmitted, setPasswordSubmitted] = useState(false) + const [error, setError] = useState(null) + // Performance mode: limit to 1000 top nodes + const [performanceMode, setPerformanceMode] = useState(false) + + // Check for password in URL on mount + useEffect(() => { + const urlPassword = searchParams.get('password') + if (urlPassword) { + setPassword(urlPassword) + setPasswordSubmitted(true) + } + }, [searchParams]) + + const handlePasswordSubmit = useCallback(() => { + if (!password.trim()) { + setError('Please enter a password') + return + } + setError(null) + setPasswordSubmitted(true) + }, [password]) + + const handleClose = useCallback(() => { + window.location.href = '/dev' + }, []) + + // Password input screen (only shown if not provided in URL) + if (!passwordSubmitted) { + return ( +
+
+
+
πŸ’Έ
+

Payment Graph

+

P2P payment flow visualization

+
+ {error && ( +
+
Error
+
{error}
+
+ )} + setPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handlePasswordSubmit()} + placeholder="Password" + className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm transition-colors focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20" + /> + + +
+
+ ) + } + + return ( +
+ ( + <> + {/* Controls Panel - Top Right */} +
+

Display & Forces

+ +
+ {/* Scale indicator */} +
+ 0.1Γ— + 1Γ— + 10Γ— +
+ + {/* Repulsion Force */} +
+ + {forceConfig.charge.enabled && ( + + setForceConfig({ + ...forceConfig, + charge: { + ...forceConfig.charge, + strength: + DEFAULT_FORCE_CONFIG.charge.strength * + Math.pow(10, parseFloat(e.target.value)), + }, + }) + } + className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-cyan-600" + /> + )} +
+ + {/* P2P Force */} +
+ + {forceConfig.p2pLinks.enabled && ( + + setForceConfig({ + ...forceConfig, + p2pLinks: { + ...forceConfig.p2pLinks, + strength: + DEFAULT_FORCE_CONFIG.p2pLinks.strength * + Math.pow(10, parseFloat(e.target.value)), + }, + }) + } + className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-cyan-600" + /> + )} +
+ + {/* Center Force */} +
+ + {(forceConfig.center?.enabled ?? DEFAULT_FORCE_CONFIG.center.enabled) && ( +
+ + setForceConfig({ + ...forceConfig, + center: { + ...(forceConfig.center || DEFAULT_FORCE_CONFIG.center), + strength: + DEFAULT_FORCE_CONFIG.center.strength * + Math.pow(10, parseFloat(e.target.value)), + }, + }) + } + className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-amber-600" + /> +
+ )} +
+ + {/* Divider */} +
+ + {/* External Nodes Section */} +
+
+ + setExternalNodesConfig({ + ...externalNodesConfig, + enabled: e.target.checked, + // When enabling, default to merchants only + types: e.target.checked + ? { WALLET: false, BANK: false, MERCHANT: true } + : externalNodesConfig.types, + }) + } + className="h-3 w-3 rounded border-gray-300 text-orange-600" + /> + External Nodes + {externalNodesLoading && ( + + loading... + + )} + {externalNodesError && ( + + ❌ + + )} + {!externalNodesLoading && + !externalNodesError && + externalNodesConfig.enabled && ( + + {externalNodes.length} + + )} +
+ {externalNodesConfig.enabled && !externalNodesError && ( +
+ {/* Type filters - emoji only */} +
+ + + +
+ {/* Min connections */} +
+ Min users: +
+ {[1, 2, 3, 5, 10, 20, 50].map((val) => ( + + ))} +
+
+ {/* External link force strength */} +
+ + {(forceConfig.externalLinks?.enabled ?? + DEFAULT_FORCE_CONFIG.externalLinks.enabled) && ( + + setForceConfig({ + ...forceConfig, + externalLinks: { + ...(forceConfig.externalLinks || + DEFAULT_FORCE_CONFIG.externalLinks), + strength: + DEFAULT_FORCE_CONFIG.externalLinks.strength * + Math.pow(10, parseFloat(e.target.value)), + }, + }) + } + className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-orange-500" + /> + )} +
+
+ )} +
+ + {/* Divider */} +
+ + {/* Other options */} +
+ +
+ + {/* Performance mode toggle */} +
+ +
+ + {/* Action buttons */} +
+ + +
+
+ + {/* Compact Legend */} +
+
+ {/* Nodes - by P2P activity */} +
+ + + P2P Active + + + + No P2P + +
+ {/* External nodes */} + {externalNodesConfig.enabled && ( +
+ {externalNodesConfig.types.WALLET && ( + + + β‚Ώ + + )} + {externalNodesConfig.types.BANK && ( + + + Bank + + )} + {externalNodesConfig.types.MERCHANT && ( + + + Merchant + + )} +
+ )} + {/* Edges */} +
+ + P2P + +
+

Click β†’ Grafana | Right-click β†’ Focus

+

+ {performanceMode ? 'Limited to 1000 nodes' : 'Limited to 5000 nodes'} +

+
+
+
+ + )} + /> +
+ ) +} diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx index 55c04719b..25dddbf6f 100644 --- a/src/app/(mobile-ui)/points/page.tsx +++ b/src/app/(mobile-ui)/points/page.tsx @@ -22,6 +22,7 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { type PointsInvite } from '@/services/services.types' import { useEffect } from 'react' import InvitesGraph from '@/components/Global/InvitesGraph' +import { IS_DEV } from '@/constants/general.consts' const PointsPage = () => { const router = useRouter() @@ -53,11 +54,12 @@ const PointsPage = () => { enabled: !!user?.user.userId, }) + // In dev mode, show graph for all users. In production, only for Seedling badge holders. + const hasSeedlingBadge = user?.user?.badges?.some((badge) => badge.code === 'SEEDLING_DEVCONNECT_BA_2025') const { data: myGraphResult } = useQuery({ queryKey: ['myInviteGraph', user?.user.userId], queryFn: () => pointsApi.getUserInvitesGraph(), - enabled: - !!user?.user.userId && user?.user?.badges?.some((badge) => badge.code === 'SEEDLING_DEVCONNECT_BA_2025'), + enabled: !!user?.user.userId && (IS_DEV || hasSeedlingBadge), }) const username = user?.user.username const { inviteCode, inviteLink } = generateInviteCodeLink(username ?? '') @@ -171,6 +173,29 @@ const PointsPage = () => {
+ {/* User Graph - shows user, their inviter, and points flow regardless of invites */} + {myGraphResult?.data && ( + <> + + + +
+ +

+ {IS_DEV + ? 'Experimental. Enabled for all users in dev mode.' + : 'Experimental. Only available for Seedlings badge holders.'} +

+
+ + )} + {invites && invites?.invitees && invites.invitees.length > 0 && ( <> {
- {/* Invite Graph */} - {myGraphResult?.data && ( - <> - - - -
- -

- Experimental. Only available for Seedlings badge holders. -

-
- - )} -
{invites.invitees?.map((invite: PointsInvite, i: number) => { const username = invite.username diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index c45b02d52..de668ef0d 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -12,7 +12,7 @@ import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' import { useEffect, useMemo, useRef, useState } from 'react' import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm' -import { addBankAccount } from '@/app/actions/users' +import { addBankAccount, updateUserById } from '@/app/actions/users' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' import { useWebSocket } from '@/hooks/useWebSocket' @@ -136,6 +136,38 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // scenario (2): if the user hasn't completed kyc yet if (!isUserKycVerified) { + // update user's name and email if they are not present + const hasNameOnLoad = !!user?.user.fullName + const hasEmailOnLoad = !!user?.user.email + + if (!hasNameOnLoad || !hasEmailOnLoad) { + if (user?.user.userId) { + // Build update payload to only update missing fields + const updatePayload: Record = { userId: user.user.userId } + + if (!hasNameOnLoad && rawData.accountOwnerName) { + updatePayload.fullName = rawData.accountOwnerName.trim() + } + + if (!hasEmailOnLoad && rawData.email) { + updatePayload.email = rawData.email.trim() + } + + // Only call update if we have fields to update + if (Object.keys(updatePayload).length > 1) { + const result = await updateUserById(updatePayload) + if (result.error) { + return { error: result.error } + } + try { + await fetchUser() + } catch (err) { + console.error('Failed to refresh user data after update:', err) + } + } + } + } + setIsKycModalOpen(true) } diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index fdd9042ed..5efe6d4d7 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -14,7 +14,7 @@ * - visibilityConfig: Remove nodes/edges from simulation * - activeNodes / inactiveNodes: Filter by activity status * - inviteEdges / p2pEdges: Filter edge types - * - showAllNodes: Toggle 5000 node limit + * - topNodes: Limit to top N nodes by points (0 = all, default 5000) * - externalNodesConfig: Add/remove external nodes * * REINSERTION STRATEGY (when toggling nodes/edges back ON): @@ -34,7 +34,14 @@ import { useState, useCallback, useMemo, useRef, useEffect } from 'react' import dynamic from 'next/dynamic' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' -import { pointsApi, type ExternalNode, type ExternalNodeType } from '@/services/points' +import { + pointsApi, + type ExternalNode, + type ExternalNodeType, + type SizeLabel, + type FrequencyLabel, + type VolumeLabel, +} from '@/services/points' import { inferBankAccountType } from '@/utils/bridge.utils' import { useGraphPreferences } from '@/hooks/useGraphPreferences' @@ -46,18 +53,61 @@ const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { const CLICK_MAX_DURATION_MS = 200 const CLICK_MAX_DISTANCE_PX = 5 +// Helper to convert qualitative size labels to numeric points for graph calculations +// Used in payment mode where real points aren't sent to frontend +function sizeLabelToPoints(size: SizeLabel | undefined): number { + if (!size) return 10 // default + switch (size) { + case 'tiny': + return 5 + case 'small': + return 50 + case 'medium': + return 500 + case 'large': + return 5000 + case 'huge': + return 50000 + } +} + +// Helper to get effective points for a node (real points in full mode, converted from size in payment mode) +function getNodePoints(node: any): number { + // Payment mode: node has size label instead of totalPoints + if (node.size && !node.totalPoints) { + return sizeLabelToPoints(node.size) + } + // Full mode: use real totalPoints + return node.totalPoints || 0 +} + +// Helper to get effective unique users count for external nodes +function getExternalNodeUsers(node: any): number { + // Payment mode: use userIds array length (accurate count of connections in graph) + if (node.userIds && node.userIds.length > 0) { + return node.userIds.length + } + // Full mode: use real uniqueUsers count + if (node.uniqueUsers !== undefined) { + return node.uniqueUsers + } + // Fallback: shouldn't reach here in normal operation + return 1 +} + // Types export interface GraphNode { id: string username: string hasAppAccess: boolean - directPoints: number - transitivePoints: number - totalPoints: number - /** ISO date when user signed up */ - createdAt: string - /** ISO date of last transaction activity (null if never active or >90 days ago) */ - lastActiveAt: string | null + // Full mode fields - optional in payment mode + directPoints?: number + transitivePoints?: number + totalPoints?: number + createdAt?: string + lastActiveAt?: string | null + // Payment mode fields - optional in full mode + size?: SizeLabel /** KYC regions: AR (Manteca Argentina), BR (Manteca Brazil), World (Bridge) - null if not KYC'd */ kycRegions: string[] | null x?: number @@ -72,15 +122,21 @@ export interface GraphEdge { createdAt: string } -/** P2P payment edge between users (for clustering) */ +/** P2P payment edge between users (for clustering) + * Supports both full mode (with exact count/totalUsd) and anonymized mode (with frequency/volume labels) + */ export interface P2PEdge { source: string target: string type: 'SEND_LINK' | 'REQUEST_PAYMENT' | 'DIRECT_TRANSFER' - count: number - totalUsd: number /** True if payments went both ways between these users */ bidirectional: boolean + // Full mode fields (exact values) + count?: number + totalUsd?: number + // Anonymized mode fields (qualitative labels) + frequency?: 'rare' | 'occasional' | 'regular' | 'frequent' + volume?: 'small' | 'medium' | 'large' | 'whale' } export interface GraphData { @@ -116,7 +172,7 @@ export type ForceConfig = { /** Node repulsion (charge) - prevents overlap */ charge: { enabled: boolean; strength: number } /** Invite link attraction - tree clustering (force only, use visibilityConfig to hide edges) */ - inviteLinks: { enabled: boolean; strength: number } + inviteLinks: { enabled: boolean; strength: number; distance?: number } /** P2P link attraction - clusters transacting users (force only, use visibilityConfig to hide edges) */ p2pLinks: { enabled: boolean; strength: number } /** External link attraction - clusters users with shared wallets/banks/merchants */ @@ -127,7 +183,7 @@ export type ForceConfig = { export const DEFAULT_FORCE_CONFIG: ForceConfig = { charge: { enabled: true, strength: 80 }, - inviteLinks: { enabled: true, strength: 0.4 }, + inviteLinks: { enabled: true, strength: 0.4, distance: 50 }, p2pLinks: { enabled: true, strength: 0.3 }, externalLinks: { enabled: true, strength: 0.2 }, // Weaker than P2P - external connections are looser center: { enabled: true, strength: 0.03, sizeBias: 0.5 }, @@ -176,7 +232,7 @@ export type ExternalNodesConfig = { export const DEFAULT_EXTERNAL_NODES_CONFIG: ExternalNodesConfig = { enabled: false, - minConnections: 2, + minConnections: 1, // Changed from 2 to 1 to show all external nodes including single-user banks limit: 5000, // Default limit for API query (can increase up to 10k) types: { WALLET: false, // Disabled by default (too many, less useful for analysis) @@ -188,14 +244,17 @@ export const DEFAULT_EXTERNAL_NODES_CONFIG: ExternalNodesConfig = { /** Re-export ExternalNode type for convenience */ export type { ExternalNode, ExternalNodeType } +/** Graph mode determines which features are enabled */ +export type GraphMode = 'full' | 'payment' | 'user' + interface BaseProps { width?: number height?: number backgroundColor?: string /** Show usernames on nodes */ showUsernames?: boolean - /** Show all nodes (no 5000 limit) - can be slow */ - showAllNodes?: boolean + /** Limit to top N nodes by points (0 = all nodes, default 5000). Backend filtering. */ + topNodes?: number /** Activity filter for highlighting active/inactive/new users */ activityFilter?: ActivityFilter /** Force configuration for layout tuning */ @@ -206,8 +265,9 @@ interface BaseProps { renderOverlays?: (props: { showUsernames: boolean setShowUsernames: (v: boolean) => void - showAllNodes: boolean - setShowAllNodes: (v: boolean) => void + /** Top N nodes limit (0 = all). Changing triggers backend refetch. */ + topNodes: number + setTopNodes: (v: number) => void activityFilter: ActivityFilter setActivityFilter: (v: ActivityFilter) => void forceConfig: ForceConfig @@ -228,8 +288,14 @@ interface BaseProps { interface FullModeProps extends BaseProps { /** Admin API key to fetch full graph */ apiKey: string + /** Password for payment mode authentication */ + password?: string + /** Graph mode: 'full' shows all features, 'payment' shows P2P only (no invites, fixed 120-day window) */ + mode?: GraphMode /** Close/back button handler */ onClose?: () => void + /** Performance mode: limit to top 1000 nodes (frontend-filtered, no refetch) */ + performanceMode?: boolean /** Minimal mode disabled */ minimal?: false data?: never @@ -260,57 +326,8 @@ export const DEFAULT_ACTIVITY_FILTER: ActivityFilter = { hideInactive: false, // Default: show inactive as greyed out } -// Performance limit - max nodes to render -const MAX_NODES = 5000 - -/** - * Prune graph to MAX_NODES by removing oldest inactive users first - * Keeps all edges between remaining nodes - */ -function pruneGraphData(graphData: GraphData | null): GraphData | null { - if (!graphData || graphData.nodes.length <= MAX_NODES) { - return graphData - } - - // Sort nodes: active users first (by lastActiveAt desc), then inactive (by createdAt desc) - const sortedNodes = [...graphData.nodes].sort((a, b) => { - // Both have lastActiveAt - sort by most recent first - if (a.lastActiveAt && b.lastActiveAt) { - return new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime() - } - // Only a has activity - a comes first - if (a.lastActiveAt && !b.lastActiveAt) return -1 - // Only b has activity - b comes first - if (!a.lastActiveAt && b.lastActiveAt) return 1 - // Neither has activity - sort by createdAt (most recent first) - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - }) - - // Take top MAX_NODES - const keptNodes = sortedNodes.slice(0, MAX_NODES) - const keptNodeIds = new Set(keptNodes.map((n) => n.id)) - - // Filter edges to only include those between kept nodes - const keptEdges = graphData.edges.filter((edge) => keptNodeIds.has(edge.source) && keptNodeIds.has(edge.target)) - - // Filter P2P edges to only include those between kept nodes - const keptP2PEdges = (graphData.p2pEdges || []).filter( - (edge) => keptNodeIds.has(edge.source) && keptNodeIds.has(edge.target) - ) - - return { - nodes: keptNodes, - edges: keptEdges, - p2pEdges: keptP2PEdges, - stats: { - totalNodes: keptNodes.length, - totalEdges: keptEdges.length, - totalP2PEdges: keptP2PEdges.length, - usersWithAccess: keptNodes.filter((n) => n.hasAppAccess).length, - orphans: keptNodes.filter((n) => !n.hasAppAccess).length, - }, - } -} +// Default top nodes limit (0 = all nodes, backend-filtered) +const DEFAULT_TOP_NODES = 5000 export default function InvitesGraph(props: InvitesGraphProps) { const { @@ -318,7 +335,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { height, backgroundColor = '#f9fafb', showUsernames: initialShowUsernames = true, - showAllNodes: initialShowAllNodes = false, + topNodes: initialTopNodes = DEFAULT_TOP_NODES, activityFilter: initialActivityFilter = DEFAULT_ACTIVITY_FILTER, forceConfig: initialForceConfig = DEFAULT_FORCE_CONFIG, visibilityConfig: initialVisibilityConfig = DEFAULT_VISIBILITY_CONFIG, @@ -326,6 +343,54 @@ export default function InvitesGraph(props: InvitesGraphProps) { } = props const isMinimal = props.minimal === true + // Get mode from props - defaults to 'full' for non-minimal, 'user' for minimal + const mode: GraphMode = isMinimal ? 'user' : (props.mode ?? 'full') + + // Mode-specific defaults + // Payment mode: 120-day fixed window, no invite edges + // User mode: invite edges only (no P2P), used for points animation + const modeActivityFilter: ActivityFilter = + mode === 'payment' ? { ...initialActivityFilter, activityDays: 120 } : initialActivityFilter + const modeVisibilityConfig: VisibilityConfig = + mode === 'payment' + ? { ...initialVisibilityConfig, inviteEdges: false } + : mode === 'user' + ? { ...initialVisibilityConfig, p2pEdges: false } + : initialVisibilityConfig + const modeForceConfig: ForceConfig = + mode === 'payment' + ? { ...initialForceConfig, inviteLinks: { ...initialForceConfig.inviteLinks, enabled: false } } + : mode === 'user' + ? { + ...initialForceConfig, + p2pLinks: { ...initialForceConfig.p2pLinks, enabled: false }, + // Stronger repulsion for user graph to prevent overlap in small space + charge: { ...initialForceConfig.charge, strength: initialForceConfig.charge.strength * 3 }, + // Longer link distance for clearer separation + inviteLinks: { ...initialForceConfig.inviteLinks, distance: 80 }, + } + : initialForceConfig + // Payment mode: merchants enabled by default with minConnections=10, weaker link force (0.1x) + const modeExternalNodesConfig: ExternalNodesConfig = + mode === 'payment' + ? { + enabled: true, + minConnections: 10, + limit: 5000, + types: { WALLET: false, BANK: false, MERCHANT: true }, + } + : DEFAULT_EXTERNAL_NODES_CONFIG + // Apply payment mode external link force adjustment (0.1x default - weak to avoid clustering) + const finalModeForceConfig: ForceConfig = + mode === 'payment' + ? { + ...modeForceConfig, + externalLinks: { + ...DEFAULT_FORCE_CONFIG.externalLinks, + strength: DEFAULT_FORCE_CONFIG.externalLinks.strength * 0.1, + }, + } + : modeForceConfig // Data state const [fetchedGraphData, setFetchedGraphData] = useState(null) @@ -334,20 +399,67 @@ export default function InvitesGraph(props: InvitesGraphProps) { // UI state (declare early so they can be used in data processing) const [showUsernames, setShowUsernames] = useState(initialShowUsernames) - const [showAllNodes, setShowAllNodes] = useState(initialShowAllNodes) + // topNodes: limit to top N by points (0 = all). Backend-filtered, triggers refetch. + const [topNodes, setTopNodes] = useState(initialTopNodes) // Use passed data in minimal mode, fetched data otherwise - const rawGraphData = isMinimal ? props.data : fetchedGraphData + // Note: topNodes filtering is now done by backend, no client-side pruning needed + // Performance mode: frontend filter to top 1000 without refetch + const rawGraphData = useMemo(() => { + const data = isMinimal ? props.data : fetchedGraphData + if (!data) return null + + // Performance mode: limit to top 1000 nodes on frontend (payment graph only) + const performanceMode = !isMinimal && (props as FullModeProps).performanceMode + if (performanceMode && data.nodes.length > 1000) { + // Sort by size label (payment mode) or totalPoints (full mode) and take top 1000 + const sortedNodes = [...data.nodes].sort((a, b) => { + // Payment mode nodes have size labels, full mode has totalPoints + if (a.totalPoints !== undefined && b.totalPoints !== undefined) { + return b.totalPoints - a.totalPoints + } + // Size label sorting: huge > large > medium > small > tiny + const sizeOrder: Record = { huge: 5, large: 4, medium: 3, small: 2, tiny: 1 } + const aSize = (a as any).size || 'tiny' + const bSize = (b as any).size || 'tiny' + return (sizeOrder[bSize as string] || 0) - (sizeOrder[aSize as string] || 0) + }) + const limitedNodes = sortedNodes.slice(0, 1000) + const limitedNodeIds = new Set(limitedNodes.map((n) => n.id)) - // Prune to MAX_NODES for performance (keeps most active users) unless showAllNodes is enabled - const prunedGraphData = useMemo(() => { - if (showAllNodes) return rawGraphData - return pruneGraphData(rawGraphData) - }, [rawGraphData, showAllNodes]) + // Filter edges and P2P edges to only include connections between limited nodes + const filteredEdges = data.edges.filter( + (edge) => limitedNodeIds.has(edge.source) && limitedNodeIds.has(edge.target) + ) + const filteredP2PEdges = (data.p2pEdges || []).filter( + (edge) => limitedNodeIds.has(edge.source) && limitedNodeIds.has(edge.target) + ) + + return { + nodes: limitedNodes, + edges: filteredEdges, + p2pEdges: filteredP2PEdges, + stats: { + ...data.stats, + totalNodes: limitedNodes.length, + totalEdges: filteredEdges.length, + totalP2PEdges: filteredP2PEdges.length, + }, + } + } + + return data + }, [isMinimal, props, fetchedGraphData]) // Helper to check if node is active based on activityDays threshold // Used for both coloring and visibility filtering const isNodeActive = useCallback((node: GraphNode, filter: ActivityFilter): boolean => { + // In payment mode, nodes are anonymized and lack timestamps + // Treat all nodes as "active" since we can't determine activity + if (!node.createdAt && !node.lastActiveAt) { + return true + } + const now = Date.now() const activityCutoff = now - filter.activityDays * 24 * 60 * 60 * 1000 @@ -363,25 +475,33 @@ export default function InvitesGraph(props: InvitesGraphProps) { return false }, []) - const [activityFilter, setActivityFilter] = useState(initialActivityFilter) - const [forceConfig, setForceConfig] = useState(initialForceConfig) - const [visibilityConfig, setVisibilityConfig] = useState(initialVisibilityConfig) + const [activityFilter, setActivityFilter] = useState(modeActivityFilter) + const [forceConfig, setForceConfig] = useState(finalModeForceConfig) + const [visibilityConfig, setVisibilityConfig] = useState(modeVisibilityConfig) const [selectedUserId, setSelectedUserId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) // External nodes state (wallets, banks, merchants) - const [externalNodesConfig, setExternalNodesConfig] = useState(DEFAULT_EXTERNAL_NODES_CONFIG) + const [externalNodesConfig, setExternalNodesConfig] = useState(modeExternalNodesConfig) const [externalNodesData, setExternalNodesData] = useState([]) const [externalNodesLoading, setExternalNodesLoading] = useState(false) const [externalNodesError, setExternalNodesError] = useState(null) - const externalNodesFetchedRef = useRef(false) // Track if we've fetched (don't refetch on toggle off/on) + // Track fetch state: stores the limit used for last fetch, or null if never fetched + // This allows refetch when limit changes while preventing refetch on toggle off/on + const externalNodesFetchedLimitRef = useRef(null) - // Graph preferences persistence - const { preferences, savePreferences, isLoaded: preferencesLoaded } = useGraphPreferences() + // Graph preferences persistence (separate storage for payment vs full mode) + const isPaymentMode = mode === 'payment' + const { + preferences, + savePreferences, + isLoaded: preferencesLoaded, + } = useGraphPreferences(isPaymentMode ? 'payment' : 'full') const preferencesRestoredRef = useRef(false) - // Load preferences ONCE on mount (only in full mode) + // Load preferences ONCE on mount (not in minimal mode) + // Payment and full mode now have separate storage // Using preferencesLoaded as the only dependency - preferences won't change after load useEffect(() => { if (isMinimal || !preferencesLoaded || preferencesRestoredRef.current) return @@ -416,7 +536,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { sizeBias: sizeBasedCenter?.enabled ? 0.5 : 0, // If old sizeBased was on, keep some bias }, } - console.log('Migrated old center forces to unified center') } // Merge with defaults to fill in any missing fields @@ -429,17 +548,35 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Restore saved preferences if (migratedForceConfig) setForceConfig(migratedForceConfig) if (preferences.visibilityConfig) setVisibilityConfig(preferences.visibilityConfig) - if (preferences.activityFilter) setActivityFilter(preferences.activityFilter) + + // Payment mode: NEVER restore activityDays (fixed at 120) or topNodes (always use prop) + // Full mode: restore both + if (preferences.activityFilter) { + if (isPaymentMode) { + // Restore enabled/hideInactive, but keep activityDays at 120 + setActivityFilter({ + ...preferences.activityFilter, + activityDays: 120, + }) + } else { + setActivityFilter(preferences.activityFilter) + } + } + if (preferences.externalNodesConfig) setExternalNodesConfig(preferences.externalNodesConfig) if (preferences.showUsernames !== undefined) setShowUsernames(preferences.showUsernames) - if (preferences.showAllNodes !== undefined) setShowAllNodes(preferences.showAllNodes) - console.log('Restored graph preferences (migrated):', preferences) + // Payment mode: NEVER restore topNodes - always use prop value (5000 for full data) + if (!isPaymentMode && preferences.topNodes !== undefined) { + setTopNodes(preferences.topNodes) + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [preferencesLoaded, isMinimal]) // Only depend on preferencesLoaded, not preferences // Auto-save preferences when they change (debounced to avoid excessive writes) // Skip saving until preferences have been restored to avoid overwriting with defaults + // Payment and full mode now save to separate keys, so no pollution useEffect(() => { if (isMinimal || !preferencesRestoredRef.current) return @@ -450,7 +587,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { activityFilter, externalNodesConfig, showUsernames, - showAllNodes, + topNodes, }) }, 1000) // Debounce 1 second @@ -461,7 +598,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { activityFilter, externalNodesConfig, showUsernames, - showAllNodes, + topNodes, isMinimal, savePreferences, ]) @@ -469,13 +606,15 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Filter nodes/edges based on visibility settings (DELETE approach) // All visibility toggles remove data from simulation for better performance and accurate layout const graphData = useMemo(() => { - if (!prunedGraphData) return null + if (!rawGraphData) return null // Start with all nodes - let filteredNodes = prunedGraphData.nodes + let filteredNodes = rawGraphData.nodes - // Filter by Active/Inactive visibility checkboxes - // Uses activityDays (default 30) to determine what's "active" + // Filter by activity time window AND active/inactive checkboxes + // activityDays defines the time window (e.g., 30 days) + // Nodes are classified as active (within window) or inactive (outside window) + // Then visibilityConfig checkboxes control which category to show if (!visibilityConfig.activeNodes || !visibilityConfig.inactiveNodes) { filteredNodes = filteredNodes.filter((node) => { const isActive = isNodeActive(node, activityFilter) @@ -488,12 +627,18 @@ export default function InvitesGraph(props: InvitesGraphProps) { const nodeIds = new Set(filteredNodes.map((n) => n.id)) // Filter edges based on visibility settings AND whether both nodes exist - let filteredEdges = prunedGraphData.edges.filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)) + let filteredEdges = rawGraphData.edges.filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)) if (!visibilityConfig.inviteEdges) { filteredEdges = [] } - let filteredP2PEdges = (prunedGraphData.p2pEdges || []).filter( + // Safety: detect duplicate node IDs (should never happen after SHA-256 fix) + console.assert( + nodeIds.size === filteredNodes.length, + `Duplicate node IDs detected: ${filteredNodes.length} nodes collapsed to ${nodeIds.size} unique IDs` + ) + + let filteredP2PEdges = (rawGraphData.p2pEdges || []).filter( (edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target) ) if (!visibilityConfig.p2pEdges) { @@ -512,7 +657,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { orphans: filteredNodes.filter((n) => !n.hasAppAccess).length, }, } - }, [prunedGraphData, activityFilter.activityDays, visibilityConfig, isNodeActive]) + }, [rawGraphData, activityFilter.activityDays, visibilityConfig, isNodeActive]) const graphRef = useRef(null) const containerRef = useRef(null) @@ -568,18 +713,55 @@ export default function InvitesGraph(props: InvitesGraphProps) { return map }, [filteredGraphData]) + // Build set of node IDs that participate in P2P (for payment mode coloring) + // A node is "P2P active" if it's the source or target of any P2P edge + const p2pActiveNodes = useMemo(() => { + if (!rawGraphData) return new Set() + const set = new Set() + ;(rawGraphData.p2pEdges || []).forEach((edge) => { + set.add(edge.source) + set.add(edge.target) + }) + return set + }, [rawGraphData]) + // Filter external nodes based on config (client-side for fast UI updates) const filteredExternalNodes = useMemo(() => { if (!externalNodesConfig.enabled) return [] - return externalNodesData.filter((node) => { + const now = Date.now() + const activityCutoff = now - activityFilter.activityDays * 24 * 60 * 60 * 1000 + const isPaymentMode = mode === 'payment' + + const filtered = externalNodesData.filter((node) => { // Filter by minConnections - if (node.uniqueUsers < externalNodesConfig.minConnections) return false + // In payment mode: count unique user IDs from userIds array + // In full mode: use uniqueUsers or fall back to size label conversion + let userCount: number + if (isPaymentMode) { + // Payment mode: count actual user IDs in the array + userCount = node.userIds?.length || 0 + } else { + // Full mode: use helper which reads uniqueUsers or converts size label + userCount = getExternalNodeUsers(node) + } + + if (userCount < externalNodesConfig.minConnections) { + return false + } + // Filter by type if (!externalNodesConfig.types[node.type]) return false + // Filter by activity window (only in full mode where lastTxDate exists) + if (node.lastTxDate) { + const lastTxMs = new Date(node.lastTxDate).getTime() + if (lastTxMs < activityCutoff) return false + } return true }) - }, [externalNodesData, externalNodesConfig]) + + return filtered + }, [externalNodesData, externalNodesConfig, activityFilter.activityDays]) // Build combined graph nodes including external nodes // External nodes are marked with isExternal: true for different rendering @@ -598,52 +780,178 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Get set of user IDs in the graph for filtering links const userIdsInGraph = new Set(filteredGraphData.nodes.map((n) => n.id)) + // Helper to extract userId from userTxData keys (full mode only) + // Keys can be: `${userId}_${direction}` (e.g., "abc123_INCOMING") or just `${userId}` (old format) + // User IDs may contain underscores, so we use lastIndexOf to find the direction suffix + const extractUserIdFromKey = (key: string): string => { + if (key.endsWith('_INCOMING') || key.endsWith('_OUTGOING')) { + return key.substring(0, key.lastIndexOf('_')) + } + return key // Old format: key is just the userId + } + + // Get connected user IDs for an external node + // In payment mode: use userIds array (real UUIDs for graph linking) + // In full mode: use userIds if available, otherwise extract from userTxData keys + const getConnectedUserIds = (ext: ExternalNode): string[] => { + if (ext.userIds && ext.userIds.length > 0) { + return ext.userIds + } + return Object.keys(ext.userTxData || {}).map(extractUserIdFromKey) + } + // Add external nodes with position hint (start them at edges) // x, y will be populated by force simulation at runtime + // Track filtered out nodes for debugging + const filteredOutByVisibility = { WALLET: 0, BANK: 0, MERCHANT: 0 } const externalNodes = filteredExternalNodes - .filter((ext) => ext.userIds.some((uid) => userIdsInGraph.has(uid))) // Only show if connected to visible users - .map((ext) => ({ - id: `ext_${ext.id}`, - label: ext.label, - externalType: ext.type, - uniqueUsers: ext.uniqueUsers, - txCount: ext.txCount, - totalUsd: ext.totalUsd, - userIds: ext.userIds.filter((uid) => userIdsInGraph.has(uid)), // Only connected users in graph - isExternal: true as const, - x: undefined as number | undefined, - y: undefined as number | undefined, - })) - - return [...userNodes, ...externalNodes] - }, [filteredGraphData, filteredExternalNodes, externalNodesConfig.enabled]) - - // Build links to external nodes + .filter((ext) => { + // Only show if connected to visible users + const connectedUserIds = getConnectedUserIds(ext) + const hasVisibleUser = connectedUserIds.some((uid: string) => userIdsInGraph.has(uid)) + if (!hasVisibleUser) { + filteredOutByVisibility[ext.type as keyof typeof filteredOutByVisibility]++ + } + return hasVisibleUser + }) + .map((ext) => { + const connectedUserIds = getConnectedUserIds(ext) + const filteredUserIds = connectedUserIds.filter((uid: string) => userIdsInGraph.has(uid)) + return { + id: `ext_${ext.id}`, + label: ext.label, + externalType: ext.type, + uniqueUsers: ext.uniqueUsers, + txCount: ext.txCount, + totalUsd: ext.totalUsd, + frequency: ext.frequency, + volume: ext.volume, + userIds: filteredUserIds, + isExternal: true as const, + x: undefined as number | undefined, + y: undefined as number | undefined, + } + }) + + const combined = [...userNodes, ...externalNodes] + + // Safety: detect duplicate external node IDs + const externalNodeIds = new Set(externalNodes.map((n) => n.id)) + console.assert( + externalNodeIds.size === externalNodes.length, + `Duplicate external node IDs: ${externalNodes.length} nodes collapsed to ${externalNodeIds.size} unique IDs` + ) + + return combined + }, [filteredGraphData, externalNodesConfig.enabled, filteredExternalNodes]) + + // Build links to external nodes with per-user transaction data and direction + // Creates separate links for INCOMING and OUTGOING to enable correct particle flow + // Supports both full mode (txCount, totalUsd) and anonymized mode (frequency, volume) const externalLinks = useMemo(() => { if (!externalNodesConfig.enabled || filteredExternalNodes.length === 0 || !filteredGraphData) { return [] } const userIdsInGraph = new Set(filteredGraphData.nodes.map((n) => n.id)) - const links: { source: string; target: string; isExternal: true }[] = [] + const isPaymentMode = mode === 'payment' + + type ExternalLink = { + source: string + target: string + isExternal: true + direction: 'INCOMING' | 'OUTGOING' + } & ({ txCount: number; totalUsd: number } | { frequency: string; volume: string }) + + const links: ExternalLink[] = [] filteredExternalNodes.forEach((ext) => { const extNodeId = `ext_${ext.id}` - ext.userIds.forEach((userId) => { - if (userIdsInGraph.has(userId)) { + + // In payment mode, userTxData keys are anonymized (hex IDs) + // Parse userTxData to get per-user direction, frequency, and volume + if (isPaymentMode) { + // userTxData format: { "hexUserId_DIRECTION": { direction, frequency, volume } } + Object.entries(ext.userTxData || {}).forEach(([key, data]) => { + // Parse userId and direction from key format: "hexUserId_DIRECTION" + const lastUnderscoreIdx = key.lastIndexOf('_') + if (lastUnderscoreIdx === -1) return // Skip malformed keys + + const hexUserId = key.substring(0, lastUnderscoreIdx) + const direction = key.substring(lastUnderscoreIdx + 1) as 'INCOMING' | 'OUTGOING' + + // userTxData keys are hex-anonymized, but graph nodes use the original hex IDs + // Match by checking if this hex ID is in the graph + if (!userIdsInGraph.has(hexUserId)) { + return + } + + links.push({ + source: hexUserId, + target: extNodeId, + isExternal: true, + frequency: data.frequency || ext.frequency || 'occasional', + volume: data.volume || ext.volume || 'medium', + direction: direction, + }) + }) + + return + } + + // Full mode: userTxData keys can be in two formats: + // - New format: `${userId}_${direction}` (e.g., "abc123_INCOMING", "abc123_OUTGOING") + // - Old format: just `${userId}` (e.g., "abc123") - backwards compatibility + Object.entries(ext.userTxData || {}).forEach(([key, data]) => { + // Check if key ends with _INCOMING or _OUTGOING (new format) + const isNewFormat = key.endsWith('_INCOMING') || key.endsWith('_OUTGOING') + + let userId: string + let direction: 'INCOMING' | 'OUTGOING' + + if (isNewFormat) { + // New format: parse userId and direction from key + const lastUnderscoreIdx = key.lastIndexOf('_') + userId = key.substring(0, lastUnderscoreIdx) + direction = key.substring(lastUnderscoreIdx + 1) as 'INCOMING' | 'OUTGOING' + } else { + // Old format: key is just userId, default to OUTGOING (original behavior) + userId = key + direction = data.direction || 'OUTGOING' + } + + if (!userIdsInGraph.has(userId)) return + + // Handle both full and anonymized data formats + if (data.txCount !== undefined && data.totalUsd !== undefined) { + // Full mode: use exact values links.push({ source: userId, target: extNodeId, isExternal: true, + txCount: data.txCount, + totalUsd: data.totalUsd, + direction: direction, + }) + } else if (data.frequency && data.volume) { + // Anonymized mode: use labels + links.push({ + source: userId, + target: extNodeId, + isExternal: true, + frequency: data.frequency, + volume: data.volume, + direction: direction, }) } }) }) return links - }, [filteredExternalNodes, filteredGraphData, externalNodesConfig.enabled]) + }, [filteredExternalNodes, filteredGraphData, externalNodesConfig.enabled, mode]) - // Fetch graph data on mount (only in full mode) + // Fetch graph data on mount and when topNodes changes (only in full mode) + // Note: topNodes filtering only applies to full mode (payment mode has fixed 5000 limit in backend) useEffect(() => { if (isMinimal) return @@ -651,7 +959,15 @@ export default function InvitesGraph(props: InvitesGraphProps) { setLoading(true) setError(null) - const result = await pointsApi.getInvitesGraph(props.apiKey) + // API only supports 'full' | 'payment' modes (user mode uses different endpoint) + const apiMode = mode === 'payment' ? 'payment' : 'full' + // Pass topNodes for both modes - payment mode now supports it via Performance button + // Pass password for payment mode authentication + const result = await pointsApi.getInvitesGraph(props.apiKey, { + mode: apiMode, + topNodes: topNodes > 0 ? topNodes : undefined, + password: mode === 'payment' ? props.password : undefined, + }) if (result.success && result.data) { setFetchedGraphData(result.data) @@ -662,28 +978,40 @@ export default function InvitesGraph(props: InvitesGraphProps) { } fetchData() - }, [isMinimal, props.apiKey]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMinimal, !isMinimal && props.apiKey, mode, topNodes]) // Fetch external nodes when enabled (lazy load on first enable) + // Refetch if limit changes (but not on simple toggle off/on) useEffect(() => { if (isMinimal) return if (!externalNodesConfig.enabled) return - if (externalNodesFetchedRef.current) return // Already fetched, don't refetch + // Skip if already fetched with same or higher limit (no need to refetch for same data) + const lastLimit = externalNodesFetchedLimitRef.current + if (lastLimit !== null && lastLimit >= externalNodesConfig.limit) return const fetchExternalNodes = async () => { setExternalNodesLoading(true) setExternalNodesError(null) try { + // API only supports 'full' | 'payment' modes + const apiMode = mode === 'payment' ? 'payment' : 'full' + // Fetch ALL types so user can toggle client-side without refetch + // Backend defaults to MERCHANT only in payment mode, so we must explicitly request all const result = await pointsApi.getExternalNodes(props.apiKey, { + mode: apiMode, minConnections: 1, // Fetch all, filter client-side for flexibility limit: externalNodesConfig.limit, // User-configurable limit + types: ['WALLET', 'BANK', 'MERCHANT'], // Fetch all types, filter client-side + topNodes: topNodes > 0 ? topNodes : undefined, // Match graph's top-N filter + password: apiMode === 'payment' ? props.password : undefined, // Password for payment mode }) if (result.success && result.data) { + // Debug logging for external nodes setExternalNodesData(result.data.nodes) - externalNodesFetchedRef.current = true - console.log(`Fetched ${result.data.nodes.length} external nodes`) + externalNodesFetchedLimitRef.current = externalNodesConfig.limit } else { const errorMsg = result.error || 'Unknown error' setExternalNodesError(errorMsg) @@ -699,7 +1027,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { } fetchExternalNodes() - }, [isMinimal, props.apiKey, externalNodesConfig.enabled]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMinimal, !isMinimal && props.apiKey, externalNodesConfig.enabled, mode, externalNodesConfig.limit]) // Track display settings with ref to avoid re-renders // NOTE: These settings only affect RENDERING, not force simulation @@ -708,44 +1037,62 @@ export default function InvitesGraph(props: InvitesGraphProps) { showUsernames, selectedUserId, isMinimal, + mode, activityFilter, visibilityConfig, externalNodesConfig, + p2pActiveNodes, }) useEffect(() => { displaySettingsRef.current = { showUsernames, selectedUserId, isMinimal, + mode, activityFilter, visibilityConfig, externalNodesConfig, + p2pActiveNodes, } - }, [showUsernames, selectedUserId, isMinimal, activityFilter, visibilityConfig, externalNodesConfig]) + }, [ + showUsernames, + selectedUserId, + isMinimal, + mode, + activityFilter, + visibilityConfig, + externalNodesConfig, + p2pActiveNodes, + ]) // Helper to determine user activity status - const getUserActivityStatus = useCallback((node: GraphNode, filter: ActivityFilter): 'active' | 'inactive' => { - if (!filter.enabled) return 'active' // No filtering, show all as active + const getUserActivityStatus = useCallback( + (node: GraphNode, filter: ActivityFilter): 'new' | 'active' | 'inactive' => { + if (!filter.enabled) return 'active' // No filtering, show all as active - const now = Date.now() - const activityCutoff = now - filter.activityDays * 24 * 60 * 60 * 1000 + // In payment mode, all nodes shown as active (no inactive differentiation) + // Backend already sets lastActiveAt to now, but check mode to be safe + if (mode === 'payment') return 'active' - // Check if signed up within activity window - const createdAtMs = node.createdAt ? new Date(node.createdAt).getTime() : 0 - if (createdAtMs >= activityCutoff) { - return 'active' // New signup counts as active - } + const now = Date.now() + const activityCutoff = now - filter.activityDays * 24 * 60 * 60 * 1000 - // Check if had tx within activity window - if (node.lastActiveAt) { - const lastActiveMs = new Date(node.lastActiveAt).getTime() - if (lastActiveMs >= activityCutoff) { - return 'active' - } - } + // Check if signed up within activity window (NEW user) + const createdAtMs = node.createdAt ? new Date(node.createdAt).getTime() : 0 + const isNewSignup = createdAtMs >= activityCutoff - return 'inactive' - }, []) + // Check if had tx within activity window + const hasRecentActivity = node.lastActiveAt + ? new Date(node.lastActiveAt).getTime() >= activityCutoff + : false + + // Priority: New signup > Active > Inactive + if (isNewSignup) return 'new' + if (hasRecentActivity) return 'active' + return 'inactive' + }, + [mode] + ) // Node styling const nodeCanvasObject = useCallback( @@ -765,7 +1112,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { if (node.isExternal) { if (!extConfig.enabled) return // Hidden - const size = 4 + Math.log2(node.uniqueUsers || 1) * 2 + const size = 4 + Math.log2(getExternalNodeUsers(node)) * 2 // Colors by type const colors: Record = { @@ -832,21 +1179,29 @@ export default function InvitesGraph(props: InvitesGraphProps) { // ============================================ const isSelected = node.id === selId const hasAccess = node.hasAppAccess + const { mode: currentMode } = displaySettingsRef.current // Determine activity status for coloring // Note: Visibility filtering is done at data level, so hidden nodes never reach here const activityStatus = getUserActivityStatus(node, filter) - const baseSize = hasAccess ? 6 : 3 - const pointsMultiplier = Math.sqrt(node.totalPoints) / 10 - const size = baseSize + Math.min(pointsMultiplier, 25) + // In user mode: all nodes same size (larger for cleaner display) + // In other modes: size based on points + let size: number + if (currentMode === 'user') { + size = 12 // Fixed size for user graph - all nodes equal + } else { + const baseSize = hasAccess ? 6 : 3 + const pointsMultiplier = Math.sqrt(getNodePoints(node)) / 10 + size = baseSize + Math.min(pointsMultiplier, 25) + } // =========================================== // NODE STYLING: Fill + Outline are separate // =========================================== - // FILL: Based on activity status - // - Active (signup or tx within window): purple (#8b5cf6) - // - Inactive: gray, semi-transparent + // In USER mode: All nodes same purple color (unified appearance) + // In PAYMENT mode: Color by P2P activity (purple = has P2P, grey = no P2P) + // In FULL mode: Color based on activity status // OUTLINE: Based on access/selection // - Jailed (no app access): black (#000000) // - Selected: golden (#fbbf24) @@ -854,25 +1209,57 @@ export default function InvitesGraph(props: InvitesGraphProps) { // =========================================== let fillColor: string - let fillAlpha = 0.85 // Slight transparency on all nodes to see behind - - if (!filter.enabled) { + const { p2pActiveNodes: p2pNodes } = displaySettingsRef.current + + if (currentMode === 'user') { + // User mode: all nodes same pleasant purple + fillColor = 'rgba(139, 92, 246, 0.9)' // Solid purple for all + } else if (currentMode === 'payment') { + // Payment mode: color by P2P participation (sending or receiving) + const hasP2PActivity = p2pNodes.has(node.id) + fillColor = hasP2PActivity + ? 'rgba(139, 92, 246, 0.85)' // Purple for P2P active + : 'rgba(156, 163, 175, 0.5)' // Grey for no P2P + } else if (!filter.enabled) { // No filter - simple active/inactive by access - fillColor = hasAccess ? '#8b5cf6' : '#9ca3af' + fillColor = hasAccess ? 'rgba(139, 92, 246, 0.85)' : 'rgba(156, 163, 175, 0.85)' } else { - // Activity filter enabled - if (activityStatus === 'active') { - fillColor = '#8b5cf6' // Purple for active + // Activity filter enabled - three states + if (activityStatus === 'new') { + fillColor = 'rgba(16, 185, 129, 0.85)' // Green for new signups + } else if (activityStatus === 'active') { + fillColor = 'rgba(139, 92, 246, 0.85)' // Purple for active } else { - fillColor = '#9ca3af' // Gray for inactive - if (!filter.hideInactive) { - fillAlpha = 0.25 // More transparent for inactive (when showing them) + // Inactive - exponential time bands with distinct shades + const now = Date.now() + const createdAtMs = node.createdAt ? new Date(node.createdAt).getTime() : 0 + const lastActiveMs = node.lastActiveAt ? new Date(node.lastActiveAt).getTime() : 0 + const lastActivityMs = Math.max(createdAtMs, lastActiveMs) + const daysSinceActivity = (now - lastActivityMs) / (24 * 60 * 60 * 1000) + + // Exponential time bands: 1w, 2w, 4w, 8w, 16w, 32w, 64w+ + // Each band gets progressively lighter gray + if (daysSinceActivity < 7) { + fillColor = 'rgba(80, 80, 80, 0.9)' // Very dark gray - <1 week + } else if (daysSinceActivity < 14) { + fillColor = 'rgba(100, 100, 100, 0.85)' // Dark gray - 1-2 weeks + } else if (daysSinceActivity < 28) { + fillColor = 'rgba(120, 120, 120, 0.8)' // Medium-dark - 2-4 weeks + } else if (daysSinceActivity < 56) { + fillColor = 'rgba(145, 145, 145, 0.7)' // Medium gray - 4-8 weeks + } else if (daysSinceActivity < 112) { + fillColor = 'rgba(170, 170, 170, 0.6)' // Medium-light - 8-16 weeks + } else if (daysSinceActivity < 224) { + fillColor = 'rgba(195, 195, 195, 0.5)' // Light gray - 16-32 weeks + } else if (daysSinceActivity < 448) { + fillColor = 'rgba(215, 215, 215, 0.4)' // Very light - 32-64 weeks + } else { + fillColor = 'rgba(235, 235, 235, 0.3)' // Almost invisible - 64+ weeks } } } // Draw fill - ctx.globalAlpha = fillAlpha ctx.beginPath() ctx.arc(node.x, node.y, size, 0, 2 * Math.PI) ctx.fillStyle = fillColor @@ -967,7 +1354,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { const uy = dy / len // Unit vector y // ============================================ - // EXTERNAL LINK RENDERING (with animated particles) + // EXTERNAL LINK RENDERING (with animated particles scaling by volume/count) // ============================================ if (link.isExternal) { // Get target node type for color @@ -983,37 +1370,77 @@ export default function InvitesGraph(props: InvitesGraphProps) { MERCHANT: 'rgba(16, 185, 129, 0.8)', // Green } + // Convert frequency/volume labels to numeric values for rendering + // Full mode: use actual values; Anonymized mode: map labels to ranges + const frequencyMap = { rare: 1, occasional: 3, regular: 10, frequent: 30 } + const volumeMap = { small: 50, medium: 500, large: 5000, whale: 50000 } + + const txCount = link.txCount ?? frequencyMap[link.frequency as keyof typeof frequencyMap] ?? 1 + const usdVolume = link.totalUsd ?? volumeMap[link.volume as keyof typeof volumeMap] ?? 50 + + // Scale line width by transaction count (same formula as P2P) + const lineWidth = Math.min(0.4 + txCount * 0.25, 3.0) + // Draw base line ctx.strokeStyle = lineColors[extType] || 'rgba(107, 114, 128, 0.25)' - ctx.lineWidth = 0.6 + ctx.lineWidth = lineWidth ctx.beginPath() ctx.moveTo(source.x, source.y) ctx.lineTo(target.x, target.y) ctx.stroke() - // Animated particles flowing user β†’ external + // Animated particles with direction based on actual fund flow const time = performance.now() - const speed = 0.0003 // Slower than P2P for visual distinction - const particleSize = 1.5 + // Logarithmic scaling for better visual distinction + const logTxCount = Math.log10(Math.max(txCount, 1) + 1) + const logUsd = Math.log10(Math.max(usdVolume, 1) + 1) + + // Speed: 0.0002 (1tx) β†’ 0.0008 (100tx) using log scale + const baseSpeed = 0.0002 + logTxCount * 0.0003 + const speed = baseSpeed + + // Particle count: 1 β†’ 4 particles, log-scaled + const particleCount = Math.min(1 + Math.floor(logTxCount * 1.5), 4) + // Size: 1.5 (small) β†’ 6.0 (large), log-scaled by USD volume + const particleSize = 1.5 + logUsd * 2.25 ctx.fillStyle = particleColors[extType] || 'rgba(107, 114, 128, 0.8)' - // Single particle per link (less visual clutter) - const t = (time * speed) % 1 - const px = source.x + dx * t - const py = source.y + dy * t - ctx.beginPath() - ctx.arc(px, py, particleSize, 0, 2 * Math.PI) - ctx.fill() + // Determine particle direction based on fund flow + const isIncoming = link.direction === 'INCOMING' + + // Draw particles along the edge + for (let i = 0; i < particleCount; i++) { + const t = (time * speed + i / particleCount) % 1 + // OUTGOING: flow from source (user) to target (external) β†’ t goes 0β†’1 + // INCOMING: flow from target (external) to source (user) β†’ t goes 1β†’0 (use 1-t) + const progress = isIncoming ? 1 - t : t + const px = source.x + dx * progress + const py = source.y + dy * progress + ctx.beginPath() + ctx.arc(px, py, particleSize, 0, 2 * Math.PI) + ctx.fill() + } return } if (link.isP2P) { - // P2P: Draw line with animated particles + // P2P: Draw line with animated particles (scaled by activity & volume) + // Supports both full mode (count/totalUsd) and anonymized mode (frequency/volume labels) const baseAlpha = inactive ? 0.08 : 0.25 ctx.strokeStyle = `rgba(6, 182, 212, ${baseAlpha})` - ctx.lineWidth = Math.min(0.5 + (link.count || 1) * 0.2, 2.5) + + // Convert frequency/volume labels to numeric values for rendering + // Full mode: use actual values; Anonymized mode: map labels to ranges + const frequencyMap = { rare: 1, occasional: 3, regular: 10, frequent: 30 } + const volumeMap = { small: 50, medium: 500, large: 5000, whale: 50000 } + + const txCount = link.count ?? frequencyMap[link.frequency as keyof typeof frequencyMap] ?? 1 + const usdVolume = link.totalUsd ?? volumeMap[link.volume as keyof typeof volumeMap] ?? 50 + + // Line width: 0.4 (min) β†’ 3.0 (max) based on tx count + ctx.lineWidth = Math.min(0.4 + txCount * 0.25, 3.0) ctx.beginPath() ctx.moveTo(source.x, source.y) ctx.lineTo(target.x, target.y) @@ -1021,10 +1448,19 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Animated particles for P2P if (!inactive) { - const time = performance.now() // More precise than Date.now() - const particleCount = Math.min(1 + Math.floor((link.count || 1) / 2), 4) - const speed = 0.0004 + Math.min((link.count || 1) * 0.0001, 0.0003) - const particleSize = 2 + Math.min((link.totalUsd || 0) / 300, 3) + const time = performance.now() + // Logarithmic scaling for better visual distinction + const logTxCount = Math.log10(Math.max(txCount, 1) + 1) + const logUsd = Math.log10(Math.max(usdVolume, 1) + 1) + + // Particle count: 1 β†’ 5 particles, log-scaled + const particleCount = Math.min(1 + Math.floor(logTxCount * 2), 5) + // Speed: 0.0003 (1tx) β†’ 0.001 (100tx) using log scale + const baseSpeed = 0.0003 + logTxCount * 0.00035 + const speed = baseSpeed + + // Size: 1.5 (small) β†’ 6.0 (large), log-scaled by USD volume + const particleSize = 1.5 + logUsd * 2.25 const isBidirectional = link.bidirectional === true ctx.fillStyle = 'rgba(6, 182, 212, 0.85)' @@ -1055,6 +1491,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { const baseColor = isDirect ? [139, 92, 246] : [236, 72, 153] const alpha = inactive ? 0.12 : 0.35 const arrowAlpha = inactive ? 0.2 : 0.6 + const { mode: currentMode } = displaySettingsRef.current // Draw main line ctx.strokeStyle = `rgba(${baseColor.join(',')}, ${alpha})` @@ -1064,34 +1501,60 @@ export default function InvitesGraph(props: InvitesGraphProps) { ctx.lineTo(target.x, target.y) ctx.stroke() - // Draw arrows along the line (every ~60px, minimum 2) - // Skip the last arrow to prevent bunching near target node - const arrowSpacing = 60 - const numArrows = Math.max(2, Math.floor(len / arrowSpacing)) - const arrowSize = inactive ? 3 : 5 - - ctx.fillStyle = `rgba(${baseColor.join(',')}, ${arrowAlpha})` + // In user mode: Draw animated points flowing UP the tree (invitee β†’ inviter) + // This visualizes "points flowing to the inviter" + if (currentMode === 'user' && !inactive) { + const time = performance.now() + // Slow, pulsing animation - slower than P2P + const baseSpeed = 0.00015 + const particleCount = 3 + const particleSize = 3 - // Draw arrows from source toward target, but skip the last one (closest to target) - for (let i = 1; i < numArrows; i++) { - // Changed: i < numArrows instead of i <= numArrows - const t = i / (numArrows + 1) - const ax = source.x + dx * t - const ay = source.y + dy * t + // Gold color for points + ctx.fillStyle = 'rgba(251, 191, 36, 0.9)' // #fbbf24 with alpha - // Draw arrow head pointing in direction of edge - ctx.beginPath() - ctx.moveTo(ax + ux * arrowSize, ay + uy * arrowSize) - ctx.lineTo( - ax - ux * arrowSize * 0.5 - uy * arrowSize * 0.6, - ay - uy * arrowSize * 0.5 + ux * arrowSize * 0.6 - ) - ctx.lineTo( - ax - ux * arrowSize * 0.5 + uy * arrowSize * 0.6, - ay - uy * arrowSize * 0.5 - ux * arrowSize * 0.6 - ) - ctx.closePath() - ctx.fill() + for (let i = 0; i < particleCount; i++) { + // Flow direction: source β†’ target (invitee β†’ inviter) + // Note: Edges are REVERSED for graph rendering (see graphData mapping) + // After reversal: link.source = invitee, link.target = inviter + // So particles flow from source (invitee) to target (inviter) + const t = (time * baseSpeed + i / particleCount) % 1 + const px = source.x + (target.x - source.x) * t + const py = source.y + (target.y - source.y) * t + ctx.beginPath() + ctx.arc(px, py, particleSize, 0, 2 * Math.PI) + ctx.fill() + } + } else { + // Full/Payment mode: Draw arrows along the line (every ~60px, minimum 2) + // Skip the last arrow to prevent bunching near target node + const arrowSpacing = 60 + const numArrows = Math.max(2, Math.floor(len / arrowSpacing)) + const arrowSize = inactive ? 3 : 5 + + ctx.fillStyle = `rgba(${baseColor.join(',')}, ${arrowAlpha})` + + // Draw arrows from source toward target, but skip the last one (closest to target) + for (let i = 1; i < numArrows; i++) { + // Changed: i < numArrows instead of i <= numArrows + const t = i / (numArrows + 1) + const ax = source.x + dx * t + const ay = source.y + dy * t + + // Draw arrow head pointing in direction of edge + ctx.beginPath() + ctx.moveTo(ax + ux * arrowSize, ay + uy * arrowSize) + ctx.lineTo( + ax - ux * arrowSize * 0.5 - uy * arrowSize * 0.6, + ay - uy * arrowSize * 0.5 + ux * arrowSize * 0.6 + ) + ctx.lineTo( + ax - ux * arrowSize * 0.5 + uy * arrowSize * 0.6, + ay - uy * arrowSize * 0.5 - ux * arrowSize * 0.6 + ) + ctx.closePath() + ctx.fill() + } } } }, @@ -1154,7 +1617,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { // User node β†’ Select (camera follows) - click again to open Grafana if (selectedUserId === node.id) { // Already selected - open Grafana - window.open(`https://peanut.grafana.net/d/user-details/user-details?var-user_id=${node.id}`, '_blank') + const username = node.username || node.id + window.open( + `https://teampeanut.grafana.net/d/ad31f645-81ca-4779-bfb2-bff8e03d9057/explore-peanut-wallet-user?orgId=1&var-GRAFANA_VAR_Username=${encodeURIComponent(username)}`, + '_blank' + ) } else { // Select node setSelectedUserId(node.id) @@ -1165,10 +1632,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Right-click selects the node (camera follows) const handleNodeRightClick = useCallback((node: any) => { - // Don't select external nodes - if (node.isExternal) { - return - } + // External nodes can be selected for camera zoom but don't open Grafana setSelectedUserId((prev) => (prev === node.id ? null : node.id)) }, []) @@ -1202,7 +1666,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { clearTimeout(searchTimeoutRef.current) } - if (!prunedGraphData || !query.trim()) { + if (!rawGraphData || !query.trim()) { setSearchResults([]) return } @@ -1210,10 +1674,34 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Debounce the actual search by 150ms searchTimeoutRef.current = setTimeout(() => { const lowerQuery = query.toLowerCase() - // Search ALL nodes (not just active/visible ones) - const results = prunedGraphData.nodes.filter( - (node) => node.username && node.username.toLowerCase().includes(lowerQuery) - ) + const results: any[] = [] + + // Search user nodes + if (rawGraphData) { + const userResults = rawGraphData.nodes.filter( + (node) => node.username && node.username.toLowerCase().includes(lowerQuery) + ) + results.push(...userResults.map((n) => ({ ...n, isExternal: false, displayName: n.username }))) + } + + // Search external nodes (by label and ID) + if (externalNodesConfig.enabled && filteredExternalNodes.length > 0) { + const externalResults = filteredExternalNodes.filter( + (node) => + node.label.toLowerCase().includes(lowerQuery) || node.id.toLowerCase().includes(lowerQuery) + ) + results.push( + ...externalResults.map((n) => ({ + id: `ext_${n.id}`, + isExternal: true, + displayName: n.label, + externalType: n.type, + uniqueUsers: n.uniqueUsers, + totalUsd: n.totalUsd, + })) + ) + } + setSearchResults(results) if (results.length === 1) { @@ -1221,7 +1709,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { } }, 150) }, - [prunedGraphData] + [rawGraphData, filteredExternalNodes, externalNodesConfig.enabled] ) const handleClearSearch = useCallback(() => { @@ -1269,7 +1757,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { } // User nodes: scale slightly with points (bigger nodes push more) const base = -fc.charge.strength - const pointsMultiplier = 1 + Math.sqrt(node.totalPoints || 0) / 100 + const pointsMultiplier = 1 + Math.sqrt(getNodePoints(node)) / 100 return base * Math.min(pointsMultiplier, 2) // Cap at 2x }) .distanceMin(10) // Prevent infinite force at very close range @@ -1286,12 +1774,12 @@ export default function InvitesGraph(props: InvitesGraphProps) { .radius((node: any) => { // External nodes: size based on connections if (node.isExternal) { - const size = 4 + Math.log2(node.uniqueUsers || 1) * 2 + const size = 4 + Math.log2(getExternalNodeUsers(node)) * 2 return size * 1.5 } // User nodes: size based on points const baseSize = node.hasAppAccess ? 6 : 3 - const pointsMultiplier = Math.sqrt(node.totalPoints || 0) / 10 + const pointsMultiplier = Math.sqrt(getNodePoints(node)) / 10 const nodeRadius = baseSize + Math.min(pointsMultiplier, 25) return nodeRadius * 1.5 // 1.5x = slight padding, doesn't fight charge }) @@ -1325,9 +1813,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { const inviteStr = fc.inviteLinks.enabled ? Math.min(fc.inviteLinks.strength, 1.0) : 0 const p2pStr = fc.p2pLinks.enabled ? Math.min(fc.p2pLinks.strength, 1.0) : 0 const extStr = extConfig.enabled ? Math.min(extConfig.strength, 1.0) : 0 - console.log( - `[FORCES] Links: invite=${inviteStr.toFixed(2)}, p2p=${p2pStr.toFixed(2)}, ext=${extStr.toFixed(2)}` - ) } // CENTER: Pulls nodes toward origin. sizeBias controls how much bigger nodes are pulled more @@ -1340,7 +1825,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { // sizeBias: 0 = uniform, 1 = big nodes get 2x pull // Formula: strength * (1 + sizeBias * pointsMultiplier) - const pointsMultiplier = Math.min(Math.sqrt(node.totalPoints || 0) / 100, 1) + const pointsMultiplier = Math.min(Math.sqrt(getNodePoints(node)) / 100, 1) return centerConfig.strength * (1 + centerConfig.sizeBias * pointsMultiplier) }) ) @@ -1348,7 +1833,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { 'y', d3.forceY(0).strength((node: any) => { if (node.isExternal) return centerConfig.strength * 0.5 - const pointsMultiplier = Math.min(Math.sqrt(node.totalPoints || 0) / 100, 1) + const pointsMultiplier = Math.min(Math.sqrt(getNodePoints(node)) / 100, 1) return centerConfig.strength * (1 + centerConfig.sizeBias * pointsMultiplier) }) ) @@ -1368,25 +1853,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Manual recalculation button - resets positions and reconfigures forces const handleRecalculate = useCallback(() => { if (!graphRef.current) { - console.log('[RECALC] No graphRef') return } const graph = graphRef.current as any - // Debug: find where simulation lives - console.log( - '[RECALC] Graph keys:', - Object.keys(graph).filter((k) => !k.startsWith('_')) - ) - - // Try different ways to access simulation - const simulation = - graph.d3Force?.('link')?._simulation || // via force - graph._simulation || // direct - graph.simulation?.() || // method - graph.__simulation // alt internal - // Get nodes from graphData prop or via d3Force const linkForce = graph.d3Force?.('link') const nodes = @@ -1395,8 +1866,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { [] const uniqueNodes = [...new Map(nodes.map((n: any) => [n.id || n, n])).values()] - console.log('[RECALC] Found nodes:', uniqueNodes.length, 'hasSimulation:', !!simulation) - if (uniqueNodes.length > 0) { // Reset positions on actual node objects uniqueNodes.forEach((node: any) => { @@ -1409,7 +1878,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { delete node.fy } }) - console.log('[RECALC] Reset positions for', uniqueNodes.length, 'nodes') } // Reheat simulation @@ -1427,12 +1895,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { const applyForces = () => { if (!graphRef.current) return false - console.log('[FORCES] Configuring:', { - charge: forceConfig.charge.strength, - links: forceConfig.inviteLinks.strength, - nodes: filteredGraphData.nodes.length, - }) - // configureForces is async - must wait for it to complete before reheating configureForces().then(() => { if (!graphRef.current) return @@ -1440,7 +1902,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { const internalGraph = graphRef.current as any if (internalGraph._simulation) { internalGraph._simulation.alpha(1).restart() - console.log('[FORCES] Reheated') } else { graphRef.current.d3ReheatSimulation() } @@ -1535,6 +1996,37 @@ export default function InvitesGraph(props: InvitesGraphProps) { // 2. performance.now() in linkCanvasObject - animates particles based on real time // No additional animation loop needed! + // Debug: Build combined links and log what's being passed to ForceGraph2D + const combinedLinks = useMemo(() => { + if (!filteredGraphData) return [] + + const inviteLinks = filteredGraphData.edges.map((edge) => ({ + ...edge, + source: edge.target, + target: edge.source, + isP2P: false, + isExternal: false, + })) + + const p2pLinks = (filteredGraphData.p2pEdges || []).map((edge, i) => ({ + id: `p2p-${i}`, + source: edge.source, + target: edge.target, + type: edge.type, + count: edge.count, + totalUsd: edge.totalUsd, + frequency: edge.frequency, + volume: edge.volume, + bidirectional: edge.bidirectional, + isP2P: true, + isExternal: false, + })) + + const allLinks = [...inviteLinks, ...p2pLinks, ...externalLinks] + + return allLinks + }, [filteredGraphData, externalLinks]) + // Cleanup on unmount useEffect(() => { return () => { @@ -1615,6 +2107,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { isP2P: false, })), // P2P payment edges (for clustering visualization) + // P2P payment edges (for clustering visualization) + // Include both full mode (count/totalUsd) and anonymized mode (frequency/volume) fields ...(filteredGraphData.p2pEdges || []).map((edge, i) => ({ id: `p2p-${i}`, source: edge.source, @@ -1622,6 +2116,9 @@ export default function InvitesGraph(props: InvitesGraphProps) { type: edge.type, count: edge.count, totalUsd: edge.totalUsd, + frequency: edge.frequency, + volume: edge.volume, + bidirectional: edge.bidirectional, isP2P: true, })), ], @@ -1629,10 +2126,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { nodeId="id" nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => { // Draw hit detection area matching actual rendered node size - const hasAccess = node.hasAppAccess - const baseSize = hasAccess ? 6 : 3 - const pointsMultiplier = Math.sqrt(node.totalPoints || 0) / 10 - const nodeRadius = baseSize + Math.min(pointsMultiplier, 25) + // In user mode (minimal): fixed size of 12 + const nodeRadius = 12 ctx.fillStyle = color ctx.beginPath() ctx.arc(node.x, node.y, nodeRadius + 2, 0, 2 * Math.PI) // +2 for easier hover @@ -1675,8 +2170,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { {renderOverlays?.({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, @@ -1726,7 +2221,9 @@ export default function InvitesGraph(props: InvitesGraphProps) {
)} -

Invite Network

+

+ {mode === 'payment' ? 'Payment Network' : 'Invite Network'} +

{combinedGraphNodes.length} nodes @@ -1738,7 +2235,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { )} - {filteredGraphData.stats.totalEdges + externalLinks.length} edges + {/* In payment mode, show P2P edges; in other modes, show invite edges */} + {(mode === 'payment' + ? filteredGraphData.stats.totalP2PEdges + : filteredGraphData.stats.totalEdges) + externalLinks.length}{' '} + edges {externalNodesConfig.enabled && externalLinks.length > 0 && ( (+{externalLinks.length} ext) )} @@ -1749,67 +2250,97 @@ export default function InvitesGraph(props: InvitesGraphProps) { {/* Right side - empty, controls are in sidebar overlay */}
- {/* Second Row: Search */} -
-
-
- handleSearch(e.target.value)} - placeholder="Search username..." - className="w-full rounded-lg border border-gray-300 py-1.5 pl-9 pr-9 text-sm transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20" - /> - - {searchQuery && ( - + {/* Second Row: Search (hidden in payment mode - no usernames) */} + {mode !== 'payment' && ( +
+
+
+ handleSearch(e.target.value)} + placeholder="Search username..." + className="w-full rounded-lg border border-gray-300 py-1.5 pl-9 pr-9 text-sm transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20" + /> + + {searchQuery && ( + + )} +
+ {searchResults.length > 0 && ( + + {searchResults.length} {searchResults.length === 1 ? 'match' : 'matches'} + )}
- {searchResults.length > 0 && ( - - {searchResults.length} {searchResults.length === 1 ? 'match' : 'matches'} - + {/* Search Results Dropdown */} + {searchQuery && searchResults.length > 1 && ( +
+ {searchResults.map((node: any) => ( + + ))} +
)}
- {/* Search Results Dropdown */} - {searchQuery && searchResults.length > 1 && ( -
- {searchResults.map((node) => ( - - ))} -
- )} -
+ )} - {/* Selected User Banner */} + {/* Selected User/Node Banner */} {selectedUserId && ( -
- +
+ Focused on:{' '} - {filteredGraphData.nodes.find((n) => n.id === selectedUserId)?.username || - selectedUserId} + {selectedUserId.startsWith('ext_') + ? filteredExternalNodes.find((n) => `ext_${n.id}` === selectedUserId)?.label || + selectedUserId.replace('ext_', '') + : filteredGraphData.nodes.find((n) => n.id === selectedUserId)?.username || + selectedUserId}
` }} nodeCanvasObject={nodeCanvasObject} nodeCanvasObjectMode={() => 'replace'} - linkLabel={(link: any) => - link.isP2P - ? `P2P: ${link.count} txs ($${link.totalUsd?.toFixed(2) ?? '0'})` - : `${link.type} - ${new Date(link.createdAt).toLocaleDateString()}` - } + linkLabel={(link: any) => { + if (link.isP2P) { + // Handle both full (count/totalUsd) and anonymized (frequency/volume) modes + if (link.frequency && link.volume) { + return `P2P: ${link.frequency} activity, ${link.volume} volume` + } + return `P2P: ${link.count} txs ($${link.totalUsd?.toFixed(2) ?? '0'})` + } + if (link.isExternal) { + // Handle both full and anonymized modes + if (link.frequency && link.volume) { + return `Merchant: ${link.frequency} activity, ${link.volume} volume` + } + return `External: ${link.txCount} txs ($${link.totalUsd?.toFixed(2) ?? '0'})` + } + return `${link.type} - ${new Date(link.createdAt).toLocaleDateString()}` + }} linkCanvasObject={linkCanvasObject} linkCanvasObjectMode={() => 'replace'} onNodeClick={handleNodeClick} @@ -1950,8 +2505,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { {renderOverlays?.({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx index 976f58d18..60e84286c 100644 --- a/src/components/Global/NavHeader/index.tsx +++ b/src/components/Global/NavHeader/index.tsx @@ -65,10 +65,9 @@ const NavHeader = ({ onClick={logoutUser} loading={isLoggingOut} variant="stroke" + icon="logout" className={twMerge('h-7 w-7 p-0 md:hidden', isLoggingOut && 'pl-3')} - > - - + /> )}
) diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx index 04a6d7ba4..80052b893 100644 --- a/src/components/Global/TokenSelector/TokenSelector.tsx +++ b/src/components/Global/TokenSelector/TokenSelector.tsx @@ -33,6 +33,7 @@ import { TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS, } from './TokenSelector.consts' import { Drawer, DrawerContent, DrawerTitle } from '../Drawer' +import underMaintenanceConfig from '@/config/underMaintenance.config' interface SectionProps { title: string @@ -59,6 +60,9 @@ interface NewTokenSelectorProps { } const TokenSelector: React.FC = ({ classNameButton, viewType = 'other', disabled }) => { + // check if cross-chain withdraw is disabled via maintenance config + const isSquidWithdrawDisabled = viewType === 'withdraw' && underMaintenanceConfig.disableSquidWithdraw + // state to track content height const contentRef = useRef(null) const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -168,6 +172,28 @@ const TokenSelector: React.FC = ({ classNameButton, viewT // build list of popular tokens (usdc, usdt, native) for display const popularTokensList = useMemo(() => { + // when squid withdraw is disabled, only show USDC on Arbitrum + if (isSquidWithdrawDisabled) { + if (!supportedSquidChainsAndTokens) return [] + const arbitrumChainId = PEANUT_WALLET_CHAIN.id.toString() + const chainData = supportedSquidChainsAndTokens[arbitrumChainId] + if (!chainData?.tokens) return [] + + const usdcToken = chainData.tokens.find((t) => areEvmAddressesEqual(t.address, PEANUT_WALLET_TOKEN)) + if (!usdcToken) return [] + + return [ + { + ...usdcToken, + chainId: arbitrumChainId, + amount: 0, + price: 0, + currency: usdcToken.symbol, + value: '', + }, + ] + } + const popularSymbolsToFind = ['USDC', 'USDT'] const createPopularTokenEntry = (token: IToken, chainId: string): IUserBalance => ({ ...token, @@ -250,7 +276,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT // default: popular tokens on popular chains const popularChainIds = popularChainsForButtons.map((pc) => pc.chainId) return buildTokensForChainArray(popularChainIds) - }, [searchValue, selectedChainID, supportedSquidChainsAndTokens, popularChainsForButtons]) + }, [searchValue, selectedChainID, supportedSquidChainsAndTokens, popularChainsForButtons, isSquidWithdrawDisabled]) // filter popular tokens by search const filteredPopularTokensToDisplay = useMemo(() => { @@ -365,60 +391,76 @@ const TokenSelector: React.FC = ({ classNameButton, viewT /> ) : (
- {/* Popular chains section - rendered for all views */} - <> -
-
-
- {popularChainsForButtons.map((chain) => ( + {/* Info banner when cross-chain withdraw is disabled */} + {isSquidWithdrawDisabled && ( +
+ + + Cross-chain withdrawals are temporarily unavailable. You can withdraw USDC + on Arbitrum. + +
+ )} + + {/* Popular chains section - hidden when cross-chain withdraw is disabled */} + {!isSquidWithdrawDisabled && ( + <> +
+
+
+ {popularChainsForButtons.map((chain) => ( + { + if (selectedChainID === chain.chainId) { + setSelectedChainID('') // clear selection if already selected + } else { + setSelectedChainID(chain.chainId) //otherwise, select it + } + }} + isSelected={chain.chainId === selectedChainID} + /> + ))} { - if (selectedChainID === chain.chainId) { - setSelectedChainID('') // clear selection if already selected - } else { - setSelectedChainID(chain.chainId) //otherwise, select it - } - }} - isSelected={chain.chainId === selectedChainID} + chainName="Search" + isSearch={true} + onClick={handleSearchNetwork} /> - ))} - +
+
+ + + )} + + {/* Hide search when squid withdraw is disabled - only one option available */} + {!isSquidWithdrawDisabled && ( +
+ setSearchValue('')} + placeholder="Search for a token or paste address" + /> +
+ + + Transactions using USDC on Arbitrum are sponsored +
-
- - - -
- setSearchValue('')} - placeholder="Search for a token or paste address" - /> -
- - - Transactions using USDC on Arbitrum are sponsored -
-
+ )} {/* Popular tokens section */}
- {selectedNetworkName && clearChainSelection()} + {selectedNetworkName && !isSquidWithdrawDisabled && clearChainSelection()} {filteredPopularTokensToDisplay.length > 0 ? ( filteredPopularTokensToDisplay.map((token) => { diff --git a/src/components/Kyc/InitiateBridgeKYCModal.tsx b/src/components/Kyc/InitiateBridgeKYCModal.tsx index 0fdb440bf..8fd7b90aa 100644 --- a/src/components/Kyc/InitiateBridgeKYCModal.tsx +++ b/src/components/Kyc/InitiateBridgeKYCModal.tsx @@ -5,6 +5,7 @@ import { KycVerificationInProgressModal } from './KycVerificationInProgressModal import { type IconName } from '@/components/Global/Icons/Icon' import { saveRedirectUrl } from '@/utils/general.utils' import useClaimLink from '../Claim/useClaimLink' +import { useEffect } from 'react' interface BridgeKycModalFlowProps { isOpen: boolean @@ -29,9 +30,17 @@ export const InitiateBridgeKYCModal = ({ handleInitiateKyc, handleIframeClose, closeVerificationProgressModal, + resetError, } = useBridgeKycFlow({ onKycSuccess, flow, onManualClose }) const { addParamStep } = useClaimLink() + // Reset error when modal opens to ensure clean state + useEffect(() => { + if (isOpen) { + resetError() + } + }, [isOpen, resetError]) + const handleVerifyClick = async () => { addParamStep('bank') const result = await handleInitiateKyc() @@ -61,14 +70,17 @@ export const InitiateBridgeKYCModal = ({ icon: 'check-circle', className: 'h-11', }, - { - hidden: !error, - text: error ?? 'Retry', - onClick: onClose, - variant: 'transparent', - className: - 'underline text-xs md:text-sm !font-normal w-full !transform-none !pt-2 text-error-3 px-0', - }, + ...(error + ? [ + { + text: error, + onClick: onClose, + variant: 'transparent' as const, + className: + 'underline text-xs md:text-sm !font-normal w-full !transform-none !pt-2 text-error-3 px-0', + }, + ] + : []), ]} /> diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index cbf3439b1..38ed4dc4f 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -32,7 +32,6 @@ export const Profile = () => { await logoutUser() } - const fullName = user?.user.fullName || user?.user?.username || 'Anonymous User' const username = user?.user.username || 'anonymous' // respect user's showFullName preference: use fullName only if showFullName is true, otherwise use username const displayName = user?.user.showFullName && user?.user.fullName ? user.user.fullName : username diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts index 275da7ccf..fff8dbeee 100644 --- a/src/config/underMaintenance.config.ts +++ b/src/config/underMaintenance.config.ts @@ -17,6 +17,11 @@ * - shows clear error message to users about provider outage * - other providers continue to work normally * + * 4. disableSquidWithdraw: disables cross-chain withdrawals via Squid + * - restricts withdraw token selector to only USDC on Arbitrum + * - shows info message explaining cross-chain is temporarily unavailable + * - same-chain withdrawals (USDC on Arbitrum) continue to work + * * note: if either mode is enabled, the maintenance banner will show everywhere * * I HOPE WE NEVER NEED TO USE THIS... @@ -29,12 +34,14 @@ interface MaintenanceConfig { enableFullMaintenance: boolean enableMaintenanceBanner: boolean disabledPaymentProviders: PaymentProvider[] + disableSquidWithdraw: boolean } const underMaintenanceConfig: MaintenanceConfig = { enableFullMaintenance: false, // set to true to redirect all pages to /maintenance enableMaintenanceBanner: false, // set to true to show maintenance banner on all pages disabledPaymentProviders: [], // set to ['MANTECA'] to disable Manteca QR payments + disableSquidWithdraw: true, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum) } export default underMaintenanceConfig diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 838e5d220..59411a9df 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -51,17 +51,19 @@ export const RESERVED_ROUTES: readonly string[] = [...DEDICATED_ROUTES, ...STATI * Routes accessible without authentication * These paths can be accessed by non-logged-in users * - * Note: 'dev' routes require authentication and specific user authorization (not public) + * Note: Most 'dev' routes require authentication and specific user authorization + * Exception: /dev/payment-graph is public (uses API key instead of user auth) */ -export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite', 'qr'] as const +export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite', 'qr', 'dev/payment-graph'] as const /** * Regex pattern for public routes (used in layout.tsx) * Matches paths that don't require authentication * - * Note: Dev tools routes are NOT public - they require both authentication and specific user authorization + * Note: Most dev tools routes are NOT public - they require both authentication and specific user authorization + * Exception: /dev/payment-graph is public (uses API key instead of user auth) */ -export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invite|qr)/ +export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invite|qr|dev\/payment-graph)/ /** * Routes where middleware should run diff --git a/src/hooks/useBridgeKycFlow.ts b/src/hooks/useBridgeKycFlow.ts index 2cde6dead..03feb4724 100644 --- a/src/hooks/useBridgeKycFlow.ts +++ b/src/hooks/useBridgeKycFlow.ts @@ -170,6 +170,10 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl router.push('/home') } + const resetError = useCallback(() => { + setError(null) + }, []) + return { isLoading, error, @@ -179,5 +183,6 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl handleIframeClose, closeVerificationProgressModal, closeVerificationModalAndGoHome, + resetError, } } diff --git a/src/hooks/useGraphPreferences.ts b/src/hooks/useGraphPreferences.ts index b39f944f5..c718b45db 100644 --- a/src/hooks/useGraphPreferences.ts +++ b/src/hooks/useGraphPreferences.ts @@ -8,6 +8,7 @@ import type { } from '@/components/Global/InvitesGraph' const GRAPH_PREFS_KEY = 'invite-graph-preferences' +const PAYMENT_GRAPH_PREFS_KEY = 'payment-graph-preferences' export interface GraphPreferences { forceConfig?: ForceConfig @@ -15,7 +16,8 @@ export interface GraphPreferences { activityFilter?: ActivityFilter externalNodesConfig?: ExternalNodesConfig showUsernames?: boolean - showAllNodes?: boolean + /** Top N nodes limit (0 = all nodes). Backend-filtered. */ + topNodes?: number } /** @@ -25,36 +27,43 @@ export interface GraphPreferences { * * IMPORTANT: savePreferences does NOT update state to avoid infinite loops * It only writes to localStorage. preferences state is only set on initial load. + * + * @param mode - 'full' for full-graph, 'payment' for payment-graph (separate storage keys) */ -export function useGraphPreferences() { +export function useGraphPreferences(mode: 'full' | 'payment' = 'full') { const [preferences, setPreferences] = useState(null) const [isLoaded, setIsLoaded] = useState(false) const initialPrefsRef = useRef(null) + const storageKey = mode === 'payment' ? PAYMENT_GRAPH_PREFS_KEY : GRAPH_PREFS_KEY + // Load preferences on mount useEffect(() => { - const saved = getFromLocalStorage(GRAPH_PREFS_KEY) as GraphPreferences | null + const saved = getFromLocalStorage(storageKey) as GraphPreferences | null if (saved) { setPreferences(saved) initialPrefsRef.current = saved } setIsLoaded(true) - }, []) + }, [storageKey]) // Save preferences to localStorage ONLY - does NOT update state to avoid loops - const savePreferences = useCallback((prefs: GraphPreferences) => { - saveToLocalStorage(GRAPH_PREFS_KEY, prefs) - // Don't call setPreferences here - it causes infinite loops - }, []) + const savePreferences = useCallback( + (prefs: GraphPreferences) => { + saveToLocalStorage(storageKey, prefs) + // Don't call setPreferences here - it causes infinite loops + }, + [storageKey] + ) // Clear all preferences const clearPreferences = useCallback(() => { setPreferences(null) initialPrefsRef.current = null if (typeof localStorage !== 'undefined') { - localStorage.removeItem(GRAPH_PREFS_KEY) + localStorage.removeItem(storageKey) } - }, []) + }, [storageKey]) return { preferences, diff --git a/src/services/points.ts b/src/services/points.ts index f1aee26c2..ad2dc3748 100644 --- a/src/services/points.ts +++ b/src/services/points.ts @@ -3,6 +3,25 @@ import { type CalculatePointsRequest, PointsAction, type TierInfo } from './serv import { fetchWithSentry } from '@/utils/sentry.utils' import { PEANUT_API_URL } from '@/constants/general.consts' +/** Qualitative labels for anonymized data */ +export type FrequencyLabel = 'rare' | 'occasional' | 'regular' | 'frequent' +export type VolumeLabel = 'small' | 'medium' | 'large' | 'whale' +export type SizeLabel = 'tiny' | 'small' | 'medium' | 'large' | 'huge' + +/** P2P edge - can be full (with counts) or anonymized (with labels) */ +export type P2PEdge = { + source: string + target: string + type: 'SEND_LINK' | 'REQUEST_PAYMENT' | 'DIRECT_TRANSFER' + bidirectional: boolean + // Full mode (exact values) - optional in anonymized mode + count?: number + totalUsd?: number + // Anonymized mode (qualitative labels) - optional in full mode + frequency?: FrequencyLabel + volume?: VolumeLabel +} + type InvitesGraphResponse = { success: boolean data: { @@ -10,11 +29,14 @@ type InvitesGraphResponse = { id: string username: string hasAppAccess: boolean - directPoints: number - transitivePoints: number - totalPoints: number - createdAt: string - lastActiveAt: string | null + // Full mode fields - optional in payment mode + directPoints?: number + transitivePoints?: number + totalPoints?: number + createdAt?: string + lastActiveAt?: string | null + // Payment mode fields - optional in full mode + size?: SizeLabel kycRegions: string[] | null }> edges: Array<{ @@ -24,14 +46,7 @@ type InvitesGraphResponse = { type: 'DIRECT' | 'PAYMENT_LINK' createdAt: string }> - p2pEdges: Array<{ - source: string - target: string - type: 'SEND_LINK' | 'REQUEST_PAYMENT' | 'DIRECT_TRANSFER' - count: number - totalUsd: number - bidirectional: boolean - }> + p2pEdges: P2PEdge[] stats: { totalNodes: number totalEdges: number @@ -46,15 +61,40 @@ type InvitesGraphResponse = { /** External node types for payment destinations outside our user base */ export type ExternalNodeType = 'WALLET' | 'BANK' | 'MERCHANT' -/** External payment destination node */ +/** Direction of payment flow for external nodes */ +export type ExternalDirection = 'INCOMING' | 'OUTGOING' + +/** Per-user transaction data with direction + * Supports both full mode (with exact values) and anonymized mode (with labels) + */ +export type UserTxDataEntry = { + direction: ExternalDirection + // Full mode (exact values) - optional in anonymized mode + txCount?: number + totalUsd?: number + // Anonymized mode (qualitative labels) - optional in full mode + frequency?: FrequencyLabel + volume?: VolumeLabel +} + +/** External payment destination node + * Supports both full mode and anonymized mode with optional fields + */ export type ExternalNode = { id: string type: ExternalNodeType - userIds: string[] - uniqueUsers: number - txCount: number - totalUsd: number label: string + userTxData: Record + // Full mode fields - optional in anonymized mode + uniqueUsers?: number + userIds?: string[] + txCount?: number + totalUsd?: number + lastTxDate?: string + // Anonymized mode fields - optional in full mode + size?: SizeLabel + frequency?: FrequencyLabel + volume?: VolumeLabel } type ExternalNodesResponse = { @@ -68,8 +108,8 @@ type ExternalNodesResponse = { BANK: number MERCHANT: number } - totalTxCount: number - totalVolumeUsd: number + totalTxCount?: number + totalVolumeUsd?: number } } | null error?: string @@ -78,12 +118,13 @@ type ExternalNodesResponse = { async function fetchInvitesGraph( endpoint: string, extraHeaders?: Record, - handleStatusError?: (status: number) => string | null + handleStatusError?: (status: number) => string | null, + requiresAuth: boolean = true ): Promise { try { - // Get JWT token for user authentication + // Get JWT token for user authentication (optional in payment mode) const jwtToken = Cookies.get('jwt-token') - if (!jwtToken) { + if (requiresAuth && !jwtToken) { console.error('getInvitesGraph: No JWT token found') return { success: false, data: null, error: 'Not authenticated. Please log in.' } } @@ -92,13 +133,18 @@ async function fetchInvitesGraph( const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 30000) + // Build headers - JWT is optional when requiresAuth is false + const headers: Record = { + 'Content-Type': 'application/json', + ...extraHeaders, + } + if (jwtToken) { + headers['Authorization'] = `Bearer ${jwtToken}` + } + const response = await fetchWithSentry(`${PEANUT_API_URL}${endpoint}`, { method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwtToken}`, - ...extraHeaders, - }, + headers, signal: controller.signal, }) @@ -255,15 +301,37 @@ export const pointsApi = { } }, - getInvitesGraph: async (apiKey: string): Promise => { - return fetchInvitesGraph('/invites/graph', { 'api-key': apiKey }, (status) => { - if (status === 403) { - return 'Access denied. Only authorized users can access this tool.' - } else if (status === 401) { - return 'Invalid API key or authentication token.' - } - return null - }) + getInvitesGraph: async ( + apiKey: string, + options?: { mode?: 'full' | 'payment'; topNodes?: number; password?: string } + ): Promise => { + const isPaymentMode = options?.mode === 'payment' + const params = new URLSearchParams() + if (isPaymentMode) { + params.set('mode', 'payment') + } + if (options?.topNodes && options.topNodes > 0) { + params.set('topNodes', options.topNodes.toString()) + } + if (options?.password) { + params.set('password', options.password) + } + const endpoint = `/invites/graph${params.toString() ? `?${params}` : ''}` + // Payment mode uses password auth (no API key needed), full mode requires API key + JWT + const headers: Record = isPaymentMode ? {} : { 'api-key': apiKey } + return fetchInvitesGraph( + endpoint, + headers, + (status) => { + if (status === 403) { + return 'Access denied. Only authorized users can access this tool.' + } else if (status === 401) { + return isPaymentMode ? 'Invalid or missing password.' : 'Invalid API key or authentication token.' + } + return null + }, + !isPaymentMode // requiresAuth = false for payment mode + ) }, getUserInvitesGraph: async (): Promise => { @@ -272,16 +340,28 @@ export const pointsApi = { getExternalNodes: async ( apiKey: string, - options?: { minConnections?: number; types?: ExternalNodeType[]; limit?: number } + options?: { + mode?: 'full' | 'payment' + minConnections?: number + types?: ExternalNodeType[] + limit?: number + topNodes?: number + password?: string + } ): Promise => { try { const jwtToken = Cookies.get('jwt-token') - if (!jwtToken) { + // Payment mode uses password auth, full mode requires JWT + const isPaymentMode = options?.mode === 'payment' + if (!isPaymentMode && !jwtToken) { return { success: false, data: null, error: 'Not authenticated. Please log in.' } } // Build query params const params = new URLSearchParams() + if (options?.mode) { + params.set('mode', options.mode) + } if (options?.minConnections) { params.set('minConnections', options.minConnections.toString()) } @@ -291,16 +371,32 @@ export const pointsApi = { if (options?.limit) { params.set('limit', options.limit.toString()) } + if (options?.topNodes && options.topNodes > 0) { + params.set('topNodes', options.topNodes.toString()) + } + // Password is required for payment mode + if (options?.password) { + params.set('password', options.password) + } const url = `${PEANUT_API_URL}/invites/graph/external${params.toString() ? `?${params}` : ''}` + // Build headers: + // - Payment mode: no API key required (uses password auth) + // - Full mode: API key + JWT required + const headers: Record = { + 'Content-Type': 'application/json', + } + if (!isPaymentMode) { + headers['api-key'] = apiKey + } + if (jwtToken) { + headers['Authorization'] = `Bearer ${jwtToken}` + } + const response = await fetchWithSentry(url, { method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwtToken}`, - 'api-key': apiKey, - }, + headers, }) if (!response.ok) {