@b3dotfun/sdk 0.0.30 → 0.0.31-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/cjs/anyspend/react/components/AnySpend.js +1 -1
  2. package/dist/cjs/anyspend/react/components/AnySpendBuySpin.js +2 -1
  3. package/dist/cjs/anyspend/react/components/AnySpendStakeB3.js +2 -1
  4. package/dist/cjs/anyspend/react/components/AnyspendDepositHype.d.ts +4 -0
  5. package/dist/cjs/anyspend/react/components/AnyspendDepositHype.js +6 -1
  6. package/dist/cjs/anyspend/react/components/common/ChainTokenIcon.d.ts +1 -1
  7. package/dist/cjs/anyspend/react/components/common/ChainTokenIcon.js +2 -1
  8. package/dist/cjs/anyspend/react/components/common/CryptoPaymentMethod.js +23 -28
  9. package/dist/cjs/anyspend/react/components/common/CryptoReceiveSection.d.ts +3 -1
  10. package/dist/cjs/anyspend/react/components/common/CryptoReceiveSection.js +2 -2
  11. package/dist/cjs/anyspend/react/components/common/OrderDetails.js +5 -5
  12. package/dist/cjs/anyspend/react/components/common/OrderTokenAmount.js +1 -1
  13. package/dist/cjs/anyspend/react/components/common/PanelOnramp.d.ts +4 -1
  14. package/dist/cjs/anyspend/react/components/common/PanelOnramp.js +3 -3
  15. package/dist/cjs/anyspend/react/components/common/PaySection.js +1 -1
  16. package/dist/cjs/global-account/react/components/B3DynamicModal.js +2 -5
  17. package/dist/cjs/global-account/react/components/B3Provider/B3Provider.js +5 -0
  18. package/dist/cjs/global-account/react/components/LinkAccount/LinkAccount.js +1 -0
  19. package/dist/cjs/global-account/react/components/ManageAccount/BalanceContent.d.ts +6 -0
  20. package/dist/cjs/global-account/react/components/ManageAccount/BalanceContent.js +94 -0
  21. package/dist/cjs/global-account/react/components/ManageAccount/ContentTokens.d.ts +14 -0
  22. package/dist/cjs/global-account/react/components/ManageAccount/ContentTokens.js +272 -0
  23. package/dist/cjs/global-account/react/components/ManageAccount/ManageAccount.js +9 -51
  24. package/dist/cjs/global-account/react/components/ManageAccount/TokenBalanceRow.d.ts +10 -0
  25. package/dist/cjs/global-account/react/components/ManageAccount/TokenBalanceRow.js +8 -0
  26. package/dist/cjs/global-account/react/components/TokenIcon.d.ts +11 -0
  27. package/dist/cjs/global-account/react/components/TokenIcon.js +43 -0
  28. package/dist/cjs/global-account/react/components/ui/accordion.d.ts +7 -0
  29. package/dist/cjs/global-account/react/components/ui/accordion.js +53 -0
  30. package/dist/cjs/global-account/react/components/ui/dialog.js +1 -1
  31. package/dist/cjs/global-account/react/hooks/index.d.ts +2 -0
  32. package/dist/cjs/global-account/react/hooks/index.js +5 -1
  33. package/dist/cjs/global-account/react/hooks/useAnalytics.d.ts +7 -0
  34. package/dist/cjs/global-account/react/hooks/useAnalytics.js +29 -0
  35. package/dist/cjs/global-account/react/hooks/useB3BalanceFromAddresses.js +2 -1
  36. package/dist/cjs/global-account/react/hooks/useNativeBalance.js +2 -1
  37. package/dist/cjs/global-account/react/hooks/useSimBalance.d.ts +24 -0
  38. package/dist/cjs/global-account/react/hooks/useSimBalance.js +29 -0
  39. package/dist/cjs/global-account/react/hooks/useUnifiedChainSwitchAndExecute.js +2 -1
  40. package/dist/cjs/global-account/react/stores/useModalStore.d.ts +2 -2
  41. package/dist/cjs/global-account/react/utils/profileDisplay.js +9 -0
  42. package/dist/cjs/global-account/utils/analytics.d.ts +16 -0
  43. package/dist/cjs/global-account/utils/analytics.js +55 -0
  44. package/dist/cjs/shared/constants/index.d.ts +1 -0
  45. package/dist/cjs/shared/constants/index.js +2 -1
  46. package/dist/cjs/shared/generated/chain-networks.json +185 -17
  47. package/dist/esm/anyspend/react/components/AnySpend.js +1 -1
  48. package/dist/esm/anyspend/react/components/AnySpendBuySpin.js +2 -1
  49. package/dist/esm/anyspend/react/components/AnySpendStakeB3.js +2 -1
  50. package/dist/esm/anyspend/react/components/AnyspendDepositHype.d.ts +4 -0
  51. package/dist/esm/anyspend/react/components/AnyspendDepositHype.js +5 -1
  52. package/dist/esm/anyspend/react/components/common/ChainTokenIcon.d.ts +1 -1
  53. package/dist/esm/anyspend/react/components/common/ChainTokenIcon.js +2 -1
  54. package/dist/esm/anyspend/react/components/common/CryptoPaymentMethod.js +22 -27
  55. package/dist/esm/anyspend/react/components/common/CryptoReceiveSection.d.ts +3 -1
  56. package/dist/esm/anyspend/react/components/common/CryptoReceiveSection.js +2 -2
  57. package/dist/esm/anyspend/react/components/common/OrderDetails.js +5 -5
  58. package/dist/esm/anyspend/react/components/common/OrderTokenAmount.js +1 -1
  59. package/dist/esm/anyspend/react/components/common/PanelOnramp.d.ts +4 -1
  60. package/dist/esm/anyspend/react/components/common/PanelOnramp.js +4 -4
  61. package/dist/esm/anyspend/react/components/common/PaySection.js +1 -1
  62. package/dist/esm/global-account/react/components/B3DynamicModal.js +2 -5
  63. package/dist/esm/global-account/react/components/B3Provider/B3Provider.js +5 -0
  64. package/dist/esm/global-account/react/components/LinkAccount/LinkAccount.js +1 -0
  65. package/dist/esm/global-account/react/components/ManageAccount/BalanceContent.d.ts +6 -0
  66. package/dist/esm/global-account/react/components/ManageAccount/BalanceContent.js +88 -0
  67. package/dist/esm/global-account/react/components/ManageAccount/ContentTokens.d.ts +14 -0
  68. package/dist/esm/global-account/react/components/ManageAccount/ContentTokens.js +266 -0
  69. package/dist/esm/global-account/react/components/ManageAccount/ManageAccount.js +12 -51
  70. package/dist/esm/global-account/react/components/ManageAccount/TokenBalanceRow.d.ts +10 -0
  71. package/dist/esm/global-account/react/components/ManageAccount/TokenBalanceRow.js +5 -0
  72. package/dist/esm/global-account/react/components/TokenIcon.d.ts +11 -0
  73. package/dist/esm/global-account/react/components/TokenIcon.js +37 -0
  74. package/dist/esm/global-account/react/components/ui/accordion.d.ts +7 -0
  75. package/dist/esm/global-account/react/components/ui/accordion.js +14 -0
  76. package/dist/esm/global-account/react/components/ui/dialog.js +1 -1
  77. package/dist/esm/global-account/react/hooks/index.d.ts +2 -0
  78. package/dist/esm/global-account/react/hooks/index.js +2 -0
  79. package/dist/esm/global-account/react/hooks/useAnalytics.d.ts +7 -0
  80. package/dist/esm/global-account/react/hooks/useAnalytics.js +26 -0
  81. package/dist/esm/global-account/react/hooks/useB3BalanceFromAddresses.js +2 -1
  82. package/dist/esm/global-account/react/hooks/useNativeBalance.js +2 -1
  83. package/dist/esm/global-account/react/hooks/useSimBalance.d.ts +24 -0
  84. package/dist/esm/global-account/react/hooks/useSimBalance.js +26 -0
  85. package/dist/esm/global-account/react/hooks/useUnifiedChainSwitchAndExecute.js +2 -1
  86. package/dist/esm/global-account/react/stores/useModalStore.d.ts +2 -2
  87. package/dist/esm/global-account/react/utils/profileDisplay.js +9 -0
  88. package/dist/esm/global-account/utils/analytics.d.ts +16 -0
  89. package/dist/esm/global-account/utils/analytics.js +50 -0
  90. package/dist/esm/shared/constants/index.d.ts +1 -0
  91. package/dist/esm/shared/constants/index.js +1 -0
  92. package/dist/esm/shared/generated/chain-networks.json +185 -17
  93. package/dist/styles/index.css +1 -1
  94. package/dist/types/anyspend/react/components/AnyspendDepositHype.d.ts +4 -0
  95. package/dist/types/anyspend/react/components/common/ChainTokenIcon.d.ts +1 -1
  96. package/dist/types/anyspend/react/components/common/CryptoReceiveSection.d.ts +3 -1
  97. package/dist/types/anyspend/react/components/common/PanelOnramp.d.ts +4 -1
  98. package/dist/types/global-account/react/components/ManageAccount/BalanceContent.d.ts +6 -0
  99. package/dist/types/global-account/react/components/ManageAccount/ContentTokens.d.ts +14 -0
  100. package/dist/types/global-account/react/components/ManageAccount/TokenBalanceRow.d.ts +10 -0
  101. package/dist/types/global-account/react/components/TokenIcon.d.ts +11 -0
  102. package/dist/types/global-account/react/components/ui/accordion.d.ts +7 -0
  103. package/dist/types/global-account/react/hooks/index.d.ts +2 -0
  104. package/dist/types/global-account/react/hooks/useAnalytics.d.ts +7 -0
  105. package/dist/types/global-account/react/hooks/useSimBalance.d.ts +24 -0
  106. package/dist/types/global-account/react/stores/useModalStore.d.ts +2 -2
  107. package/dist/types/global-account/utils/analytics.d.ts +16 -0
  108. package/dist/types/shared/constants/index.d.ts +1 -0
  109. package/package.json +10 -18
  110. package/src/anyspend/react/components/AnySpend.tsx +1 -0
  111. package/src/anyspend/react/components/AnySpendBuySpin.tsx +2 -1
  112. package/src/anyspend/react/components/AnySpendStakeB3.tsx +3 -2
  113. package/src/anyspend/react/components/AnyspendDepositHype.tsx +10 -0
  114. package/src/anyspend/react/components/AnyspendSignatureMint.tsx +4 -4
  115. package/src/anyspend/react/components/common/ChainTokenIcon.tsx +8 -2
  116. package/src/anyspend/react/components/common/CryptoPaymentMethod.tsx +56 -107
  117. package/src/anyspend/react/components/common/CryptoReceiveSection.tsx +12 -3
  118. package/src/anyspend/react/components/common/OrderDetails.tsx +5 -5
  119. package/src/anyspend/react/components/common/OrderTokenAmount.tsx +2 -2
  120. package/src/anyspend/react/components/common/PanelOnramp.tsx +11 -5
  121. package/src/anyspend/react/components/common/PaySection.tsx +1 -1
  122. package/src/global-account/react/components/B3DynamicModal.tsx +8 -7
  123. package/src/global-account/react/components/B3Provider/B3Provider.tsx +6 -0
  124. package/src/global-account/react/components/LinkAccount/LinkAccount.tsx +2 -1
  125. package/src/global-account/react/components/ManageAccount/BalanceContent.tsx +228 -0
  126. package/src/global-account/react/components/ManageAccount/ContentTokens.tsx +568 -0
  127. package/src/global-account/react/components/ManageAccount/ManageAccount.tsx +86 -341
  128. package/src/global-account/react/components/ManageAccount/TokenBalanceRow.tsx +46 -0
  129. package/src/global-account/react/components/TokenIcon.tsx +87 -0
  130. package/src/global-account/react/components/ui/accordion.tsx +53 -0
  131. package/src/global-account/react/components/ui/dialog.tsx +1 -1
  132. package/src/global-account/react/hooks/index.ts +2 -0
  133. package/src/global-account/react/hooks/useAccountAssets.ts +1 -0
  134. package/src/global-account/react/hooks/useAnalytics.tsx +30 -0
  135. package/src/global-account/react/hooks/useB3BalanceFromAddresses.ts +3 -2
  136. package/src/global-account/react/hooks/useNativeBalance.tsx +2 -1
  137. package/src/global-account/react/hooks/useSimBalance.ts +56 -0
  138. package/src/global-account/react/hooks/useUnifiedChainSwitchAndExecute.ts +3 -1
  139. package/src/global-account/react/stores/useModalStore.ts +2 -2
  140. package/src/global-account/react/utils/profileDisplay.ts +9 -0
  141. package/src/global-account/utils/analytics.ts +64 -0
  142. package/src/shared/constants/index.ts +2 -0
  143. package/src/shared/generated/chain-networks.json +185 -17
  144. package/src/{anyspend/types → types}/window.d.ts +5 -1
  145. package/dist/cjs/index.d.ts +0 -0
  146. package/dist/cjs/index.js +0 -2
  147. package/dist/esm/index.d.ts +0 -0
  148. package/dist/esm/index.js +0 -2
  149. package/dist/types/index.d.ts +0 -0
  150. package/src/index.ts +0 -1
@@ -0,0 +1,568 @@
1
+ import { ALL_CHAINS, getExplorerTxUrl } from "@b3dotfun/sdk/anyspend";
2
+ import { ChainTokenIcon } from "@b3dotfun/sdk/anyspend/react/components/common/ChainTokenIcon";
3
+ import {
4
+ Button,
5
+ TransitionPanel,
6
+ useAnalytics,
7
+ useSimBalance,
8
+ useUnifiedChainSwitchAndExecute,
9
+ } from "@b3dotfun/sdk/global-account/react";
10
+ import { formatDisplayNumber, formatTokenAmount } from "@b3dotfun/sdk/shared/utils/number";
11
+ import { ArrowLeft, CircleHelp, Copy, Loader2, Send } from "lucide-react";
12
+ import { useEffect, useMemo, useRef, useState } from "react";
13
+ import { NumericFormat } from "react-number-format";
14
+ import { toast } from "sonner";
15
+ import { useActiveAccount } from "thirdweb/react";
16
+ import { encodeFunctionData, erc20Abi, isAddress, parseUnits } from "viem";
17
+ import { SimBalanceItem } from "../../hooks/useSimBalance";
18
+ import invariant from "invariant";
19
+
20
+ // Panel view enum for managing navigation between token list and send form
21
+ enum TokenPanelView {
22
+ LIST = 0, // Show list of user's tokens
23
+ SEND = 1, // Show send token form
24
+ }
25
+
26
+ interface ContentTokensProps {
27
+ activeTab: string;
28
+ }
29
+
30
+ /**
31
+ * ContentTokens Component
32
+ *
33
+ * Displays user's token balances with ability to send tokens. Features:
34
+ * - Animated transitions between token list and send form
35
+ * - Smart filtering (shows all tokens when ≤5 valuable tokens or no $1+ tokens)
36
+ * - NumericFormat inputs for proper number handling
37
+ * - Focus preservation during transitions (see render functions pattern below)
38
+ */
39
+ export function ContentTokens({ activeTab }: ContentTokensProps) {
40
+ // === TOKEN FILTERING STATE ===
41
+ const [showAllTokens, setShowAllTokens] = useState(false);
42
+
43
+ // === NAVIGATION STATE ===
44
+ const [tokenPanelView, setTokenPanelView] = useState<TokenPanelView>(TokenPanelView.LIST);
45
+
46
+ // === SEND FORM STATE ===
47
+ const [selectedToken, setSelectedToken] = useState<SimBalanceItem | null>(null);
48
+ const [recipientAddress, setRecipientAddress] = useState("");
49
+ const [sendAmount, setSendAmount] = useState("");
50
+ const [isSending, setIsSending] = useState(false);
51
+ const [addressError, setAddressError] = useState("");
52
+
53
+ // === ANIMATION STATE ===
54
+ // CRITICAL: useRef for animation direction prevents component remounting
55
+ // This ensures input focus is preserved during panel transitions
56
+ const animationDirection = useRef<"forward" | "back" | null>(null);
57
+
58
+ // === DATA FETCHING ===
59
+ const account = useActiveAccount();
60
+ const { data: simBalance, refetch: refetchSimBalance, isLoading: isLoadingBalance } = useSimBalance(account?.address);
61
+
62
+ // === BLOCKCHAIN INTERACTION ===
63
+ const { switchChainAndExecute } = useUnifiedChainSwitchAndExecute();
64
+
65
+ // === ANALYTICS ===
66
+ const { sendAnalyticsEvent } = useAnalytics();
67
+
68
+ // === ADDRESS VALIDATION ===
69
+ // Handle recipient address change with real-time validation using viem
70
+ const handleRecipientAddressChange = (value: string) => {
71
+ setRecipientAddress(value);
72
+ // Only show error if user has typed something and it's invalid
73
+ // Using viem's isAddress for robust EVM address validation
74
+ if (value && !isAddress(value)) {
75
+ setAddressError("Please enter a valid EVM address (0x...)");
76
+ } else {
77
+ setAddressError("");
78
+ }
79
+ };
80
+
81
+ // === TAB RESET EFFECT ===
82
+ // Reset all state when user switches away from tokens tab
83
+ useEffect(() => {
84
+ if (activeTab !== "tokens") {
85
+ setTokenPanelView(TokenPanelView.LIST);
86
+ setSelectedToken(null);
87
+ setRecipientAddress("");
88
+ setSendAmount("");
89
+ setIsSending(false);
90
+ setAddressError("");
91
+ animationDirection.current = null;
92
+ }
93
+ }, [activeTab]);
94
+
95
+ // === HELPER FUNCTION ===
96
+ // Get current version of selected token from fresh balance data
97
+ // 🔧 FIX: Prevents auto-navigation back to token list when balance refreshes
98
+ // The useSimBalance hook refreshes data, creating new token object references
99
+ // This helper ensures we always get the fresh token data instead of stale references
100
+ const getCurrentSelectedToken = (): SimBalanceItem | null => {
101
+ if (!selectedToken || !simBalance?.balances) {
102
+ return null;
103
+ }
104
+
105
+ const found = simBalance.balances.find(
106
+ token => token.chain_id === selectedToken.chain_id && token.address === selectedToken.address,
107
+ );
108
+
109
+ return found || null;
110
+ };
111
+
112
+ // ==================================================================================
113
+ // === RENDER FUNCTIONS (NOT COMPONENTS!) ===
114
+ // ==================================================================================
115
+ //
116
+ // 🚨 CRITICAL ARCHITECTURE DECISION:
117
+ // These are render functions, NOT component functions with useCallback!
118
+ //
119
+ // WHY THIS WORKS:
120
+ // ✅ Stable wrapper <div> elements with consistent keys
121
+ // ✅ React never sees these as "new components"
122
+ // ✅ Input focus is preserved during transitions
123
+ // ✅ No component remounting issues
124
+ //
125
+ // WHY useCallback DIDN'T WORK:
126
+ // ❌ useCallback(() => <JSX />, [deps]) still creates new component instances
127
+ // ❌ React treats each render as a different component
128
+ // ❌ Causes remounting and focus loss
129
+ //
130
+ // THE PATTERN:
131
+ // Instead of: {[<ComponentA />, <ComponentB />]}
132
+ // We use: {[<div key="a">{renderA()}</div>, <div key="b">{renderB()}</div>]}
133
+ // ==================================================================================
134
+
135
+ /**
136
+ * Renders the send token form panel
137
+ * Includes recipient input, amount input with NumericFormat, and percentage buttons
138
+ */
139
+ const renderSendTokenPanel = () => {
140
+ // Get fresh token data to prevent stale references
141
+ const currentToken = getCurrentSelectedToken();
142
+
143
+ // 🔧 SINGLE FALLBACK STRATEGY:
144
+ // Use fresh token data when available, fall back to selectedToken if needed
145
+ // This prevents duplication of "currentToken || selectedToken" throughout the component
146
+ const displayToken = currentToken || selectedToken;
147
+
148
+ // Handle percentage button clicks (25%, 50%, 75%, 100%)
149
+ const handlePercentageClick = (percentage: number) => {
150
+ if (displayToken) {
151
+ const tokenBalance = (BigInt(displayToken.amount) * BigInt(percentage)) / BigInt(100);
152
+ const amount = formatTokenAmount(tokenBalance, displayToken.decimals, 30, false);
153
+ setSendAmount(amount);
154
+ }
155
+ };
156
+
157
+ // Execute token transfer transaction
158
+ const handleSend = async () => {
159
+ if (!displayToken || !recipientAddress || !sendAmount || parseFloat(sendAmount) <= 0) {
160
+ return;
161
+ }
162
+
163
+ setIsSending(true);
164
+
165
+ const amountInWei = parseUnits(sendAmount, displayToken.decimals);
166
+
167
+ // Prepare analytics event data
168
+ const analyticsData = {
169
+ amount: sendAmount,
170
+ symbol: displayToken.symbol,
171
+ chain_id: displayToken.chain_id,
172
+ address: displayToken.address,
173
+ };
174
+
175
+ try {
176
+ invariant(isAddress(recipientAddress), "Recipient address is not a valid address");
177
+
178
+ const sendTokenData = encodeFunctionData({
179
+ abi: erc20Abi,
180
+ functionName: "transfer",
181
+ args: [recipientAddress, amountInWei],
182
+ });
183
+
184
+ const tx = await switchChainAndExecute(displayToken.chain_id, {
185
+ to: displayToken.address === "native" ? recipientAddress : displayToken.address,
186
+ data: sendTokenData,
187
+ value: displayToken.address === "native" ? amountInWei : BigInt(0),
188
+ });
189
+
190
+ if (tx) {
191
+ // Track successful send
192
+ sendAnalyticsEvent("send_token_button_click", {
193
+ ...analyticsData,
194
+ success: true,
195
+ tx: getExplorerTxUrl(displayToken.chain_id, tx),
196
+ });
197
+
198
+ // Reset form
199
+ setSendAmount("");
200
+ }
201
+ } catch (error: any) {
202
+ // Track failed send
203
+ sendAnalyticsEvent("send_token_button_click", {
204
+ ...analyticsData,
205
+ success: false,
206
+ reason: error.message || "Unknown error",
207
+ });
208
+
209
+ // Error
210
+ toast.error(`Failed to send ${displayToken.symbol}: ${error.message || "Unknown error"}`);
211
+ } finally {
212
+ // Wait 1 second to make sure the tx is indexed on sim api.
213
+ setTimeout(async () => {
214
+ // Force refetch to bypass cache and get fresh balance data
215
+ await refetchSimBalance();
216
+ }, 1000);
217
+ setIsSending(false);
218
+ }
219
+ };
220
+
221
+ // Show loading state only if no token data is available at all
222
+ if (!displayToken) {
223
+ return (
224
+ <div className="flex flex-col items-center justify-center py-12 text-center">
225
+ <div className="bg-b3-line/50 mb-4 rounded-full p-4">
226
+ <Loader2 className="text-b3-foreground-muted h-8 w-8 animate-spin" />
227
+ </div>
228
+ <h3 className="text-b3-grey font-neue-montreal-semibold mb-2">Loading token data...</h3>
229
+ <p className="text-b3-foreground-muted font-neue-montreal-medium text-sm">
230
+ Please wait while we fetch the latest information
231
+ </p>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ return (
237
+ <div className="space-y-6">
238
+ {/* Header */}
239
+ <div className="flex items-center gap-3">
240
+ <Button
241
+ variant="ghost"
242
+ size="icon"
243
+ onClick={() => {
244
+ animationDirection.current = "back";
245
+ setTokenPanelView(TokenPanelView.LIST);
246
+ }}
247
+ className="hover:bg-b3-line/60"
248
+ disabled={isSending}
249
+ >
250
+ <ArrowLeft className="h-5 w-5" />
251
+ </Button>
252
+ <div className="flex items-center gap-3">
253
+ <div className="flex h-8 w-8 items-center justify-center">
254
+ {ALL_CHAINS[displayToken.chain_id]?.logoUrl ? (
255
+ <ChainTokenIcon
256
+ chainUrl={ALL_CHAINS[displayToken.chain_id].logoUrl}
257
+ tokenUrl={displayToken.token_metadata?.logo}
258
+ className="size-8"
259
+ />
260
+ ) : (
261
+ <CircleHelp className="text-b3-react-foreground size-8" />
262
+ )}
263
+ </div>
264
+ <h2 className="text-b3-grey font-neue-montreal-semibold text-lg">Send {displayToken.symbol}</h2>
265
+ </div>
266
+ </div>
267
+
268
+ {/* Recipient Address */}
269
+ <div className="space-y-2">
270
+ <label className="text-b3-grey font-neue-montreal-medium text-sm">Recipient Address</label>
271
+ <div className="space-y-1">
272
+ <div className="relative">
273
+ <input
274
+ type="text"
275
+ value={recipientAddress}
276
+ onChange={e => handleRecipientAddressChange(e.target.value)}
277
+ placeholder="Enter wallet address (0x...)"
278
+ className={`border-b3-line bg-b3-background text-b3-grey font-neue-montreal-medium placeholder:text-b3-foreground-muted w-full rounded-xl border px-4 py-3 pr-12 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
279
+ addressError ? "border-red-500 focus:border-red-500" : "focus:border-b3-primary-blue"
280
+ }`}
281
+ disabled={isSending}
282
+ />
283
+ <Button
284
+ variant="ghost"
285
+ size="icon"
286
+ className="hover:bg-b3-line/60 absolute right-2 top-1/2 h-8 w-8 -translate-y-1/2"
287
+ disabled={isSending}
288
+ onClick={() => {
289
+ navigator.clipboard.readText().then(text => {
290
+ handleRecipientAddressChange(text);
291
+ });
292
+ }}
293
+ >
294
+ <Copy className="h-4 w-4" />
295
+ </Button>
296
+ </div>
297
+ {addressError && <p className="font-neue-montreal-medium text-xs text-red-500">{addressError}</p>}
298
+ </div>
299
+ </div>
300
+
301
+ {/* Amount */}
302
+ <div className="space-y-2">
303
+ <label className="text-b3-grey font-neue-montreal-medium text-sm">Amount</label>
304
+ <div className="space-y-3">
305
+ <NumericFormat
306
+ decimalSeparator="."
307
+ allowedDecimalSeparators={[","]}
308
+ thousandSeparator
309
+ inputMode="decimal"
310
+ autoComplete="off"
311
+ autoCorrect="off"
312
+ type="text"
313
+ placeholder="0.00"
314
+ minLength={1}
315
+ maxLength={30}
316
+ spellCheck="false"
317
+ className="border-b3-line bg-b3-background text-b3-grey font-neue-montreal-medium placeholder:text-b3-foreground-muted focus:border-b3-primary-blue w-full rounded-xl border px-4 py-3 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
318
+ pattern="^[0-9]*[.,]?[0-9]*$"
319
+ disabled={isSending}
320
+ value={sendAmount}
321
+ allowNegative={false}
322
+ onChange={e => setSendAmount(e.currentTarget.value)}
323
+ />
324
+
325
+ {/* Percentage buttons */}
326
+ <div className="grid grid-cols-4 gap-2">
327
+ {[25, 50, 75, 100].map(percentage => (
328
+ <Button
329
+ key={percentage}
330
+ variant="outline"
331
+ onClick={() => handlePercentageClick(percentage)}
332
+ className="hover:bg-b3-primary-wash border-b3-line text-b3-grey font-neue-montreal-medium text-sm"
333
+ disabled={isSending}
334
+ >
335
+ {percentage}%
336
+ </Button>
337
+ ))}
338
+ </div>
339
+
340
+ {/* Available balance */}
341
+ <div className="text-b3-foreground-muted font-neue-montreal-medium text-sm">
342
+ Available: {formatTokenAmount(BigInt(displayToken.amount), displayToken.decimals)} {displayToken.symbol}
343
+ </div>
344
+ </div>
345
+ </div>
346
+
347
+ {/* Send Button */}
348
+ <Button
349
+ onClick={handleSend}
350
+ disabled={
351
+ !recipientAddress ||
352
+ !sendAmount ||
353
+ parseFloat(sendAmount) <= 0 ||
354
+ isSending ||
355
+ !!addressError ||
356
+ !isAddress(recipientAddress)
357
+ }
358
+ className="bg-b3-primary-blue hover:bg-b3-primary-blue/90 font-neue-montreal-semibold disabled:bg-b3-line disabled:text-b3-foreground-muted w-full rounded-xl py-3 text-white"
359
+ >
360
+ {isSending ? (
361
+ <>
362
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
363
+ Sending...
364
+ </>
365
+ ) : (
366
+ <>
367
+ <Send className="mr-2 h-4 w-4" />
368
+ Send {displayToken.symbol}
369
+ </>
370
+ )}
371
+ </Button>
372
+ </div>
373
+ );
374
+ };
375
+
376
+ // Skeleton loading component for token list
377
+ const LoadingIndicator = () => (
378
+ <div className="space-y-4">
379
+ <div className="space-y-1">
380
+ {[...Array(3)].map((_, index) => (
381
+ <div key={index} className="flex items-center justify-between rounded-xl p-3">
382
+ <div className="flex items-center gap-3">
383
+ <div className="bg-b3-line h-10 w-10 animate-pulse rounded-full"></div>
384
+ <div>
385
+ <div className="bg-b3-line mb-1 h-4 w-16 animate-pulse rounded"></div>
386
+ <div className="bg-b3-line h-3 w-24 animate-pulse rounded"></div>
387
+ </div>
388
+ </div>
389
+ <div className="text-right">
390
+ <div className="bg-b3-line mb-1 h-4 w-20 animate-pulse rounded"></div>
391
+ <div className="bg-b3-line h-3 w-16 animate-pulse rounded"></div>
392
+ </div>
393
+ </div>
394
+ ))}
395
+ </div>
396
+ </div>
397
+ );
398
+
399
+ /**
400
+ * Renders the token list panel with smart filtering
401
+ * Features intelligent token display logic to reduce noise while ensuring visibility
402
+ */
403
+ const renderTokenListPanel = () => {
404
+ // Show loading indicator when balance is loading
405
+ if (isLoadingBalance) {
406
+ return <LoadingIndicator />;
407
+ }
408
+
409
+ // Show empty state when no account or no balance data
410
+ if (!account?.address || !simBalance) {
411
+ return (
412
+ <div className="flex flex-col items-center justify-center py-12 text-center">
413
+ <div className="bg-b3-line/50 mb-4 rounded-full p-4">
414
+ <Loader2 className="text-b3-foreground-muted h-8 w-8" />
415
+ </div>
416
+ <h3 className="text-b3-grey font-neue-montreal-semibold mb-2">No wallet connected</h3>
417
+ <p className="text-b3-foreground-muted font-neue-montreal-medium text-sm">
418
+ Connect your wallet to view token balances
419
+ </p>
420
+ </div>
421
+ );
422
+ }
423
+
424
+ // === SMART FILTERING LOGIC ===
425
+ // Filter tokens with value >= $1 to reduce noise from dust tokens
426
+ const filteredTokens =
427
+ simBalance?.balances.filter(token => token.value_usd !== undefined && token.value_usd >= 1) || [];
428
+
429
+ // 🧠 INTELLIGENT DISPLAY LOGIC:
430
+ // Show all tokens automatically when filtering would be unhelpful:
431
+ // 1. User explicitly requested to show all, OR
432
+ // 2. No tokens with value >= $1 (user has only dust), OR
433
+ // 3. 5 or fewer tokens with value >= $1 (not enough to warrant filtering)
434
+ const shouldShowAllTokens = showAllTokens || filteredTokens.length === 0 || filteredTokens.length <= 5;
435
+ const tokensToShow = shouldShowAllTokens ? simBalance?.balances || [] : filteredTokens;
436
+ const hasHiddenTokens = !shouldShowAllTokens && (simBalance?.balances.length || 0) > filteredTokens.length;
437
+
438
+ // Handle token selection and navigate to send form
439
+ const handleTokenClick = (token: SimBalanceItem) => {
440
+ setSelectedToken(token);
441
+ animationDirection.current = "forward"; // Set animation direction BEFORE state change
442
+ setTokenPanelView(TokenPanelView.SEND);
443
+ // Reset form when selecting a new token
444
+ setRecipientAddress("");
445
+ setSendAmount("");
446
+ setIsSending(false);
447
+ setAddressError("");
448
+ };
449
+
450
+ // Show empty state when no tokens are available
451
+ if (tokensToShow.length === 0) {
452
+ return (
453
+ <div className="flex flex-col items-center justify-center py-12 text-center">
454
+ <div className="bg-b3-line/50 mb-4 rounded-full p-4">
455
+ <CircleHelp className="text-b3-foreground-muted h-8 w-8" />
456
+ </div>
457
+ <h3 className="text-b3-grey font-neue-montreal-semibold mb-2">No tokens found</h3>
458
+ <p className="text-b3-foreground-muted font-neue-montreal-medium text-sm">
459
+ No token balances found in your wallet.
460
+ </p>
461
+ </div>
462
+ );
463
+ }
464
+
465
+ return (
466
+ <div className="space-y-4">
467
+ <div className="space-y-1">
468
+ {tokensToShow.map(token => (
469
+ <div
470
+ key={token.chain_id + "_" + token.address}
471
+ className="hover:bg-b3-line/60 dark:hover:bg-b3-primary-wash/40 group flex cursor-pointer items-center justify-between rounded-xl p-3 transition-all duration-200"
472
+ onClick={() => handleTokenClick(token)}
473
+ >
474
+ <div className="flex items-center gap-3">
475
+ <div className="flex h-10 w-10 items-center justify-center rounded-full">
476
+ {ALL_CHAINS[token.chain_id]?.logoUrl ? (
477
+ <ChainTokenIcon
478
+ chainUrl={ALL_CHAINS[token.chain_id].logoUrl}
479
+ tokenUrl={token.token_metadata?.logo}
480
+ className="size-10"
481
+ />
482
+ ) : (
483
+ <CircleHelp className="text-b3-react-foreground size-10" />
484
+ )}
485
+ </div>
486
+ <div>
487
+ <div className="flex items-center gap-2">
488
+ <span className="text-b3-grey font-neue-montreal-semibold transition-colors duration-200 group-hover:font-bold group-hover:text-black">
489
+ {token.symbol}
490
+ </span>
491
+ </div>
492
+ <div className="text-b3-foreground-muted font-neue-montreal-medium text-sm transition-colors duration-200 group-hover:text-gray-700">
493
+ {token.name}
494
+ </div>
495
+ </div>
496
+ </div>
497
+ <div className="text-right">
498
+ <div className="text-b3-grey font-neue-montreal-semibold transition-colors duration-200 group-hover:font-bold group-hover:text-black">
499
+ {formatTokenAmount(BigInt(token.amount), token.decimals)}
500
+ </div>
501
+ <div className="text-b3-foreground-muted font-neue-montreal-medium text-sm transition-colors duration-200 group-hover:text-gray-700">
502
+ {formatDisplayNumber(token.value_usd, { style: "currency", fractionDigits: 2 })}
503
+ </div>
504
+ </div>
505
+ </div>
506
+ ))}
507
+ </div>
508
+
509
+ {hasHiddenTokens && !showAllTokens && (
510
+ <div className="flex justify-center">
511
+ <Button
512
+ variant="ghost"
513
+ className="text-b3-primary-blue hover:text-b3-primary-blue/80 font-neue-montreal-semibold text-sm"
514
+ onClick={() => setShowAllTokens(true)}
515
+ >
516
+ Show more
517
+ </Button>
518
+ </div>
519
+ )}
520
+ </div>
521
+ );
522
+ };
523
+
524
+ // === ANIMATION CONFIGURATION ===
525
+ // Memoize variants to prevent re-creation and unwanted re-renders
526
+ const variants = useMemo(
527
+ () => ({
528
+ enter: (direction: "forward" | "back" | null) => ({
529
+ x: direction === "back" ? -300 : 300, // Back: slide from left, Forward: slide from right
530
+ opacity: 0,
531
+ }),
532
+ center: { x: 0, opacity: 1 }, // Final position: centered and visible
533
+ exit: (direction: "forward" | "back" | null) => ({
534
+ x: direction === "back" ? 300 : -300, // Back: slide to right, Forward: slide to left
535
+ opacity: 0,
536
+ }),
537
+ }),
538
+ [],
539
+ );
540
+
541
+ // Memoize transition config for consistent spring animation
542
+ const transition = useMemo(
543
+ () => ({
544
+ type: "spring" as const,
545
+ stiffness: 300, // Spring tension
546
+ damping: 30, // Spring damping
547
+ }),
548
+ [],
549
+ );
550
+
551
+ return (
552
+ <TransitionPanel
553
+ activeIndex={tokenPanelView}
554
+ className="min-h-[400px]"
555
+ custom={animationDirection.current} // Pass direction to functional variants
556
+ variants={variants}
557
+ transition={transition}
558
+ >
559
+ {/*
560
+ 🎯 THE STABLE ELEMENT PATTERN:
561
+ Using stable <div> wrappers with consistent keys ensures React never remounts
562
+ the containers, preserving input focus during animations.
563
+ The content inside can change freely via render functions.
564
+ */}
565
+ {[<div key="token-list">{renderTokenListPanel()}</div>, <div key="send-token">{renderSendTokenPanel()}</div>]}
566
+ </TransitionPanel>
567
+ );
568
+ }