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 4b4428110..93ec0b65f 100644 --- a/src/app/(mobile-ui)/dev/invite-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/full-graph/page.tsx @@ -2,6 +2,8 @@ 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 from '@/components/Global/InvitesGraph' import { DEFAULT_FORCE_CONFIG, @@ -9,7 +11,11 @@ import { DEFAULT_EXTERNAL_NODES_CONFIG, } from '@/components/Global/InvitesGraph/types' -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) @@ -27,6 +33,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 ( @@ -34,7 +78,7 @@ export default function InviteGraphPage() {
πŸ•ΈοΈ
-

Invite Graph

+

Full Graph

Admin tool - Enter your API key to visualize the network

@@ -77,8 +121,8 @@ export default function InviteGraphPage() { renderOverlays={({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, @@ -434,27 +478,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 */}
@@ -614,7 +662,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 */} @@ -681,6 +744,10 @@ export default function InviteGraphPage() {
{/* Nodes */}
+ + + New + Active @@ -729,7 +796,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..f1a047488 --- /dev/null +++ b/src/app/(mobile-ui)/dev/payment-graph/page.tsx @@ -0,0 +1,584 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' +import { Button } from '@/components/0_Bruddle/Button' +import InvitesGraph from '@/components/Global/InvitesGraph' +import { DEFAULT_FORCE_CONFIG } from '@/components/Global/InvitesGraph/types' + +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 22aa299f0..6f6ec33ec 100644 --- a/src/app/(mobile-ui)/points/page.tsx +++ b/src/app/(mobile-ui)/points/page.tsx @@ -23,6 +23,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() @@ -54,11 +55,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 ?? '') @@ -172,6 +174,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/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index 933936c8c..736d19e51 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): @@ -45,6 +45,7 @@ import { type VisibilityConfig, type ExternalNodesConfig, type ExternalNode, + type SizeLabel, DEFAULT_FORCE_CONFIG, DEFAULT_VISIBILITY_CONFIG, DEFAULT_EXTERNAL_NODES_CONFIG, @@ -59,14 +60,59 @@ 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 +} + +/** 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 */ @@ -77,8 +123,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 @@ -99,8 +146,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 @@ -124,57 +177,8 @@ function useGraphFiltering(graphData: GraphData | null) { return graphData } -// 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 { @@ -182,7 +186,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, @@ -190,6 +194,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) @@ -198,20 +250,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 @@ -227,25 +326,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 @@ -280,7 +387,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 @@ -293,17 +399,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 @@ -314,7 +438,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { activityFilter, externalNodesConfig, showUsernames, - showAllNodes, + topNodes, }) }, 1000) // Debounce 1 second @@ -325,7 +449,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { activityFilter, externalNodesConfig, showUsernames, - showAllNodes, + topNodes, isMinimal, savePreferences, ]) @@ -333,13 +457,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) @@ -352,12 +478,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) { @@ -376,7 +508,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) @@ -432,18 +564,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 @@ -462,52 +631,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 @@ -515,7 +810,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) @@ -526,28 +829,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) @@ -563,7 +878,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 @@ -572,44 +888,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( @@ -629,7 +963,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 = { @@ -696,21 +1030,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) @@ -718,25 +1060,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 @@ -831,7 +1205,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 @@ -847,37 +1221,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) @@ -885,10 +1299,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)' @@ -919,6 +1342,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})` @@ -928,34 +1352,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() + } } } }, @@ -1018,7 +1468,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) @@ -1029,10 +1483,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)) }, []) @@ -1066,7 +1517,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { clearTimeout(searchTimeoutRef.current) } - if (!prunedGraphData || !query.trim()) { + if (!rawGraphData || !query.trim()) { setSearchResults([]) return } @@ -1074,10 +1525,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) { @@ -1085,7 +1560,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { } }, 150) }, - [prunedGraphData] + [rawGraphData, filteredExternalNodes, externalNodesConfig.enabled] ) const handleClearSearch = useCallback(() => { @@ -1133,7 +1608,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 @@ -1150,12 +1625,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 }) @@ -1189,9 +1664,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 @@ -1204,7 +1676,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) }) ) @@ -1212,7 +1684,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) }) ) @@ -1232,25 +1704,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 = @@ -1259,8 +1717,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) => { @@ -1273,7 +1729,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { delete node.fy } }) - console.log('[RECALC] Reset positions for', uniqueNodes.length, 'nodes') } // Reheat simulation @@ -1291,12 +1746,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 @@ -1304,7 +1753,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() } @@ -1399,6 +1847,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 () => { @@ -1479,6 +1958,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, @@ -1486,6 +1967,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, })), ], @@ -1493,10 +1977,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 @@ -1539,8 +2021,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { {renderOverlays?.({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, @@ -1590,7 +2072,9 @@ export default function InvitesGraph(props: InvitesGraphProps) {
)} -

Invite Network

+

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

{combinedGraphNodes.length} nodes @@ -1602,7 +2086,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) )} @@ -1613,67 +2101,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} @@ -1814,8 +2356,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { {renderOverlays?.({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, diff --git a/src/components/Global/InvitesGraph/types.ts b/src/components/Global/InvitesGraph/types.ts index 70f5ad92d..955dda317 100644 --- a/src/components/Global/InvitesGraph/types.ts +++ b/src/components/Global/InvitesGraph/types.ts @@ -1,17 +1,26 @@ -import { type ExternalNode, type ExternalNodeType } from '@/services/points' +import { + type ExternalNode, + type ExternalNodeType, + type SizeLabel, + type FrequencyLabel, + type VolumeLabel, +} from '@/services/points' // Types export interface GraphNode { id: string username: string hasAppAccess: boolean - directPoints: number - transitivePoints: number - totalPoints: number + // Full mode fields - optional in payment mode + directPoints?: number + transitivePoints?: number + totalPoints?: number /** ISO date when user signed up */ - createdAt: string + createdAt?: string /** ISO date of last transaction activity (null if never active or >90 days ago) */ - lastActiveAt: string | null + 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 @@ -26,15 +35,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?: FrequencyLabel + volume?: VolumeLabel } export interface GraphData { @@ -70,7 +85,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 */ @@ -147,5 +162,5 @@ export const DEFAULT_ACTIVITY_FILTER: ActivityFilter = { hideInactive: false, // Default: show inactive as greyed out } -/** Re-export ExternalNode type for convenience */ -export type { ExternalNode, ExternalNodeType } +/** Re-export types from points service for convenience */ +export type { ExternalNode, ExternalNodeType, SizeLabel, FrequencyLabel, VolumeLabel } 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/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/useGraphPreferences.ts b/src/hooks/useGraphPreferences.ts index 55793eb3d..5add08ab3 100644 --- a/src/hooks/useGraphPreferences.ts +++ b/src/hooks/useGraphPreferences.ts @@ -8,6 +8,7 @@ import type { } from '@/components/Global/InvitesGraph/types' 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) {