From dba133a954ab119fd31b47e4fe084793c8b60e8b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 20 Jan 2026 13:48:05 +0000 Subject: [PATCH 01/18] hotfix: grafana link broken in invite graph --- src/components/Global/InvitesGraph/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index fdd9042ed..ca6b0782c 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -1154,7 +1154,8 @@ 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) From d1a8f7c7fe0d8777a918e8d6dca68d2bb98d9790 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 20 Jan 2026 14:18:59 +0000 Subject: [PATCH 02/18] feat(invite-graph): new users indicator and progressive inactive fade - Restored "new users" feature: green nodes for recent signups (within activity window) - Three-state activity status: New (green), Active (purple), Inactive (gray) - Progressive inactive fade with exponential time bands (1w, 2w, 4w, 8w, 16w, 32w, 64w+) - Each band gets progressively lighter gray + lower opacity for visual hierarchy - Updated legend to include "New" status - Fixed Grafana link format (teampeanut.grafana.net/explore-peanut-wallet-user) --- src/app/(mobile-ui)/dev/invite-graph/page.tsx | 4 + src/components/Global/InvitesGraph/index.tsx | 77 ++++++++++++------- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/app/(mobile-ui)/dev/invite-graph/page.tsx b/src/app/(mobile-ui)/dev/invite-graph/page.tsx index 25490770e..d77bbb732 100644 --- a/src/app/(mobile-ui)/dev/invite-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/invite-graph/page.tsx @@ -680,6 +680,10 @@ export default function InviteGraphPage() {
{/* Nodes */}
+ + + New + Active diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index ca6b0782c..b951d5cc9 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -724,28 +724,29 @@ export default function InvitesGraph(props: InvitesGraphProps) { }, [showUsernames, selectedUserId, isMinimal, activityFilter, visibilityConfig, externalNodesConfig]) // 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 + const now = Date.now() + const activityCutoff = now - filter.activityDays * 24 * 60 * 60 * 1000 - // 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 - } + // Check if signed up within activity window (NEW user) + const createdAtMs = node.createdAt ? new Date(node.createdAt).getTime() : 0 + const isNewSignup = createdAtMs >= activityCutoff - // Check if had tx within activity window - if (node.lastActiveAt) { - const lastActiveMs = new Date(node.lastActiveAt).getTime() - if (lastActiveMs >= activityCutoff) { - return 'active' - } - } + // Check if had tx within activity window + const hasRecentActivity = node.lastActiveAt + ? new Date(node.lastActiveAt).getTime() >= activityCutoff + : false - return 'inactive' - }, []) + // Priority: New signup > Active > Inactive + if (isNewSignup) return 'new' + if (hasRecentActivity) return 'active' + return 'inactive' + }, + [] + ) // Node styling const nodeCanvasObject = useCallback( @@ -854,25 +855,47 @@ export default function InvitesGraph(props: InvitesGraphProps) { // =========================================== let fillColor: string - let fillAlpha = 0.85 // Slight transparency on all nodes to see behind 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 From b82a26955f41e2c7b84f3bcb30b0b51320d513e6 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 20 Jan 2026 15:07:57 +0000 Subject: [PATCH 03/18] feat(invite-graph): add per-user external edge data and improve UI - Fix external edge tooltips: show per-user amounts instead of "undefined - Invalid Date" - Display accurate transaction counts/amounts per user-external node pair - Update ExternalNode type to include userTxData for edge weight calculation - Change minConnections from slider to discrete buttons (1, 2, 3, 5, 10) - Lower default minConnections from 2 to 1 to show all external nodes - Add fallback logic for missing userTxData Now edges show "External: 5 txs ($200.50)" with correct per-user data instead of showing node totals on every edge. --- src/app/(mobile-ui)/dev/invite-graph/page.tsx | 40 ++++++++++--------- src/components/Global/InvitesGraph/index.tsx | 27 +++++++++---- src/services/points.ts | 1 + 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/app/(mobile-ui)/dev/invite-graph/page.tsx b/src/app/(mobile-ui)/dev/invite-graph/page.tsx index d77bbb732..21a7e70e1 100644 --- a/src/app/(mobile-ui)/dev/invite-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/invite-graph/page.tsx @@ -433,27 +433,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].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 */}
diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index b951d5cc9..fc5e91676 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -176,7 +176,7 @@ export type ExternalNodesConfig = { export const DEFAULT_EXTERNAL_NODES_CONFIG: ExternalNodesConfig = { enabled: false, - minConnections: 2, + minConnections: 1, // Changed from 2 to 1 to show all external nodes including single-user banks limit: 5000, // Default limit for API query (can increase up to 10k) types: { WALLET: false, // Disabled by default (too many, less useful for analysis) @@ -618,23 +618,30 @@ export default function InvitesGraph(props: InvitesGraphProps) { return [...userNodes, ...externalNodes] }, [filteredGraphData, filteredExternalNodes, externalNodesConfig.enabled]) - // Build links to external nodes + // Build links to external nodes with per-user transaction data 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 links: { source: string; target: string; isExternal: true; txCount: number; totalUsd: number }[] = [] filteredExternalNodes.forEach((ext) => { const extNodeId = `ext_${ext.id}` ext.userIds.forEach((userId) => { if (userIdsInGraph.has(userId)) { + // Use per-user data if available, otherwise fall back to node totals + const userData = ext.userTxData?.[userId] || { + txCount: ext.txCount, + totalUsd: ext.totalUsd, + } links.push({ source: userId, target: extNodeId, isExternal: true, + txCount: userData.txCount, + totalUsd: userData.totalUsd, }) } }) @@ -1943,11 +1950,15 @@ export default function InvitesGraph(props: InvitesGraphProps) { }} 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) { + return `P2P: ${link.count} txs ($${link.totalUsd?.toFixed(2) ?? '0'})` + } + if (link.isExternal) { + return `External: ${link.txCount} txs ($${link.totalUsd?.toFixed(2) ?? '0'})` + } + return `${link.type} - ${new Date(link.createdAt).toLocaleDateString()}` + }} linkCanvasObject={linkCanvasObject} linkCanvasObjectMode={() => 'replace'} onNodeClick={handleNodeClick} diff --git a/src/services/points.ts b/src/services/points.ts index f1aee26c2..25bcb7c60 100644 --- a/src/services/points.ts +++ b/src/services/points.ts @@ -55,6 +55,7 @@ export type ExternalNode = { txCount: number totalUsd: number label: string + userTxData: Record // Per-user breakdown for edge weights } type ExternalNodesResponse = { From 24c1242aa7476f8abea4cb77b9194ebfb39f938f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 20 Jan 2026 15:09:51 +0000 Subject: [PATCH 04/18] feat(invite-graph): enable search for external nodes with custom labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search now includes external nodes (banks, wallets, merchants) - Searches both custom labels ("Manteca BR Deposit") and original IDs - Search results show emoji indicators for external node types (🏦 πŸͺ πŸ’³) - External results show user count and total USD instead of points - Allow right-click selection of external nodes for camera zoom - "Focused on" banner shows custom labels for external nodes with orange styling - Results styled with orange hover for external nodes vs purple for users Users can now search for "Manteca", "Bridge SEPA", bank IDs, etc. --- src/components/Global/InvitesGraph/index.tsx | 86 ++++++++++++++++---- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index fc5e91676..ced61c7f0 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -1196,10 +1196,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)) }, []) @@ -1241,10 +1238,37 @@ 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 (prunedGraphData) { + const userResults = prunedGraphData.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) { @@ -1252,7 +1276,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { } }, 150) }, - [prunedGraphData] + [prunedGraphData, filteredExternalNodes, externalNodesConfig.enabled] ) const handleClearSearch = useCallback(() => { @@ -1814,18 +1838,33 @@ export default function InvitesGraph(props: InvitesGraphProps) { {/* Search Results Dropdown */} {searchQuery && searchResults.length > 1 && (
- {searchResults.map((node) => ( + {searchResults.map((node: any) => ( ))} @@ -1833,14 +1872,25 @@ export default function InvitesGraph(props: InvitesGraphProps) { )}
- {/* 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} +
+
+ ) + } + // API key input screen if (!apiKeySubmitted) { return ( @@ -33,7 +77,7 @@ export default function InviteGraphPage() {
πŸ•ΈοΈ
-

Invite Graph

+

Full Graph

Admin tool - Enter your API key to visualize the network

@@ -76,8 +120,8 @@ export default function InviteGraphPage() { renderOverlays={({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, @@ -617,7 +661,7 @@ export default function InviteGraphPage() {
{/* Other options */} -
+
- +
+ + {/* Top nodes slider */} +
+
+ Top nodes: + + {topNodes === 0 ? 'All' : topNodes.toLocaleString()} + +
+ setTopNodes(parseInt(e.target.value))} + className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-purple-600" + /> +
+ All + 5k + 10k +
{/* Activity window */} @@ -736,7 +795,9 @@ export default function InviteGraphPage() {

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

- {!showAllNodes &&

Showing top 5000 nodes

} + {topNodes > 0 && ( +

Showing top {topNodes.toLocaleString()} nodes

+ )}
diff --git a/src/app/(mobile-ui)/dev/layout.tsx b/src/app/(mobile-ui)/dev/layout.tsx new file mode 100644 index 000000000..087150b36 --- /dev/null +++ b/src/app/(mobile-ui)/dev/layout.tsx @@ -0,0 +1,23 @@ +'use client' + +import { usePathname } from 'next/navigation' +import { notFound } from 'next/navigation' +import { IS_DEV } from '@/constants/general.consts' + +// Routes that are allowed in production (protected by API key / user check) +const PRODUCTION_ALLOWED_ROUTES = ['/dev/full-graph', '/dev/payment-graph'] + +export default function DevLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + + // In production, only allow specific routes (full-graph, payment-graph) + // Other dev tools (leaderboard, shake-test, dev index) are dev-only + if (!IS_DEV) { + const isAllowedInProd = PRODUCTION_ALLOWED_ROUTES.some((route) => pathname?.startsWith(route)) + if (!isAllowedInProd) { + notFound() + } + } + + return <>{children} +} diff --git a/src/app/(mobile-ui)/dev/page.tsx b/src/app/(mobile-ui)/dev/page.tsx index 193367576..a1a4d956f 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 (120-day window, no invites)', + 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..17777772f --- /dev/null +++ b/src/app/(mobile-ui)/dev/payment-graph/page.tsx @@ -0,0 +1,486 @@ +'use client' + +import { useState, useCallback } from 'react' +import { Button } from '@/components/0_Bruddle/Button' +import InvitesGraph, { DEFAULT_FORCE_CONFIG } from '@/components/Global/InvitesGraph' + +export default function PaymentGraphPage() { + const [apiKey, setApiKey] = useState('') + const [apiKeySubmitted, setApiKeySubmitted] = useState(false) + const [error, setError] = useState(null) + + const handleApiKeySubmit = useCallback(() => { + if (!apiKey.trim()) { + setError('Please enter an API key') + return + } + setError(null) + setApiKeySubmitted(true) + }, [apiKey]) + + const handleClose = useCallback(() => { + window.location.href = '/dev' + }, []) + + // API key input screen + if (!apiKeySubmitted) { + return ( +
+
+
+
πŸ’Έ
+

Payment Graph

+

+ P2P payment flow visualization (120-day window, no invites) +

+
+ {error && ( +
+
Error
+
{error}
+
+ )} + setApiKey(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()} + placeholder="Admin API Key" + 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 */} +
+ + {/* Merchants 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-green-600" + /> + Merchants + {externalNodesLoading && ( + + loading... + + )} + {externalNodesError && ( + + ❌ + + )} + {!externalNodesLoading && + !externalNodesError && + externalNodesConfig.enabled && ( + + {externalNodes.length} + + )} +
+ {externalNodesConfig.enabled && !externalNodesError && ( +
+ {/* Min connections */} +
+ + Min users to show merchant: + +
+ {[1, 2, 3, 5, 10].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-green-500" + /> + )} +
+
+ )} +
+ + {/* Divider */} +
+ + {/* Other options */} +
+ +
+ + {/* Action buttons */} +
+ + +
+
+ + {/* Compact Legend */} +
+
+ {/* Nodes - by P2P activity */} +
+ + + P2P Active + + + + No P2P + +
+ {/* External nodes */} + {externalNodesConfig.enabled && ( +
+ + + Merchant + +
+ )} + {/* Edges */} +
+ + P2P + +
+

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

+

Limited to 5000 nodes for performance

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

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

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

- Experimental. Only available for Seedlings badge holders. -

-
- - )} -
{invites.invitees?.map((invite: PointsInvite, i: number) => { const username = invite.username diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index 0fdced16b..9c4463f62 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): @@ -72,15 +72,21 @@ export interface GraphEdge { createdAt: string } -/** P2P payment edge between users (for clustering) */ +/** P2P payment edge between users (for clustering) + * Supports both full mode (with exact count/totalUsd) and anonymized mode (with frequency/volume labels) + */ export interface P2PEdge { source: string target: string type: 'SEND_LINK' | 'REQUEST_PAYMENT' | 'DIRECT_TRANSFER' - count: number - totalUsd: number /** True if payments went both ways between these users */ bidirectional: boolean + // Full mode fields (exact values) + count?: number + totalUsd?: number + // Anonymized mode fields (qualitative labels) + frequency?: 'rare' | 'occasional' | 'regular' | 'frequent' + volume?: 'small' | 'medium' | 'large' | 'whale' } export interface GraphData { @@ -116,7 +122,7 @@ export type ForceConfig = { /** Node repulsion (charge) - prevents overlap */ charge: { enabled: boolean; strength: number } /** Invite link attraction - tree clustering (force only, use visibilityConfig to hide edges) */ - inviteLinks: { enabled: boolean; strength: number } + inviteLinks: { enabled: boolean; strength: number; distance?: number } /** P2P link attraction - clusters transacting users (force only, use visibilityConfig to hide edges) */ p2pLinks: { enabled: boolean; strength: number } /** External link attraction - clusters users with shared wallets/banks/merchants */ @@ -127,7 +133,7 @@ export type ForceConfig = { export const DEFAULT_FORCE_CONFIG: ForceConfig = { charge: { enabled: true, strength: 80 }, - inviteLinks: { enabled: true, strength: 0.4 }, + inviteLinks: { enabled: true, strength: 0.4, distance: 50 }, p2pLinks: { enabled: true, strength: 0.3 }, externalLinks: { enabled: true, strength: 0.2 }, // Weaker than P2P - external connections are looser center: { enabled: true, strength: 0.03, sizeBias: 0.5 }, @@ -188,14 +194,17 @@ export const DEFAULT_EXTERNAL_NODES_CONFIG: ExternalNodesConfig = { /** Re-export ExternalNode type for convenience */ export type { ExternalNode, ExternalNodeType } +/** Graph mode determines which features are enabled */ +export type GraphMode = 'full' | 'payment' | 'user' + interface BaseProps { width?: number height?: number backgroundColor?: string /** Show usernames on nodes */ showUsernames?: boolean - /** Show all nodes (no 5000 limit) - can be slow */ - showAllNodes?: boolean + /** Limit to top N nodes by points (0 = all nodes, default 5000). Backend filtering. */ + topNodes?: number /** Activity filter for highlighting active/inactive/new users */ activityFilter?: ActivityFilter /** Force configuration for layout tuning */ @@ -206,8 +215,9 @@ interface BaseProps { renderOverlays?: (props: { showUsernames: boolean setShowUsernames: (v: boolean) => void - showAllNodes: boolean - setShowAllNodes: (v: boolean) => void + /** Top N nodes limit (0 = all). Changing triggers backend refetch. */ + topNodes: number + setTopNodes: (v: number) => void activityFilter: ActivityFilter setActivityFilter: (v: ActivityFilter) => void forceConfig: ForceConfig @@ -228,6 +238,8 @@ interface BaseProps { interface FullModeProps extends BaseProps { /** Admin API key to fetch full graph */ apiKey: 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 /** Minimal mode disabled */ @@ -260,57 +272,8 @@ export const DEFAULT_ACTIVITY_FILTER: ActivityFilter = { hideInactive: false, // Default: show inactive as greyed out } -// Performance limit - max nodes to render -const MAX_NODES = 5000 - -/** - * Prune graph to MAX_NODES by removing oldest inactive users first - * Keeps all edges between remaining nodes - */ -function pruneGraphData(graphData: GraphData | null): GraphData | null { - if (!graphData || graphData.nodes.length <= MAX_NODES) { - return graphData - } - - // Sort nodes: active users first (by lastActiveAt desc), then inactive (by createdAt desc) - const sortedNodes = [...graphData.nodes].sort((a, b) => { - // Both have lastActiveAt - sort by most recent first - if (a.lastActiveAt && b.lastActiveAt) { - return new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime() - } - // Only a has activity - a comes first - if (a.lastActiveAt && !b.lastActiveAt) return -1 - // Only b has activity - b comes first - if (!a.lastActiveAt && b.lastActiveAt) return 1 - // Neither has activity - sort by createdAt (most recent first) - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - }) - - // Take top MAX_NODES - const keptNodes = sortedNodes.slice(0, MAX_NODES) - const keptNodeIds = new Set(keptNodes.map((n) => n.id)) - - // Filter edges to only include those between kept nodes - const keptEdges = graphData.edges.filter((edge) => keptNodeIds.has(edge.source) && keptNodeIds.has(edge.target)) - - // Filter P2P edges to only include those between kept nodes - const keptP2PEdges = (graphData.p2pEdges || []).filter( - (edge) => keptNodeIds.has(edge.source) && keptNodeIds.has(edge.target) - ) - - return { - nodes: keptNodes, - edges: keptEdges, - p2pEdges: keptP2PEdges, - stats: { - totalNodes: keptNodes.length, - totalEdges: keptEdges.length, - totalP2PEdges: keptP2PEdges.length, - usersWithAccess: keptNodes.filter((n) => n.hasAppAccess).length, - orphans: keptNodes.filter((n) => !n.hasAppAccess).length, - }, - } -} +// Default top nodes limit (0 = all nodes, backend-filtered) +const DEFAULT_TOP_NODES = 5000 export default function InvitesGraph(props: InvitesGraphProps) { const { @@ -318,7 +281,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { height, backgroundColor = '#f9fafb', showUsernames: initialShowUsernames = true, - showAllNodes: initialShowAllNodes = false, + topNodes: initialTopNodes = DEFAULT_TOP_NODES, activityFilter: initialActivityFilter = DEFAULT_ACTIVITY_FILTER, forceConfig: initialForceConfig = DEFAULT_FORCE_CONFIG, visibilityConfig: initialVisibilityConfig = DEFAULT_VISIBILITY_CONFIG, @@ -326,6 +289,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) + const finalModeForceConfig: ForceConfig = + mode === 'payment' + ? { + ...modeForceConfig, + externalLinks: { + ...DEFAULT_FORCE_CONFIG.externalLinks, + strength: DEFAULT_FORCE_CONFIG.externalLinks.strength * 0.1, + }, + } + : modeForceConfig // Data state const [fetchedGraphData, setFetchedGraphData] = useState(null) @@ -334,17 +345,13 @@ 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 + // Note: topNodes filtering is now done by backend, no client-side pruning needed const rawGraphData = isMinimal ? props.data : fetchedGraphData - // 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]) - // 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 => { @@ -363,15 +370,15 @@ 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) @@ -431,7 +438,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { if (preferences.activityFilter) setActivityFilter(preferences.activityFilter) if (preferences.externalNodesConfig) setExternalNodesConfig(preferences.externalNodesConfig) if (preferences.showUsernames !== undefined) setShowUsernames(preferences.showUsernames) - if (preferences.showAllNodes !== undefined) setShowAllNodes(preferences.showAllNodes) + if (preferences.topNodes !== undefined) setTopNodes(preferences.topNodes) // eslint-disable-next-line react-hooks/exhaustive-deps }, [preferencesLoaded, isMinimal]) // Only depend on preferencesLoaded, not preferences @@ -448,7 +455,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { activityFilter, externalNodesConfig, showUsernames, - showAllNodes, + topNodes, }) }, 1000) // Debounce 1 second @@ -459,7 +466,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { activityFilter, externalNodesConfig, showUsernames, - showAllNodes, + topNodes, isMinimal, savePreferences, ]) @@ -467,10 +474,10 @@ 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 activity time window AND active/inactive checkboxes // activityDays defines the time window (e.g., 30 days) @@ -488,12 +495,12 @@ 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( + let filteredP2PEdges = (rawGraphData.p2pEdges || []).filter( (edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target) ) if (!visibilityConfig.p2pEdges) { @@ -512,7 +519,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { orphans: filteredNodes.filter((n) => !n.hasAppAccess).length, }, } - }, [prunedGraphData, activityFilter.activityDays, visibilityConfig, isNodeActive]) + }, [rawGraphData, activityFilter.activityDays, visibilityConfig, isNodeActive]) const graphRef = useRef(null) const containerRef = useRef(null) @@ -568,6 +575,18 @@ 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 [] @@ -580,9 +599,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { if (node.uniqueUsers < externalNodesConfig.minConnections) return false // Filter by type if (!externalNodesConfig.types[node.type]) return false - // Filter by activity window (only show nodes with recent transactions) - const lastTxMs = new Date(node.lastTxDate).getTime() - if (lastTxMs < activityCutoff) 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, activityFilter.activityDays]) @@ -604,50 +625,110 @@ 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 + // Keys can be: `${userId}_${direction}` (new format) 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 + } + // Add external nodes with position hint (start them at edges) // x, y will be populated by force simulation at runtime 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, - })) + .filter((ext) => { + // Only show if connected to visible users + // In anonymized mode, check userTxData keys for user IDs + const connectedUserIds = ext.userIds || Object.keys(ext.userTxData || {}).map(extractUserIdFromKey) + return connectedUserIds.some((uid: string) => userIdsInGraph.has(uid)) + }) + .map((ext) => { + const connectedUserIds = ext.userIds || Object.keys(ext.userTxData || {}).map(extractUserIdFromKey) + 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: connectedUserIds.filter((uid: string) => userIdsInGraph.has(uid)), + 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 with per-user transaction data + // 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; txCount: number; totalUsd: number }[] = [] + 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)) { - // Use per-user data if available, otherwise fall back to node totals - const userData = ext.userTxData?.[userId] || { - txCount: ext.txCount, - totalUsd: ext.totalUsd, - } + + // 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: userData.txCount, - totalUsd: userData.totalUsd, + 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, }) } }) @@ -656,7 +737,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { return links }, [filteredExternalNodes, filteredGraphData, externalNodesConfig.enabled]) - // 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 @@ -664,7 +746,13 @@ 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 = props.mode === 'payment' ? 'payment' : 'full' + // Only pass topNodes for full mode (payment mode ignores it, has its own limit) + const result = await pointsApi.getInvitesGraph(props.apiKey, { + mode: apiMode, + topNodes: apiMode === 'full' ? topNodes : undefined, + }) if (result.success && result.data) { setFetchedGraphData(result.data) @@ -675,7 +763,8 @@ 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) useEffect(() => { @@ -688,7 +777,10 @@ export default function InvitesGraph(props: InvitesGraphProps) { setExternalNodesError(null) try { + // API only supports 'full' | 'payment' modes + const apiMode = props.mode === 'payment' ? 'payment' : 'full' const result = await pointsApi.getExternalNodes(props.apiKey, { + mode: apiMode, minConnections: 1, // Fetch all, filter client-side for flexibility limit: externalNodesConfig.limit, // User-configurable limit }) @@ -720,26 +812,43 @@ 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): 'new' | 'active' | 'inactive' => { if (!filter.enabled) return 'active' // No filtering, show all as active + // 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' + const now = Date.now() const activityCutoff = now - filter.activityDays * 24 * 60 * 60 * 1000 @@ -757,7 +866,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { if (hasRecentActivity) return 'active' return 'inactive' }, - [] + [mode] ) // Node styling @@ -845,21 +954,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(node.totalPoints) / 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) @@ -867,8 +984,18 @@ export default function InvitesGraph(props: InvitesGraphProps) { // =========================================== let fillColor: string - - 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 ? 'rgba(139, 92, 246, 0.85)' : 'rgba(156, 163, 175, 0.85)' } else { @@ -884,25 +1011,25 @@ export default function InvitesGraph(props: InvitesGraphProps) { 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 + 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 + 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 + 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 + 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 + 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 + fillColor = 'rgba(215, 215, 215, 0.4)' // Very light - 32-64 weeks } else { - fillColor = 'rgba(235, 235, 235, 0.3)' // Almost invisible - 64+ weeks + fillColor = 'rgba(235, 235, 235, 0.3)' // Almost invisible - 64+ weeks } } } @@ -1018,8 +1145,16 @@ 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 + (link.txCount || 1) * 0.25, 3.0) + const lineWidth = Math.min(0.4 + txCount * 0.25, 3.0) // Draw base line ctx.strokeStyle = lineColors[extType] || 'rgba(107, 114, 128, 0.25)' @@ -1029,17 +1164,16 @@ export default function InvitesGraph(props: InvitesGraphProps) { ctx.lineTo(target.x, target.y) ctx.stroke() - // Animated particles flowing user β†’ external (scaled by activity, slightly slower than P2P) + // Animated particles with direction based on actual fund flow const time = performance.now() - // Logarithmic scaling for better visual distinction (1 tx vs 10 tx vs 100 tx) - const logTxCount = Math.log10(Math.max(link.txCount || 1, 1) + 1) // log10(2) to log10(101) = 0.3 to 2.0 - const logUsd = Math.log10(Math.max(link.totalUsd || 1, 1) + 1) // Similar range - + // 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 - // Only normalize by length when simulation is stable (avoid jitter) 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 @@ -1047,11 +1181,17 @@ export default function InvitesGraph(props: InvitesGraphProps) { ctx.fillStyle = particleColors[extType] || 'rgba(107, 114, 128, 0.8)' + // 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 - const px = source.x + dx * t - const py = source.y + dy * t + // 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() @@ -1062,10 +1202,20 @@ export default function InvitesGraph(props: InvitesGraphProps) { if (link.isP2P) { // 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})` + + // 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 + (link.count || 1) * 0.25, 3.0) + 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) @@ -1074,16 +1224,16 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Animated particles for P2P if (!inactive) { const time = performance.now() - // Logarithmic scaling for better visual distinction (1 tx vs 10 tx vs 100 tx) - const logTxCount = Math.log10(Math.max(link.count || 1, 1) + 1) // log10(2) to log10(101) = 0.3 to 2.0 - const logUsd = Math.log10(Math.max(link.totalUsd || 1, 1) + 1) - + // 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 @@ -1116,6 +1266,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})` @@ -1125,34 +1276,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() + } } } }, @@ -1216,7 +1393,10 @@ export default function InvitesGraph(props: InvitesGraphProps) { if (selectedUserId === node.id) { // Already selected - open Grafana 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') + 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) @@ -1261,7 +1441,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { clearTimeout(searchTimeoutRef.current) } - if (!prunedGraphData || !query.trim()) { + if (!rawGraphData || !query.trim()) { setSearchResults([]) return } @@ -1272,21 +1452,18 @@ export default function InvitesGraph(props: InvitesGraphProps) { const results: any[] = [] // Search user nodes - if (prunedGraphData) { - const userResults = prunedGraphData.nodes.filter( + 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 })) - ) + 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) + node.label.toLowerCase().includes(lowerQuery) || node.id.toLowerCase().includes(lowerQuery) ) results.push( ...externalResults.map((n) => ({ @@ -1307,7 +1484,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { } }, 150) }, - [prunedGraphData, filteredExternalNodes, externalNodesConfig.enabled] + [rawGraphData, filteredExternalNodes, externalNodesConfig.enabled] ) const handleClearSearch = useCallback(() => { @@ -1689,6 +1866,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, @@ -1696,6 +1875,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, })), ], @@ -1703,10 +1885,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 @@ -1749,8 +1929,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { {renderOverlays?.({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, @@ -1800,7 +1980,9 @@ export default function InvitesGraph(props: InvitesGraphProps) {
)} -

Invite Network

+

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

{combinedGraphNodes.length} nodes @@ -1812,7 +1994,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) )} @@ -1823,73 +2009,77 @@ 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: any) => ( - - ))} -
- )} -
+ )} {/* Selected User/Node Banner */} {selectedUserId && ( @@ -1900,9 +2090,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { : 'border-purple-100 bg-purple-50' }`} > - + Focused on:{' '} {selectedUserId.startsWith('ext_') @@ -1935,6 +2123,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { isExternal: false, })), // 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, @@ -1942,6 +2131,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, isExternal: false, })), @@ -1967,6 +2159,9 @@ export default function InvitesGraph(props: InvitesGraphProps) { ctx.fill() }} nodeLabel={(node: any) => { + const currentMode = displaySettingsRef.current.mode + const isAnonymized = currentMode === 'payment' + // External node tooltip if (node.isExternal) { const fullId = node.id.replace('ext_', '') @@ -1976,10 +2171,23 @@ export default function InvitesGraph(props: InvitesGraphProps) { : node.externalType === 'BANK' ? `🏦 ${inferBankAccountType(fullId)}` : 'πŸͺ Merchant' - + // Show only masked labels for all types const displayLabel = node.externalType === 'BANK' ? 'Account' : 'ID' + // Anonymized mode: show qualitative labels instead of exact values + if (isAnonymized) { + return `
+
${typeLabel}
+
+
🏷️ ${displayLabel}: ${node.label}
+
πŸ‘₯ Users: ${node.uniqueUsers}
+
πŸ“Š Activity: ${node.frequency || 'N/A'}
+
πŸ’΅ Volume: ${node.volume || 'N/A'}
+
+
` + } + return `
${typeLabel}
@@ -1990,7 +2198,18 @@ export default function InvitesGraph(props: InvitesGraphProps) {
` } - // User node tooltip + + // User node tooltip - anonymized in payment mode + if (isAnonymized) { + return `
+
${node.username || 'User'}
+
+
${node.hasAppAccess ? 'βœ“ Active User' : '⏳ Inactive'}
+
+
` + } + + // Full mode: show all details const signupDate = node.createdAt ? new Date(node.createdAt).toLocaleDateString() : 'Unknown' const lastActive = node.lastActiveAt ? new Date(node.lastActiveAt).toLocaleDateString() @@ -2023,9 +2242,17 @@ export default function InvitesGraph(props: InvitesGraphProps) { nodeCanvasObjectMode={() => 'replace'} 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()}` @@ -2056,8 +2283,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { {renderOverlays?.({ showUsernames, setShowUsernames, - showAllNodes, - setShowAllNodes, + topNodes, + setTopNodes, activityFilter, setActivityFilter, forceConfig, diff --git a/src/hooks/useGraphPreferences.ts b/src/hooks/useGraphPreferences.ts index b39f944f5..195954ebe 100644 --- a/src/hooks/useGraphPreferences.ts +++ b/src/hooks/useGraphPreferences.ts @@ -15,7 +15,8 @@ export interface GraphPreferences { activityFilter?: ActivityFilter externalNodesConfig?: ExternalNodesConfig showUsernames?: boolean - showAllNodes?: boolean + /** Top N nodes limit (0 = all nodes). Backend-filtered. */ + topNodes?: number } /** diff --git a/src/services/points.ts b/src/services/points.ts index 72859fca4..cf196301b 100644 --- a/src/services/points.ts +++ b/src/services/points.ts @@ -3,6 +3,24 @@ 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' + +/** 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: { @@ -24,14 +42,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,17 +57,39 @@ 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 // Per-user breakdown for edge weights - lastTxDate: string // ISO date of most recent transaction (for activity filtering) + uniqueUsers: number + userTxData: Record + // Full mode fields - optional in anonymized mode + userIds?: string[] + txCount?: number + totalUsd?: number + lastTxDate?: string + // Anonymized mode fields - optional in full mode + frequency?: FrequencyLabel + volume?: VolumeLabel } type ExternalNodesResponse = { @@ -70,8 +103,8 @@ type ExternalNodesResponse = { BANK: number MERCHANT: number } - totalTxCount: number - totalVolumeUsd: number + totalTxCount?: number + totalVolumeUsd?: number } } | null error?: string @@ -257,8 +290,19 @@ export const pointsApi = { } }, - getInvitesGraph: async (apiKey: string): Promise => { - return fetchInvitesGraph('/invites/graph', { 'api-key': apiKey }, (status) => { + getInvitesGraph: async ( + apiKey: string, + options?: { mode?: 'full' | 'payment'; topNodes?: number } + ): Promise => { + const params = new URLSearchParams() + if (options?.mode === 'payment') { + params.set('mode', 'payment') + } + if (options?.topNodes && options.topNodes > 0) { + params.set('topNodes', options.topNodes.toString()) + } + const endpoint = `/invites/graph${params.toString() ? `?${params}` : ''}` + return fetchInvitesGraph(endpoint, { 'api-key': apiKey }, (status) => { if (status === 403) { return 'Access denied. Only authorized users can access this tool.' } else if (status === 401) { @@ -274,7 +318,12 @@ export const pointsApi = { getExternalNodes: async ( apiKey: string, - options?: { minConnections?: number; types?: ExternalNodeType[]; limit?: number } + options?: { + mode?: 'full' | 'payment' + minConnections?: number + types?: ExternalNodeType[] + limit?: number + } ): Promise => { try { const jwtToken = Cookies.get('jwt-token') @@ -284,6 +333,9 @@ export const pointsApi = { // Build query params const params = new URLSearchParams() + if (options?.mode) { + params.set('mode', options.mode) + } if (options?.minConnections) { params.set('minConnections', options.minConnections.toString()) } From 87b5d3b95c54a8b0acb0ad6ff079be927a360ec8 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 22 Jan 2026 02:10:52 +0000 Subject: [PATCH 07/18] graph fixes --- src/app/(mobile-ui)/dev/full-graph/page.tsx | 4 +- src/app/(mobile-ui)/dev/page.tsx | 2 +- .../(mobile-ui)/dev/payment-graph/page.tsx | 137 +++++++++++++---- src/components/Global/InvitesGraph/index.tsx | 143 ++++++++++++++---- src/constants/routes.ts | 10 +- src/services/points.ts | 66 +++++--- 6 files changed, 277 insertions(+), 85 deletions(-) diff --git a/src/app/(mobile-ui)/dev/full-graph/page.tsx b/src/app/(mobile-ui)/dev/full-graph/page.tsx index 729eb8441..b95acd7cb 100644 --- a/src/app/(mobile-ui)/dev/full-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/full-graph/page.tsx @@ -481,8 +481,8 @@ export default function FullGraphPage() {
Min users: -
- {[1, 2, 3, 5, 10].map((val) => ( +
+ {[1, 2, 3, 5, 10, 20, 50].map((val) => (
@@ -395,7 +449,7 @@ export default function PaymentGraphPage() { }, }) } - className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-green-500" + className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-orange-500" /> )}
@@ -419,6 +473,21 @@ export default function PaymentGraphPage() {
+ {/* Performance mode toggle */} +
+ +
+ {/* Action buttons */}
)} {/* Edges */} @@ -474,7 +557,9 @@ export default function PaymentGraphPage() {

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

-

Limited to 5000 nodes for performance

+

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

diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index 9c4463f62..050b1fe2f 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -326,14 +326,14 @@ export default function InvitesGraph(props: InvitesGraphProps) { types: { WALLET: false, BANK: false, MERCHANT: true }, } : DEFAULT_EXTERNAL_NODES_CONFIG - // Apply payment mode external link force adjustment (0.1x default) + // Apply payment mode external link force adjustment (0.01x default - very weak to avoid clustering) const finalModeForceConfig: ForceConfig = mode === 'payment' ? { ...modeForceConfig, externalLinks: { ...DEFAULT_FORCE_CONFIG.externalLinks, - strength: DEFAULT_FORCE_CONFIG.externalLinks.strength * 0.1, + strength: DEFAULT_FORCE_CONFIG.externalLinks.strength * 0.01, }, } : modeForceConfig @@ -382,16 +382,19 @@ export default function InvitesGraph(props: InvitesGraphProps) { 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() const preferencesRestoredRef = useRef(false) - // Load preferences ONCE on mount (only in full mode) + // Load preferences ONCE on mount (only in full mode, not minimal or payment) // Using preferencesLoaded as the only dependency - preferences won't change after load + const isPaymentMode = mode === 'payment' useEffect(() => { - if (isMinimal || !preferencesLoaded || preferencesRestoredRef.current) return + if (isMinimal || isPaymentMode || !preferencesLoaded || preferencesRestoredRef.current) return // Mark as restored immediately to prevent any re-runs preferencesRestoredRef.current = true @@ -441,12 +444,13 @@ export default function InvitesGraph(props: InvitesGraphProps) { if (preferences.topNodes !== undefined) setTopNodes(preferences.topNodes) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [preferencesLoaded, isMinimal]) // Only depend on preferencesLoaded, not preferences + }, [preferencesLoaded, isMinimal, isPaymentMode]) // 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 + // Also skip in payment mode - payment mode has its own defaults and shouldn't pollute full-graph prefs useEffect(() => { - if (isMinimal || !preferencesRestoredRef.current) return + if (isMinimal || isPaymentMode || !preferencesRestoredRef.current) return const timeout = setTimeout(() => { savePreferences({ @@ -594,7 +598,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { const now = Date.now() const activityCutoff = now - activityFilter.activityDays * 24 * 60 * 60 * 1000 - return externalNodesData.filter((node) => { + const filtered = externalNodesData.filter((node) => { // Filter by minConnections if (node.uniqueUsers < externalNodesConfig.minConnections) return false // Filter by type @@ -606,6 +610,23 @@ export default function InvitesGraph(props: InvitesGraphProps) { } return true }) + + // Debug logging + const byType = { + WALLET: filtered.filter((n) => n.type === 'WALLET').length, + BANK: filtered.filter((n) => n.type === 'BANK').length, + MERCHANT: filtered.filter((n) => n.type === 'MERCHANT').length, + } + console.log('[ExternalNodes] After client filter:', { + total: filtered.length, + byType, + config: { + minConnections: externalNodesConfig.minConnections, + types: externalNodesConfig.types, + activityDays: activityFilter.activityDays, + }, + }) + return filtered }, [externalNodesData, externalNodesConfig, activityFilter.activityDays]) // Build combined graph nodes including external nodes @@ -625,8 +646,8 @@ 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 - // Keys can be: `${userId}_${direction}` (new format) or just `${userId}` (old format) + // 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')) { @@ -635,17 +656,33 @@ export default function InvitesGraph(props: InvitesGraphProps) { 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) => { // Only show if connected to visible users - // In anonymized mode, check userTxData keys for user IDs - const connectedUserIds = ext.userIds || Object.keys(ext.userTxData || {}).map(extractUserIdFromKey) - return connectedUserIds.some((uid: string) => userIdsInGraph.has(uid)) + 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 = ext.userIds || Object.keys(ext.userTxData || {}).map(extractUserIdFromKey) + const connectedUserIds = getConnectedUserIds(ext) + const filteredUserIds = connectedUserIds.filter((uid: string) => userIdsInGraph.has(uid)) return { id: `ext_${ext.id}`, label: ext.label, @@ -655,13 +692,25 @@ export default function InvitesGraph(props: InvitesGraphProps) { totalUsd: ext.totalUsd, frequency: ext.frequency, volume: ext.volume, - userIds: connectedUserIds.filter((uid: string) => userIdsInGraph.has(uid)), + userIds: filteredUserIds, isExternal: true as const, x: undefined as number | undefined, y: undefined as number | undefined, } }) + // Debug logging + const visibleByType = { + WALLET: externalNodes.filter((n) => n.externalType === 'WALLET').length, + BANK: externalNodes.filter((n) => n.externalType === 'BANK').length, + MERCHANT: externalNodes.filter((n) => n.externalType === 'MERCHANT').length, + } + console.log('[ExternalNodes] After visible users filter:', { + visible: visibleByType, + filteredOut: filteredOutByVisibility, + visibleUserCount: userIdsInGraph.size, + }) + return [...userNodes, ...externalNodes] }, [filteredGraphData, filteredExternalNodes, externalNodesConfig.enabled]) @@ -674,6 +723,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { } const userIdsInGraph = new Set(filteredGraphData.nodes.map((n) => n.id)) + const isPaymentMode = mode === 'payment' + type ExternalLink = { source: string target: string @@ -686,7 +737,25 @@ export default function InvitesGraph(props: InvitesGraphProps) { filteredExternalNodes.forEach((ext) => { const extNodeId = `ext_${ext.id}` - // userTxData keys can be in two formats: + // In payment mode, userTxData keys are anonymized (hex IDs) but we have userIds array with real UUIDs + // Use userIds for linking since it matches the user node IDs + if (isPaymentMode && ext.userIds) { + ext.userIds.forEach((userId: string) => { + if (!userIdsInGraph.has(userId)) return + // In payment mode, use the overall merchant frequency/volume since per-user data is anonymized + links.push({ + source: userId, + target: extNodeId, + isExternal: true, + frequency: ext.frequency || 'occasional', + volume: ext.volume || 'medium', + direction: 'OUTGOING', // Default direction for payment mode + }) + }) + 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]) => { @@ -735,7 +804,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { }) return links - }, [filteredExternalNodes, filteredGraphData, externalNodesConfig.enabled]) + }, [filteredExternalNodes, filteredGraphData, externalNodesConfig.enabled, 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) @@ -747,11 +816,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { setError(null) // API only supports 'full' | 'payment' modes (user mode uses different endpoint) - const apiMode = props.mode === 'payment' ? 'payment' : 'full' - // Only pass topNodes for full mode (payment mode ignores it, has its own limit) + const apiMode = mode === 'payment' ? 'payment' : 'full' + // Pass topNodes for both modes - payment mode now supports it via Performance button const result = await pointsApi.getInvitesGraph(props.apiKey, { mode: apiMode, - topNodes: apiMode === 'full' ? topNodes : undefined, + topNodes: topNodes > 0 ? topNodes : undefined, }) if (result.success && result.data) { @@ -767,10 +836,13 @@ export default function InvitesGraph(props: InvitesGraphProps) { }, [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) @@ -778,16 +850,31 @@ export default function InvitesGraph(props: InvitesGraphProps) { try { // API only supports 'full' | 'payment' modes - const apiMode = props.mode === 'payment' ? 'payment' : 'full' + 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 }) if (result.success && result.data) { + // Debug logging for external nodes + const byType = { + WALLET: result.data.nodes.filter((n) => n.type === 'WALLET').length, + BANK: result.data.nodes.filter((n) => n.type === 'BANK').length, + MERCHANT: result.data.nodes.filter((n) => n.type === 'MERCHANT').length, + } + console.log('[ExternalNodes] Fetched:', { + total: result.data.nodes.length, + byType, + stats: result.data.stats, + mode: apiMode, + }) setExternalNodesData(result.data.nodes) - externalNodesFetchedRef.current = true + externalNodesFetchedLimitRef.current = externalNodesConfig.limit } else { const errorMsg = result.error || 'Unknown error' setExternalNodesError(errorMsg) @@ -803,7 +890,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 @@ -2199,13 +2287,10 @@ export default function InvitesGraph(props: InvitesGraphProps) { ` } - // User node tooltip - anonymized in payment mode + // User node tooltip - anonymized in payment mode (minimal, no status) if (isAnonymized) { return `
-
${node.username || 'User'}
-
-
${node.hasAppAccess ? 'βœ“ Active User' : '⏳ Inactive'}
-
+
${node.username || 'User'}
` } 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/services/points.ts b/src/services/points.ts index cf196301b..f16072368 100644 --- a/src/services/points.ts +++ b/src/services/points.ts @@ -113,12 +113,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.' } } @@ -127,13 +128,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, }) @@ -294,22 +300,29 @@ export const pointsApi = { apiKey: string, options?: { mode?: 'full' | 'payment'; topNodes?: number } ): Promise => { + const isPaymentMode = options?.mode === 'payment' const params = new URLSearchParams() - if (options?.mode === 'payment') { + if (isPaymentMode) { params.set('mode', 'payment') } if (options?.topNodes && options.topNodes > 0) { params.set('topNodes', options.topNodes.toString()) } const endpoint = `/invites/graph${params.toString() ? `?${params}` : ''}` - return fetchInvitesGraph(endpoint, { '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 - }) + // Payment mode only requires API key, full mode requires JWT + return fetchInvitesGraph( + endpoint, + { '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 + }, + !isPaymentMode // requiresAuth = false for payment mode + ) }, getUserInvitesGraph: async (): Promise => { @@ -327,7 +340,9 @@ export const pointsApi = { ): Promise => { try { const jwtToken = Cookies.get('jwt-token') - if (!jwtToken) { + // Payment mode only requires API key, full mode requires JWT + const isPaymentMode = options?.mode === 'payment' + if (!isPaymentMode && !jwtToken) { return { success: false, data: null, error: 'Not authenticated. Please log in.' } } @@ -348,13 +363,18 @@ export const pointsApi = { const url = `${PEANUT_API_URL}/invites/graph/external${params.toString() ? `?${params}` : ''}` + // Build headers - JWT is optional in payment mode + const headers: Record = { + 'Content-Type': 'application/json', + '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) { From 86086762d7a266ae4e7ef2f65ab0ca5a842fa48b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 22 Jan 2026 04:37:01 +0000 Subject: [PATCH 08/18] chore: remove debug logging from InvitesGraph after fixing orphan merchant issue Cleaned up all debug console.log statements that were added to investigate the duplicate node ID issue. The root cause was identified and fixed in the backend (SHA-256 hash collision prevention). Changes: - Removed API response duplicate detection logging - Removed external links duplicate ID checking - Removed orphan merchant warning logs - Removed link creation summary logs - Cleaned up unused variables (txDataKeys, matchedLinks, unmatchedKeys) --- src/components/Global/InvitesGraph/index.tsx | 372 +++++++++++++++---- 1 file changed, 296 insertions(+), 76 deletions(-) diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index 050b1fe2f..2ecfaa009 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -34,7 +34,14 @@ import { useState, useCallback, useMemo, useRef, useEffect } from 'react' import dynamic from 'next/dynamic' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' -import { pointsApi, type ExternalNode, type ExternalNodeType } from '@/services/points' +import { + pointsApi, + type ExternalNode, + type ExternalNodeType, + type SizeLabel, + type FrequencyLabel, + type VolumeLabel, +} from '@/services/points' import { inferBankAccountType } from '@/utils/bridge.utils' import { useGraphPreferences } from '@/hooks/useGraphPreferences' @@ -46,18 +53,61 @@ const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { const CLICK_MAX_DURATION_MS = 200 const CLICK_MAX_DISTANCE_PX = 5 +// Helper to convert qualitative size labels to numeric points for graph calculations +// Used in payment mode where real points aren't sent to frontend +function sizeLabelToPoints(size: SizeLabel | undefined): number { + if (!size) return 10 // default + switch (size) { + case 'tiny': + return 5 + case 'small': + return 50 + case 'medium': + return 500 + case 'large': + return 5000 + case 'huge': + return 50000 + } +} + +// Helper to get effective points for a node (real points in full mode, converted from size in payment mode) +function getNodePoints(node: any): number { + // Payment mode: node has size label instead of totalPoints + if (node.size && !node.totalPoints) { + return sizeLabelToPoints(node.size) + } + // Full mode: use real totalPoints + return node.totalPoints || 0 +} + +// Helper to get effective unique users count for external nodes +function getExternalNodeUsers(node: any): number { + // Payment mode: use userIds array length (accurate count of connections in graph) + if (node.userIds && node.userIds.length > 0) { + return node.userIds.length + } + // Full mode: use real uniqueUsers count + if (node.uniqueUsers !== undefined) { + return node.uniqueUsers + } + // Fallback: shouldn't reach here in normal operation + return 1 +} + // Types export interface GraphNode { id: string username: string hasAppAccess: boolean - directPoints: number - transitivePoints: number - totalPoints: number - /** ISO date when user signed up */ - createdAt: string - /** ISO date of last transaction activity (null if never active or >90 days ago) */ - lastActiveAt: string | null + // Full mode fields - optional in payment mode + directPoints?: number + transitivePoints?: number + totalPoints?: number + createdAt?: string + lastActiveAt?: string | null + // Payment mode fields - optional in full mode + size?: SizeLabel /** KYC regions: AR (Manteca Argentina), BR (Manteca Brazil), World (Bridge) - null if not KYC'd */ kycRegions: string[] | null x?: number @@ -242,6 +292,8 @@ interface FullModeProps extends BaseProps { 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 @@ -326,14 +378,14 @@ export default function InvitesGraph(props: InvitesGraphProps) { types: { WALLET: false, BANK: false, MERCHANT: true }, } : DEFAULT_EXTERNAL_NODES_CONFIG - // Apply payment mode external link force adjustment (0.01x default - very weak to avoid clustering) + // 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.01, + strength: DEFAULT_FORCE_CONFIG.externalLinks.strength * 0.1, }, } : modeForceConfig @@ -350,11 +402,68 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Use passed data in minimal mode, fetched data otherwise // Note: topNodes filtering is now done by backend, no client-side pruning needed - const rawGraphData = isMinimal ? props.data : fetchedGraphData + // 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)) + + // 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) + ) + + console.log('[PerformanceMode] Limited to top 1000:', { + before: data.nodes.length, + after: limitedNodes.length, + p2pEdges: { before: data.p2pEdges?.length || 0, after: filteredP2PEdges.length }, + }) + + 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 @@ -386,15 +495,18 @@ export default function InvitesGraph(props: InvitesGraphProps) { // 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, not minimal or payment) + // 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 - const isPaymentMode = mode === 'payment' useEffect(() => { - if (isMinimal || isPaymentMode || !preferencesLoaded || preferencesRestoredRef.current) return + if (isMinimal || !preferencesLoaded || preferencesRestoredRef.current) return // Mark as restored immediately to prevent any re-runs preferencesRestoredRef.current = true @@ -438,19 +550,37 @@ 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.topNodes !== undefined) setTopNodes(preferences.topNodes) + + // 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, isPaymentMode]) // Only depend on preferencesLoaded, not preferences + }, [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 - // Also skip in payment mode - payment mode has its own defaults and shouldn't pollute full-graph prefs + // Payment and full mode now save to separate keys, so no pollution useEffect(() => { - if (isMinimal || isPaymentMode || !preferencesRestoredRef.current) return + if (isMinimal || !preferencesRestoredRef.current) return const timeout = setTimeout(() => { savePreferences({ @@ -482,18 +612,33 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Start with all nodes let filteredNodes = rawGraphData.nodes + const initialNodeCount = filteredNodes.length // 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) { + const beforeFilter = filteredNodes.length filteredNodes = filteredNodes.filter((node) => { const isActive = isNodeActive(node, activityFilter) if (isActive && !visibilityConfig.activeNodes) return false if (!isActive && !visibilityConfig.inactiveNodes) return false return true }) + const afterFilter = filteredNodes.length + if (beforeFilter !== afterFilter) { + console.log('[GraphData] Activity filter removed nodes:', { + before: beforeFilter, + after: afterFilter, + removed: beforeFilter - afterFilter, + visibilityConfig: { + activeNodes: visibilityConfig.activeNodes, + inactiveNodes: visibilityConfig.inactiveNodes, + }, + activityDays: activityFilter.activityDays, + }) + } } const nodeIds = new Set(filteredNodes.map((n) => n.id)) @@ -511,6 +656,13 @@ export default function InvitesGraph(props: InvitesGraphProps) { filteredP2PEdges = [] } + console.log('[GraphData] Final filtered data:', { + initialNodes: initialNodeCount, + filteredNodes: filteredNodes.length, + edges: filteredEdges.length, + p2pEdges: filteredP2PEdges.length, + }) + return { nodes: filteredNodes, edges: filteredEdges, @@ -597,10 +749,25 @@ export default function InvitesGraph(props: InvitesGraphProps) { 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) @@ -711,7 +878,14 @@ export default function InvitesGraph(props: InvitesGraphProps) { visibleUserCount: userIdsInGraph.size, }) - return [...userNodes, ...externalNodes] + const combined = [...userNodes, ...externalNodes] + console.log('[CombinedGraphNodes] Final node counts:', { + userNodes: userNodes.length, + externalNodes: externalNodes.length, + total: combined.length, + }) + + return combined }, [filteredGraphData, filteredExternalNodes, externalNodesConfig.enabled]) // Build links to external nodes with per-user transaction data and direction @@ -737,21 +911,34 @@ export default function InvitesGraph(props: InvitesGraphProps) { filteredExternalNodes.forEach((ext) => { const extNodeId = `ext_${ext.id}` - // In payment mode, userTxData keys are anonymized (hex IDs) but we have userIds array with real UUIDs - // Use userIds for linking since it matches the user node IDs - if (isPaymentMode && ext.userIds) { - ext.userIds.forEach((userId: string) => { - if (!userIdsInGraph.has(userId)) return - // In payment mode, use the overall merchant frequency/volume since per-user data is anonymized + // 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: userId, + source: hexUserId, target: extNodeId, isExternal: true, - frequency: ext.frequency || 'occasional', - volume: ext.volume || 'medium', - direction: 'OUTGOING', // Default direction for payment mode + frequency: data.frequency || ext.frequency || 'occasional', + volume: data.volume || ext.volume || 'medium', + direction: direction, }) }) + return } @@ -858,6 +1045,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { 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 }) if (result.success && result.data) { @@ -975,7 +1163,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 = { @@ -1055,7 +1243,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { size = 12 // Fixed size for user graph - all nodes equal } else { const baseSize = hasAccess ? 6 : 3 - const pointsMultiplier = Math.sqrt(node.totalPoints) / 10 + const pointsMultiplier = Math.sqrt(getNodePoints(node)) / 10 size = baseSize + Math.min(pointsMultiplier, 25) } @@ -1620,7 +1808,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 @@ -1637,12 +1825,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 }) @@ -1688,7 +1876,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) }) ) @@ -1696,7 +1884,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) }) ) @@ -1874,6 +2062,54 @@ 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] + + // Debug logging + const externalLinksInFinal = allLinks.filter(l => l.isExternal) + const carrefourLinks = externalLinksInFinal.filter(l => (l.target as string).includes('ext_CARREF')) + + console.log('[CombinedLinks] Final links passed to ForceGraph2D:', { + totalLinks: allLinks.length, + inviteLinks: inviteLinks.length, + p2pLinks: p2pLinks.length, + externalLinks: externalLinksInFinal.length, + carrefourLinks: carrefourLinks.length, + sampleCarrefourLinks: carrefourLinks.slice(0, 3).map(l => ({ + source: l.source, + target: l.target, + isExternal: l.isExternal + })) + }) + + return allLinks + }, [filteredGraphData, externalLinks]) + // Cleanup on unmount useEffect(() => { return () => { @@ -2159,8 +2395,10 @@ export default function InvitesGraph(props: InvitesGraphProps) { {node.isExternal ? node.totalUsd ? `${node.uniqueUsers} users, $${node.totalUsd.toFixed(0)}` - : `${node.uniqueUsers} users, ${node.volume || 'N/A'}` - : `${node.totalPoints?.toLocaleString() || 0} pts`} + : `${node.size || node.volume || 'N/A'}` + : node.totalPoints + ? `${node.totalPoints.toLocaleString()} pts` + : node.size || 'N/A'} ))} @@ -2201,44 +2439,18 @@ export default function InvitesGraph(props: InvitesGraphProps) { ref={graphRef} graphData={{ nodes: combinedGraphNodes, - links: [ - // Invite edges (reversed for arrow direction) - ...filteredGraphData.edges.map((edge) => ({ - ...edge, - source: edge.target, - target: edge.source, - isP2P: false, - isExternal: false, - })), - // 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, - 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, - })), - // External node links (user β†’ external) - ...externalLinks, - ], + links: combinedLinks, }} nodeId="id" nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => { // Draw hit detection area matching actual rendered node size let size: number if (node.isExternal) { - size = 4 + Math.log2(node.uniqueUsers || 1) * 2 + size = 4 + Math.log2(getExternalNodeUsers(node)) * 2 } else { const hasAccess = node.hasAppAccess const baseSize = hasAccess ? 6 : 3 - const pointsMultiplier = Math.sqrt(node.totalPoints || 0) / 10 + const pointsMultiplier = Math.sqrt(getNodePoints(node)) / 10 size = baseSize + Math.min(pointsMultiplier, 25) } ctx.fillStyle = color @@ -2265,11 +2477,15 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Anonymized mode: show qualitative labels instead of exact values if (isAnonymized) { + // In payment mode, uniqueUsers is not sent - use size label or userIds count + const userCount = node.uniqueUsers ?? (node.userIds?.length || 0) + const userDisplay = node.size || userCount + return `
${typeLabel}
🏷️ ${displayLabel}: ${node.label}
-
πŸ‘₯ Users: ${node.uniqueUsers}
+
πŸ‘₯ Users: ${userDisplay}
πŸ“Š Activity: ${node.frequency || 'N/A'}
πŸ’΅ Volume: ${node.volume || 'N/A'}
@@ -2280,8 +2496,8 @@ export default function InvitesGraph(props: InvitesGraphProps) {
${typeLabel}
🏷️ ${displayLabel}: ${node.label}
-
πŸ‘₯ Users: ${node.uniqueUsers}
-
πŸ“Š Transactions: ${node.txCount}
+
πŸ‘₯ Users: ${node.uniqueUsers ?? (node.userIds?.length || 0)}
+
πŸ“Š Transactions: ${node.txCount ?? 'N/A'}
πŸ’΅ Volume: $${(node.totalUsd || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}
` @@ -2317,9 +2533,13 @@ export default function InvitesGraph(props: InvitesGraphProps) { ${invitedBy ? `
πŸ‘€ Invited by: ${invitedBy}
` : ''}
${node.hasAppAccess ? 'βœ“ Has Access' : '⏳ Jailed'}
${kycDisplay ? `
πŸͺͺ KYC: ${kycDisplay}
` : ''} -
+ ${ + node.totalPoints + ? `
${node.totalPoints.toLocaleString()} pts (${node.directPoints} direct, ${node.transitivePoints} trans) -
+
` + : '' + } ` }} From 780b64a07ee818905431f6d11985ed2d5b2259e3 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 22 Jan 2026 04:44:08 +0000 Subject: [PATCH 09/18] fix: apply prettier formatting to InvitesGraph --- src/components/Global/InvitesGraph/index.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index 2ecfaa009..e9eb71f50 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -497,9 +497,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Graph preferences persistence (separate storage for payment vs full mode) const isPaymentMode = mode === 'payment' - const { preferences, savePreferences, isLoaded: preferencesLoaded } = useGraphPreferences( - isPaymentMode ? 'payment' : 'full' - ) + const { + preferences, + savePreferences, + isLoaded: preferencesLoaded, + } = useGraphPreferences(isPaymentMode ? 'payment' : 'full') const preferencesRestoredRef = useRef(false) // Load preferences ONCE on mount (not in minimal mode) @@ -2091,8 +2093,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { const allLinks = [...inviteLinks, ...p2pLinks, ...externalLinks] // Debug logging - const externalLinksInFinal = allLinks.filter(l => l.isExternal) - const carrefourLinks = externalLinksInFinal.filter(l => (l.target as string).includes('ext_CARREF')) + const externalLinksInFinal = allLinks.filter((l) => l.isExternal) + const carrefourLinks = externalLinksInFinal.filter((l) => (l.target as string).includes('ext_CARREF')) console.log('[CombinedLinks] Final links passed to ForceGraph2D:', { totalLinks: allLinks.length, @@ -2100,11 +2102,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { p2pLinks: p2pLinks.length, externalLinks: externalLinksInFinal.length, carrefourLinks: carrefourLinks.length, - sampleCarrefourLinks: carrefourLinks.slice(0, 3).map(l => ({ + sampleCarrefourLinks: carrefourLinks.slice(0, 3).map((l) => ({ source: l.source, target: l.target, - isExternal: l.isExternal - })) + isExternal: l.isExternal, + })), }) return allLinks From 409e1a55e0eb9a75295a5a2afcfd3cffee059e23 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 22 Jan 2026 04:54:50 +0000 Subject: [PATCH 10/18] refactor: remove debug logs and add safety assertions - Remove verbose debug logs from graph rendering - Add lightweight console.assert() for duplicate ID detection - Remove unused simulation variable - Clean up debug logging throughout InvitesGraph component - Add safety nets for duplicate node/external node IDs This reduces console noise in production while maintaining runtime safety checks that only appear when assertions fail. Co-Authored-By: Claude Sonnet 4.5 --- src/components/Global/InvitesGraph/index.tsx | 118 +++---------------- 1 file changed, 14 insertions(+), 104 deletions(-) diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index e9eb71f50..018d5860a 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -433,12 +433,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { (edge) => limitedNodeIds.has(edge.source) && limitedNodeIds.has(edge.target) ) - console.log('[PerformanceMode] Limited to top 1000:', { - before: data.nodes.length, - after: limitedNodes.length, - p2pEdges: { before: data.p2pEdges?.length || 0, after: filteredP2PEdges.length }, - }) - return { nodes: limitedNodes, edges: filteredEdges, @@ -614,33 +608,18 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Start with all nodes let filteredNodes = rawGraphData.nodes - const initialNodeCount = filteredNodes.length // 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) { - const beforeFilter = filteredNodes.length filteredNodes = filteredNodes.filter((node) => { const isActive = isNodeActive(node, activityFilter) if (isActive && !visibilityConfig.activeNodes) return false if (!isActive && !visibilityConfig.inactiveNodes) return false return true }) - const afterFilter = filteredNodes.length - if (beforeFilter !== afterFilter) { - console.log('[GraphData] Activity filter removed nodes:', { - before: beforeFilter, - after: afterFilter, - removed: beforeFilter - afterFilter, - visibilityConfig: { - activeNodes: visibilityConfig.activeNodes, - inactiveNodes: visibilityConfig.inactiveNodes, - }, - activityDays: activityFilter.activityDays, - }) - } } const nodeIds = new Set(filteredNodes.map((n) => n.id)) @@ -651,6 +630,12 @@ export default function InvitesGraph(props: InvitesGraphProps) { filteredEdges = [] } + // 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) ) @@ -658,13 +643,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { filteredP2PEdges = [] } - console.log('[GraphData] Final filtered data:', { - initialNodes: initialNodeCount, - filteredNodes: filteredNodes.length, - edges: filteredEdges.length, - p2pEdges: filteredP2PEdges.length, - }) - return { nodes: filteredNodes, edges: filteredEdges, @@ -780,21 +758,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { return true }) - // Debug logging - const byType = { - WALLET: filtered.filter((n) => n.type === 'WALLET').length, - BANK: filtered.filter((n) => n.type === 'BANK').length, - MERCHANT: filtered.filter((n) => n.type === 'MERCHANT').length, - } - console.log('[ExternalNodes] After client filter:', { - total: filtered.length, - byType, - config: { - minConnections: externalNodesConfig.minConnections, - types: externalNodesConfig.types, - activityDays: activityFilter.activityDays, - }, - }) return filtered }, [externalNodesData, externalNodesConfig, activityFilter.activityDays]) @@ -868,27 +831,17 @@ export default function InvitesGraph(props: InvitesGraphProps) { } }) - // Debug logging - const visibleByType = { - WALLET: externalNodes.filter((n) => n.externalType === 'WALLET').length, - BANK: externalNodes.filter((n) => n.externalType === 'BANK').length, - MERCHANT: externalNodes.filter((n) => n.externalType === 'MERCHANT').length, - } - console.log('[ExternalNodes] After visible users filter:', { - visible: visibleByType, - filteredOut: filteredOutByVisibility, - visibleUserCount: userIdsInGraph.size, - }) - const combined = [...userNodes, ...externalNodes] - console.log('[CombinedGraphNodes] Final node counts:', { - userNodes: userNodes.length, - externalNodes: externalNodes.length, - total: combined.length, - }) + + // 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, filteredExternalNodes, externalNodesConfig.enabled]) + }, [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 @@ -1052,17 +1005,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { if (result.success && result.data) { // Debug logging for external nodes - const byType = { - WALLET: result.data.nodes.filter((n) => n.type === 'WALLET').length, - BANK: result.data.nodes.filter((n) => n.type === 'BANK').length, - MERCHANT: result.data.nodes.filter((n) => n.type === 'MERCHANT').length, - } - console.log('[ExternalNodes] Fetched:', { - total: result.data.nodes.length, - byType, - stats: result.data.stats, - mode: apiMode, - }) setExternalNodesData(result.data.nodes) externalNodesFetchedLimitRef.current = externalNodesConfig.limit } else { @@ -1911,13 +1853,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { const graph = graphRef.current as any - // 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 = @@ -1926,8 +1861,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) => { @@ -1957,12 +1890,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 @@ -2092,23 +2019,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { const allLinks = [...inviteLinks, ...p2pLinks, ...externalLinks] - // Debug logging - const externalLinksInFinal = allLinks.filter((l) => l.isExternal) - const carrefourLinks = externalLinksInFinal.filter((l) => (l.target as string).includes('ext_CARREF')) - - console.log('[CombinedLinks] Final links passed to ForceGraph2D:', { - totalLinks: allLinks.length, - inviteLinks: inviteLinks.length, - p2pLinks: p2pLinks.length, - externalLinks: externalLinksInFinal.length, - carrefourLinks: carrefourLinks.length, - sampleCarrefourLinks: carrefourLinks.slice(0, 3).map((l) => ({ - source: l.source, - target: l.target, - isExternal: l.isExternal, - })), - }) - return allLinks }, [filteredGraphData, externalLinks]) From c0c25c617cb17ef753b275577e4bb3455013b41a Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 22 Jan 2026 04:58:14 +0000 Subject: [PATCH 11/18] feat: add payment mode support to types and preferences - Add SizeLabel export for node sizing in payment mode - Make node fields optional to support both full and payment modes - Add separate localStorage key for payment graph preferences - Update payment graph to use topNodes=5000 with performanceMode Co-Authored-By: Claude Sonnet 4.5 --- .../(mobile-ui)/dev/payment-graph/page.tsx | 4 +-- src/hooks/useGraphPreferences.ts | 26 ++++++++++++------- src/services/points.ts | 21 ++++++++++----- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/app/(mobile-ui)/dev/payment-graph/page.tsx b/src/app/(mobile-ui)/dev/payment-graph/page.tsx index 9acc4c647..bf790286e 100644 --- a/src/app/(mobile-ui)/dev/payment-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/payment-graph/page.tsx @@ -65,10 +65,10 @@ export default function PaymentGraphPage() { return (
(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 f16072368..a99f90ca4 100644 --- a/src/services/points.ts +++ b/src/services/points.ts @@ -6,6 +6,7 @@ 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 = { @@ -28,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<{ @@ -80,14 +84,15 @@ export type ExternalNode = { id: string type: ExternalNodeType label: string - uniqueUsers: number 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 } @@ -336,6 +341,7 @@ export const pointsApi = { minConnections?: number types?: ExternalNodeType[] limit?: number + topNodes?: number } ): Promise => { try { @@ -360,6 +366,9 @@ export const pointsApi = { if (options?.limit) { params.set('limit', options.limit.toString()) } + if (options?.topNodes && options.topNodes > 0) { + params.set('topNodes', options.topNodes.toString()) + } const url = `${PEANUT_API_URL}/invites/graph/external${params.toString() ? `?${params}` : ''}` From 2b87dd683fc927c90bec5f500cd9d36ba89f5461 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:24:01 +0530 Subject: [PATCH 12/18] fix: update user details if missing during KYC process and reset error state on modal open --- .../AddWithdraw/AddWithdrawCountriesList.tsx | 20 +++++++++++++- src/components/Global/NavHeader/index.tsx | 5 ++-- src/components/Kyc/InitiateBridgeKYCModal.tsx | 26 +++++++++++++------ src/components/Profile/index.tsx | 1 - src/hooks/useBridgeKycFlow.ts | 5 ++++ 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index c45b02d52..b3f4acaf9 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -12,7 +12,7 @@ import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' import { useEffect, useMemo, useRef, useState } from 'react' import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm' -import { addBankAccount } from '@/app/actions/users' +import { addBankAccount, updateUserById } from '@/app/actions/users' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' import { useWebSocket } from '@/hooks/useWebSocket' @@ -136,6 +136,24 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // scenario (2): if the user hasn't completed kyc yet if (!isUserKycVerified) { + // update user's name and email if they are not present + const hasNameOnLoad = !!user?.user.fullName + const hasEmailOnLoad = !!user?.user.email + + if (!hasNameOnLoad || !hasEmailOnLoad) { + if (user?.user.userId && rawData.accountOwnerName && rawData.email) { + const result = await updateUserById({ + userId: user.user.userId, + fullName: rawData.accountOwnerName.trim(), + email: rawData.email, + }) + if (result.error) { + return { error: result.error } + } + await fetchUser() + } + } + setIsKycModalOpen(true) } diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx index 976f58d18..60e84286c 100644 --- a/src/components/Global/NavHeader/index.tsx +++ b/src/components/Global/NavHeader/index.tsx @@ -65,10 +65,9 @@ const NavHeader = ({ onClick={logoutUser} loading={isLoggingOut} variant="stroke" + icon="logout" className={twMerge('h-7 w-7 p-0 md:hidden', isLoggingOut && 'pl-3')} - > - - + /> )}
) diff --git a/src/components/Kyc/InitiateBridgeKYCModal.tsx b/src/components/Kyc/InitiateBridgeKYCModal.tsx index 0fdb440bf..021239ea3 100644 --- a/src/components/Kyc/InitiateBridgeKYCModal.tsx +++ b/src/components/Kyc/InitiateBridgeKYCModal.tsx @@ -5,6 +5,7 @@ import { KycVerificationInProgressModal } from './KycVerificationInProgressModal import { type IconName } from '@/components/Global/Icons/Icon' import { saveRedirectUrl } from '@/utils/general.utils' import useClaimLink from '../Claim/useClaimLink' +import { useEffect } from 'react' interface BridgeKycModalFlowProps { isOpen: boolean @@ -29,9 +30,15 @@ export const InitiateBridgeKYCModal = ({ handleInitiateKyc, handleIframeClose, closeVerificationProgressModal, + resetError, } = useBridgeKycFlow({ onKycSuccess, flow, onManualClose }) const { addParamStep } = useClaimLink() + // Reset error whenever modal open state changes to ensure clean state + useEffect(() => { + resetError() + }, [isOpen, resetError]) + const handleVerifyClick = async () => { addParamStep('bank') const result = await handleInitiateKyc() @@ -61,14 +68,17 @@ export const InitiateBridgeKYCModal = ({ icon: 'check-circle', className: 'h-11', }, - { - hidden: !error, - text: error ?? 'Retry', - onClick: onClose, - variant: 'transparent', - className: - 'underline text-xs md:text-sm !font-normal w-full !transform-none !pt-2 text-error-3 px-0', - }, + ...(error + ? [ + { + text: error, + onClick: onClose, + variant: 'transparent' as const, + className: + 'underline text-xs md:text-sm !font-normal w-full !transform-none !pt-2 text-error-3 px-0', + }, + ] + : []), ]} /> diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index cbf3439b1..38ed4dc4f 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -32,7 +32,6 @@ export const Profile = () => { await logoutUser() } - const fullName = user?.user.fullName || user?.user?.username || 'Anonymous User' const username = user?.user.username || 'anonymous' // respect user's showFullName preference: use fullName only if showFullName is true, otherwise use username const displayName = user?.user.showFullName && user?.user.fullName ? user.user.fullName : username diff --git a/src/hooks/useBridgeKycFlow.ts b/src/hooks/useBridgeKycFlow.ts index 2cde6dead..03feb4724 100644 --- a/src/hooks/useBridgeKycFlow.ts +++ b/src/hooks/useBridgeKycFlow.ts @@ -170,6 +170,10 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl router.push('/home') } + const resetError = useCallback(() => { + setError(null) + }, []) + return { isLoading, error, @@ -179,5 +183,6 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl handleIframeClose, closeVerificationProgressModal, closeVerificationModalAndGoHome, + resetError, } } From 16816bd74eb2b308cc87f3af5ecce36ae17f7187 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:03:59 +0530 Subject: [PATCH 13/18] fix: prevent data corruption by updating user fields independently - Only update fullName if it's missing (not overwrite existing) - Only update email if it's missing (not overwrite existing) - Add proper error handling for fetchUser() call - Trim email field like accountOwnerName - Only reset error when modal opens (not when closing) --- .cursorrules | 30 +++++++++++++++-- .../AddWithdraw/AddWithdrawCountriesList.tsx | 32 +++++++++++++------ src/components/Kyc/InitiateBridgeKYCModal.tsx | 6 ++-- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/.cursorrules b/.cursorrules index 721547acd..659e694f0 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,12 +1,15 @@ # peanut-ui Development Rules -**Version:** 0.0.1 | **Updated:** October 17, 2025 +**Version:** 0.0.2 | **Updated:** December 16, 2025 ## 🚫 Random - **Never open SVG files** - it crashes you. Only read jpeg, png, gif, or webp. - **Never run jq command** - it crashes you. - **Never run sleep** from command line - it hibernates pc. +- **Do not generate .md files** unless explicity told to do so. +- **Comments** should always be made in all lowercase and simple english +- **Error messages**, any error being shown in the ui should be user friendly and easy to understand, and any error being logged in consoles and sentry should be descriptive for developers to help with debugging ## πŸ’» Code Quality @@ -14,13 +17,28 @@ - **DRY** - do not repeat yourself. Reuse existing code and abstract shared functionality. Less code is better code. - this also means to use shared consts (e.g. check src/constants) - **Separate business logic from interface** - this is important for readability, debugging and testability. -- **Use explicit imports** where possible. - **Reuse existing components and functions** - don't hardcode hacky solutions. - **Warn about breaking changes** - when making changes, ensure you're not breaking existing functionality, and if there's a risk, explicitly WARN about it. - **Mention refactor opportunities** - if you notice an opportunity to refactor or improve existing code, mention it. DO NOT make any changes you were not explicitly told to do. Only mention the potential change to the user. - **Performance is important** - cache where possible, make sure to not make unnecessary re-renders or data fetching. - **Flag breaking changes** - always flag if changes done in Frontend are breaking and require action on Backend (or viceversa) +## πŸ”— URL as State (Critical for UX) + +- **URL is source of truth** - use query parameters for user-facing state that should survive navigation, refresh, or sharing (step indicators, amounts, filters, view modes, selected items) +- **Use nuqs library** - always use `useQueryStates` from [nuqs](https://nuqs.dev) for type-safe URL state management. never manually parse/set query params with router.push or URLSearchParams +- **Enable deep-linking** - users should be able to share or bookmark URLs mid-flow (e.g. `?step=inputAmount&amount=500¤cy=ARS`) +- **Proper navigation** - URL state enables correct back/forward browser button behavior +- **Multi-step flows** - the URL should always reflect current step and relevant data, making the app behave like a proper web app, not a trapped SPA +- **Reserve useState for ephemeral UI** - only use React useState for truly transient state: + - loading spinners and skeleton states + - modal open/close state + - form validation errors (unless they should persist) + - hover/focus states + - temporary UI animations +- **Don't URL-ify everything** - API responses, user authentication state, and internal component state generally shouldn't be in the URL unless they're user-facing and shareable +- **Type safety** - define parsers for query params (e.g. `parseAsInteger`, `parseAsStringEnum`) to ensure type safety and validation + ## 🚫 Import Rules (critical for build performance) - **No barrel imports** - never use `import * as X from '@/constants'` or create index.ts barrel files. always import from specific files (e.g. `import { PEANUT_API_URL } from '@/constants/general.consts'`). barrel imports slow down builds and cause bundling issues. @@ -28,6 +46,14 @@ - **No node.js packages in client components** - packages like `web-push`, `fs`, `crypto` (node) can't be used in `'use client'` files. use server actions or api routes instead. - **Check for legacy code** - before importing from a file, check if it has TODO comments marking it as legacy/deprecated. prefer newer implementations. +## 🚫 Export Rules (critical for build performance) + +- **Do not export multiple stuff from same component**: + - never export types or other utility methods from a component or a hook + - for types always use a separate file if they need to be reused + - and for utility/helper functions use a separate utils file to export them and use if they need to be reused + - same for files with multiple components exported, do not export multiple components from same file and if you see this done anywhere in the code, abstract it to other file + ## πŸ§ͺ Testing - **Test new code** - where tests make sense, test new code. Especially with fast unit tests. diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index b3f4acaf9..de668ef0d 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -141,16 +141,30 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const hasEmailOnLoad = !!user?.user.email if (!hasNameOnLoad || !hasEmailOnLoad) { - if (user?.user.userId && rawData.accountOwnerName && rawData.email) { - const result = await updateUserById({ - userId: user.user.userId, - fullName: rawData.accountOwnerName.trim(), - email: rawData.email, - }) - if (result.error) { - return { error: result.error } + if (user?.user.userId) { + // Build update payload to only update missing fields + const updatePayload: Record = { userId: user.user.userId } + + if (!hasNameOnLoad && rawData.accountOwnerName) { + updatePayload.fullName = rawData.accountOwnerName.trim() + } + + if (!hasEmailOnLoad && rawData.email) { + updatePayload.email = rawData.email.trim() + } + + // Only call update if we have fields to update + if (Object.keys(updatePayload).length > 1) { + const result = await updateUserById(updatePayload) + if (result.error) { + return { error: result.error } + } + try { + await fetchUser() + } catch (err) { + console.error('Failed to refresh user data after update:', err) + } } - await fetchUser() } } diff --git a/src/components/Kyc/InitiateBridgeKYCModal.tsx b/src/components/Kyc/InitiateBridgeKYCModal.tsx index 021239ea3..8fd7b90aa 100644 --- a/src/components/Kyc/InitiateBridgeKYCModal.tsx +++ b/src/components/Kyc/InitiateBridgeKYCModal.tsx @@ -34,9 +34,11 @@ export const InitiateBridgeKYCModal = ({ } = useBridgeKycFlow({ onKycSuccess, flow, onManualClose }) const { addParamStep } = useClaimLink() - // Reset error whenever modal open state changes to ensure clean state + // Reset error when modal opens to ensure clean state useEffect(() => { - resetError() + if (isOpen) { + resetError() + } }, [isOpen, resetError]) const handleVerifyClick = async () => { From 81c454fefe8533c950360587df31d370384eb6d7 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 22 Jan 2026 11:44:46 +0000 Subject: [PATCH 14/18] feat: switch payment graph to password-based auth - Replace API key auth with password query param for payment mode - Read password from URL (?password=xxx) or show input form - Support direct URL access: /dev/payment-graph?password=xxx - Backend validates password against PAYMENT_GRAPH_PASSWORD env var - API key still required for full-graph mode Co-Authored-By: Claude Opus 4.5 --- .../(mobile-ui)/dev/payment-graph/page.tsx | 44 ++++++++++++------- src/components/Global/InvitesGraph/index.tsx | 4 ++ src/services/points.ts | 12 +++-- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/app/(mobile-ui)/dev/payment-graph/page.tsx b/src/app/(mobile-ui)/dev/payment-graph/page.tsx index bf790286e..41354f2eb 100644 --- a/src/app/(mobile-ui)/dev/payment-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/payment-graph/page.tsx @@ -1,31 +1,42 @@ 'use client' -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' import { Button } from '@/components/0_Bruddle/Button' import InvitesGraph, { DEFAULT_FORCE_CONFIG } from '@/components/Global/InvitesGraph' export default function PaymentGraphPage() { - const [apiKey, setApiKey] = useState('') - const [apiKeySubmitted, setApiKeySubmitted] = useState(false) + 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) - const handleApiKeySubmit = useCallback(() => { - if (!apiKey.trim()) { - setError('Please enter an API key') + // 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) - setApiKeySubmitted(true) - }, [apiKey]) + setPasswordSubmitted(true) + }, [password]) const handleClose = useCallback(() => { window.location.href = '/dev' }, []) - // API key input screen - if (!apiKeySubmitted) { + // Password input screen (only shown if not provided in URL) + if (!passwordSubmitted) { return (
@@ -42,13 +53,13 @@ export default function PaymentGraphPage() { )} setApiKey(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()} - placeholder="Admin API Key" + value={password} + onChange={(e) => 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" /> -