@nonnux/world-swap 0.1.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.
package/src/client.js ADDED
@@ -0,0 +1,851 @@
1
+ 'use client';
2
+
3
+ // @nonnux/world-swap
4
+ // Drop-in swap card for World App mini apps. Tailwind-styled.
5
+ //
6
+ // Usage:
7
+ // import { SwapCard } from '@nonnux/world-swap';
8
+ // <SwapCard walletUser={user} Button={Button} />
9
+ //
10
+ // All config is optional and falls back to the nonnux / World Chain defaults.
11
+ // Requires a quote API route (see @nonnux/world-swap/server) mounted at
12
+ // `${apiBasePath}/quote` (default: /api/swap/quote).
13
+
14
+ import { useState, useEffect, useCallback } from 'react';
15
+ import { MiniKit } from '@worldcoin/minikit-js';
16
+ import { ethers } from 'ethers';
17
+
18
+ import {
19
+ DEFAULT_TOKENS,
20
+ DEFAULT_CONTRACTS,
21
+ DEFAULT_RPC_URL,
22
+ DEFAULT_EXPLORER_URL,
23
+ } from './defaults.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // ABIs
27
+ // ---------------------------------------------------------------------------
28
+ const SWAP_ABI_DIRECT = [
29
+ {
30
+ inputs: [
31
+ { internalType: 'address', name: 'tokenIn', type: 'address' },
32
+ { internalType: 'address', name: 'tokenOut', type: 'address' },
33
+ { internalType: 'uint256', name: 'amountIn', type: 'uint256' },
34
+ { internalType: 'uint256', name: 'minAmountOut', type: 'uint256' },
35
+ ],
36
+ name: 'executeSwap',
37
+ outputs: [],
38
+ stateMutability: 'nonpayable',
39
+ type: 'function',
40
+ },
41
+ ];
42
+
43
+ const SWAP_ABI_V3 = [
44
+ {
45
+ inputs: [
46
+ { internalType: 'address', name: 'tokenIn', type: 'address' },
47
+ { internalType: 'address', name: 'tokenOut', type: 'address' },
48
+ { internalType: 'uint256', name: 'amountIn', type: 'uint256' },
49
+ { internalType: 'uint256', name: 'minAmountOut', type: 'uint256' },
50
+ { internalType: 'uint24', name: 'poolFee', type: 'uint24' },
51
+ ],
52
+ name: 'executeSwap',
53
+ outputs: [{ internalType: 'uint256', name: 'amountOut', type: 'uint256' }],
54
+ stateMutability: 'nonpayable',
55
+ type: 'function',
56
+ },
57
+ ];
58
+
59
+ const SWAP_ABI_MULTIHOP = [
60
+ {
61
+ inputs: [
62
+ { internalType: 'address[]', name: 'path', type: 'address[]' },
63
+ { internalType: 'uint256', name: 'amountIn', type: 'uint256' },
64
+ { internalType: 'uint256', name: 'minAmountOut', type: 'uint256' },
65
+ ],
66
+ name: 'executeSwapMultiHop',
67
+ outputs: [],
68
+ stateMutability: 'nonpayable',
69
+ type: 'function',
70
+ },
71
+ ];
72
+
73
+ const SWAP_ABI_ROUTER = [
74
+ {
75
+ inputs: [
76
+ { internalType: 'address', name: 'tokenIn', type: 'address' },
77
+ { internalType: 'address', name: 'tokenOut', type: 'address' },
78
+ { internalType: 'uint256', name: 'amountIn', type: 'uint256' },
79
+ { internalType: 'uint256', name: 'minAmountOut', type: 'uint256' },
80
+ { internalType: 'uint24', name: 'v3FeeTierOverride', type: 'uint24' },
81
+ ],
82
+ name: 'swap',
83
+ outputs: [{ internalType: 'uint256', name: 'amountOut', type: 'uint256' }],
84
+ stateMutability: 'nonpayable',
85
+ type: 'function',
86
+ },
87
+ ];
88
+
89
+ const TRANSFER_ABI = [
90
+ {
91
+ inputs: [
92
+ { internalType: 'address', name: 'to', type: 'address' },
93
+ { internalType: 'uint256', name: 'amount', type: 'uint256' },
94
+ ],
95
+ name: 'transfer',
96
+ outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
97
+ stateMutability: 'nonpayable',
98
+ type: 'function',
99
+ },
100
+ ];
101
+
102
+ const ERC20_ABI = ['function balanceOf(address) view returns (uint256)'];
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Storage helpers
106
+ // ---------------------------------------------------------------------------
107
+ const loadFromStorage = (key, defaultValue) => {
108
+ if (typeof window === 'undefined') return defaultValue;
109
+ try {
110
+ const stored = localStorage.getItem(key);
111
+ if (stored === null) return defaultValue;
112
+ return JSON.parse(stored);
113
+ } catch {
114
+ return defaultValue;
115
+ }
116
+ };
117
+
118
+ const saveToStorage = (key, value) => {
119
+ if (typeof window === 'undefined') return;
120
+ try {
121
+ localStorage.setItem(key, JSON.stringify(value));
122
+ } catch {
123
+ /* ignore storage errors */
124
+ }
125
+ };
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Balance formatting
129
+ // ---------------------------------------------------------------------------
130
+ const formatBalance = (balance) => {
131
+ const num = parseFloat(balance);
132
+ if (isNaN(num) || num === 0) return '0';
133
+
134
+ if (num >= 1000000) {
135
+ const val = num / 1000000;
136
+ if (val >= 100) return val.toFixed(0) + 'M';
137
+ if (val >= 10) return val.toFixed(1) + 'M';
138
+ return val.toFixed(2) + 'M';
139
+ }
140
+
141
+ if (num >= 1000) {
142
+ const val = num / 1000;
143
+ if (val >= 100) return val.toFixed(0) + 'k';
144
+ if (val >= 10) return val.toFixed(1) + 'k';
145
+ return val.toFixed(2) + 'k';
146
+ }
147
+
148
+ if (num >= 100) return num.toFixed(0);
149
+ if (num >= 10) return num.toFixed(1);
150
+ if (num >= 1) return num.toFixed(2);
151
+
152
+ const log = Math.floor(Math.log10(Math.abs(num)));
153
+ const decimals = Math.min(-log + 1, 18);
154
+ return num.toFixed(decimals);
155
+ };
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Inline icons (no external icon dependency)
159
+ // ---------------------------------------------------------------------------
160
+ const ShuffleIcon = ({ className }) => (
161
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
162
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
163
+ </svg>
164
+ );
165
+
166
+ const TokenIcon = ({ token, size = 24, ImageComponent }) => {
167
+ const [imgError, setImgError] = useState(false);
168
+
169
+ useEffect(() => {
170
+ setImgError(false);
171
+ }, [token.symbol]);
172
+
173
+ if (imgError || !token.icon) {
174
+ return <span style={{ fontSize: size * 0.9 }}>{token.emoji || '🪙'}</span>;
175
+ }
176
+
177
+ const Img = ImageComponent || 'img';
178
+
179
+ return (
180
+ <Img
181
+ key={token.symbol}
182
+ src={token.icon}
183
+ alt={token.symbol}
184
+ width={size}
185
+ height={size}
186
+ className="rounded-full object-cover"
187
+ onError={() => setImgError(true)}
188
+ />
189
+ );
190
+ };
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Main component
194
+ // ---------------------------------------------------------------------------
195
+ /**
196
+ * @param {Object} props
197
+ * @param {Object} props.walletUser Connected user ({ address }). Null = "connect wallet" state.
198
+ * @param {Function} props.Button Button component to render the swap CTA.
199
+ * @param {Array} [props.tokens] Token list (defaults to nonnux ecosystem).
200
+ * @param {Object} [props.contracts] Contract address overrides (merged with defaults).
201
+ * @param {string} [props.rpcUrl] JSON-RPC URL for balance reads.
202
+ * @param {string} [props.apiBasePath] Base path of the quote API (default /api/swap).
203
+ * @param {string} [props.explorerUrl] Block explorer base URL.
204
+ * @param {*} [props.ImageComponent] Image component (e.g. next/image). Defaults to <img>.
205
+ * @param {string} [props.storagePrefix] localStorage key prefix (avoid cross-app collisions).
206
+ * @param {string} [props.title] Header title (default "Swap").
207
+ */
208
+ export function SwapCard({
209
+ walletUser,
210
+ Button,
211
+ tokens = DEFAULT_TOKENS,
212
+ contracts: contractsProp,
213
+ rpcUrl = DEFAULT_RPC_URL,
214
+ apiBasePath = '/api/swap',
215
+ explorerUrl = DEFAULT_EXPLORER_URL,
216
+ ImageComponent,
217
+ storagePrefix = 'world_swap',
218
+ title = 'Swap',
219
+ }) {
220
+ const contracts = { ...DEFAULT_CONTRACTS, ...(contractsProp || {}) };
221
+ const SUPPORTED_TOKENS = tokens;
222
+
223
+ const STORAGE_KEYS = {
224
+ SLIPPAGE: `${storagePrefix}_slippage`,
225
+ TOKEN_IN: `${storagePrefix}_token_in`,
226
+ TOKEN_OUT: `${storagePrefix}_token_out`,
227
+ };
228
+
229
+ const [tokenInIndex, setTokenInIndex] = useState(0);
230
+ const [tokenOutIndex, setTokenOutIndex] = useState(1);
231
+ const [slippage, setSlippage] = useState(5);
232
+ const [isInitialized, setIsInitialized] = useState(false);
233
+
234
+ const [showTokenInDropdown, setShowTokenInDropdown] = useState(false);
235
+ const [showTokenOutDropdown, setShowTokenOutDropdown] = useState(false);
236
+ const [amountIn, setAmountIn] = useState('');
237
+ const [isLoading, setIsLoading] = useState(false);
238
+ const [isLoadingQuote, setIsLoadingQuote] = useState(false);
239
+ const [isLoadingBalance, setIsLoadingBalance] = useState(false);
240
+ const [swapResult, setSwapResult] = useState(null);
241
+ const [quote, setQuote] = useState(null);
242
+ const [showSettings, setShowSettings] = useState(false);
243
+ const [tokenInBalance, setTokenInBalance] = useState('0');
244
+
245
+ const tokenIn = SUPPORTED_TOKENS[tokenInIndex];
246
+ const tokenOut = SUPPORTED_TOKENS[tokenOutIndex];
247
+
248
+ const tokenInVersion = tokenIn.poolVersion || 'v2';
249
+ const tokenOutVersion = tokenOut.poolVersion || 'v2';
250
+
251
+ // Special case: tokens that share a direct V3 pool with WLD.
252
+ const isDirectV3WithWLD =
253
+ (tokenIn.symbol === 'SUSHI' && tokenOut.symbol === 'WLD') ||
254
+ (tokenIn.symbol === 'WLD' && tokenOut.symbol === 'SUSHI');
255
+
256
+ // Pick the contract + mode for this pair.
257
+ let SWAP_CONTRACT_ADDRESS;
258
+ let swapMode; // 'v2' | 'v3' | 'router'
259
+
260
+ if (isDirectV3WithWLD) {
261
+ SWAP_CONTRACT_ADDRESS = contracts.swapV3;
262
+ swapMode = 'v3';
263
+ } else if (tokenInVersion === 'v3' || tokenOutVersion === 'v3') {
264
+ if (contracts.swapRouter && contracts.swapRouter !== '0x0000000000000000000000000000000000000000') {
265
+ SWAP_CONTRACT_ADDRESS = contracts.swapRouter;
266
+ swapMode = 'router';
267
+ } else {
268
+ SWAP_CONTRACT_ADDRESS = contracts.swapV3;
269
+ swapMode = 'v3';
270
+ }
271
+ } else {
272
+ SWAP_CONTRACT_ADDRESS = contracts.swapV2;
273
+ swapMode = 'v2';
274
+ }
275
+
276
+ SWAP_CONTRACT_ADDRESS = ethers.getAddress(SWAP_CONTRACT_ADDRESS);
277
+
278
+ // -------------------------------------------------------------------------
279
+ // Load persisted prefs
280
+ // -------------------------------------------------------------------------
281
+ useEffect(() => {
282
+ const savedSlippage = loadFromStorage(STORAGE_KEYS.SLIPPAGE, 5);
283
+ const savedTokenIn = loadFromStorage(STORAGE_KEYS.TOKEN_IN, 0);
284
+ const savedTokenOut = loadFromStorage(STORAGE_KEYS.TOKEN_OUT, 1);
285
+
286
+ const validTokenIn = savedTokenIn >= 0 && savedTokenIn < SUPPORTED_TOKENS.length ? savedTokenIn : 0;
287
+ const validTokenOut = savedTokenOut >= 0 && savedTokenOut < SUPPORTED_TOKENS.length ? savedTokenOut : 1;
288
+
289
+ setSlippage(savedSlippage);
290
+ setTokenInIndex(validTokenIn);
291
+ setTokenOutIndex(validTokenOut !== validTokenIn ? validTokenOut : validTokenIn === 0 ? 1 : 0);
292
+ setIsInitialized(true);
293
+ // eslint-disable-next-line react-hooks/exhaustive-deps
294
+ }, []);
295
+
296
+ useEffect(() => {
297
+ if (isInitialized) saveToStorage(STORAGE_KEYS.SLIPPAGE, slippage);
298
+ // eslint-disable-next-line react-hooks/exhaustive-deps
299
+ }, [slippage, isInitialized]);
300
+
301
+ useEffect(() => {
302
+ if (isInitialized) saveToStorage(STORAGE_KEYS.TOKEN_IN, tokenInIndex);
303
+ // eslint-disable-next-line react-hooks/exhaustive-deps
304
+ }, [tokenInIndex, isInitialized]);
305
+
306
+ useEffect(() => {
307
+ if (isInitialized) saveToStorage(STORAGE_KEYS.TOKEN_OUT, tokenOutIndex);
308
+ // eslint-disable-next-line react-hooks/exhaustive-deps
309
+ }, [tokenOutIndex, isInitialized]);
310
+
311
+ // -------------------------------------------------------------------------
312
+ // Load balance
313
+ // -------------------------------------------------------------------------
314
+ const loadBalance = useCallback(async () => {
315
+ if (!walletUser?.address || !isInitialized) return;
316
+ setIsLoadingBalance(true);
317
+ try {
318
+ const provider = new ethers.JsonRpcProvider(rpcUrl);
319
+ const contract = new ethers.Contract(tokenIn.address, ERC20_ABI, provider);
320
+ const balance = await contract.balanceOf(walletUser.address);
321
+ setTokenInBalance(ethers.formatUnits(balance, tokenIn.decimals));
322
+ } catch (error) {
323
+ console.error('[world-swap] Balance error:', error);
324
+ setTokenInBalance('0');
325
+ } finally {
326
+ setIsLoadingBalance(false);
327
+ }
328
+ }, [walletUser, tokenIn, isInitialized, rpcUrl]);
329
+
330
+ useEffect(() => {
331
+ loadBalance();
332
+ }, [loadBalance]);
333
+
334
+ // -------------------------------------------------------------------------
335
+ // Fetch quote
336
+ // -------------------------------------------------------------------------
337
+ const fetchQuote = useCallback(async () => {
338
+ if (!amountIn || parseFloat(amountIn) <= 0) {
339
+ setQuote(null);
340
+ return;
341
+ }
342
+
343
+ setIsLoadingQuote(true);
344
+
345
+ try {
346
+ const response = await fetch(`${apiBasePath}/quote`, {
347
+ method: 'POST',
348
+ headers: { 'Content-Type': 'application/json' },
349
+ body: JSON.stringify({
350
+ token_in: tokenIn.symbol,
351
+ token_out: tokenOut.symbol,
352
+ token_in_address: tokenIn.address,
353
+ token_out_address: tokenOut.address,
354
+ amount_in: parseFloat(amountIn),
355
+ }),
356
+ });
357
+
358
+ const result = await response.json();
359
+
360
+ if (result.success) {
361
+ setQuote(result.quote);
362
+ setSwapResult(null);
363
+ } else {
364
+ setQuote(null);
365
+ if (result.error === 'NO_LIQUIDITY') {
366
+ setSwapResult({
367
+ success: false,
368
+ message: `⚠️ No liquidity for ${tokenIn.symbol}/${tokenOut.symbol}`,
369
+ });
370
+ }
371
+ }
372
+ } catch (error) {
373
+ console.error('[world-swap] Quote error:', error);
374
+ setQuote(null);
375
+ } finally {
376
+ setIsLoadingQuote(false);
377
+ }
378
+ }, [amountIn, tokenIn, tokenOut, apiBasePath]);
379
+
380
+ useEffect(() => {
381
+ const timer = setTimeout(() => {
382
+ if (amountIn && parseFloat(amountIn) > 0) fetchQuote();
383
+ }, 500);
384
+ return () => clearTimeout(timer);
385
+ }, [amountIn, tokenIn, tokenOut, fetchQuote]);
386
+
387
+ // -------------------------------------------------------------------------
388
+ // Swap transaction
389
+ // -------------------------------------------------------------------------
390
+ const handleSwap = async () => {
391
+ if (!walletUser?.address || !amountIn || parseFloat(amountIn) <= 0) {
392
+ setSwapResult({ success: false, message: 'Please enter a valid amount' });
393
+ return;
394
+ }
395
+
396
+ if (!quote) {
397
+ setSwapResult({ success: false, message: 'Please wait for price quote' });
398
+ return;
399
+ }
400
+
401
+ if (parseFloat(amountIn) > parseFloat(tokenInBalance)) {
402
+ setSwapResult({
403
+ success: false,
404
+ message: `Insufficient balance. You have ${formatBalance(tokenInBalance)} ${tokenIn.symbol}`,
405
+ });
406
+ return;
407
+ }
408
+
409
+ setIsLoading(true);
410
+ setSwapResult(null);
411
+
412
+ try {
413
+ const amountInWei = ethers.parseUnits(amountIn.toString(), tokenIn.decimals);
414
+
415
+ const externalTax = tokenIn.externalTaxPercent || 0;
416
+ const effectiveSlippage = slippage + externalTax;
417
+ const minAmountOut = quote.amount_out * (1 - effectiveSlippage / 100);
418
+ const minAmountOutWei = ethers.parseUnits(minAmountOut.toFixed(tokenOut.decimals), tokenOut.decimals);
419
+
420
+ const tokenInAddress = ethers.getAddress(tokenIn.address);
421
+ const tokenOutAddress = ethers.getAddress(tokenOut.address);
422
+
423
+ const isMultiHop = quote.route_type === 'multi-hop' && quote.path && quote.path.length > 2;
424
+
425
+ let swapTransaction;
426
+
427
+ if (isMultiHop && swapMode === 'v2') {
428
+ const pathAddresses = quote.path.map((addr) => ethers.getAddress(addr));
429
+ swapTransaction = {
430
+ address: SWAP_CONTRACT_ADDRESS,
431
+ abi: SWAP_ABI_MULTIHOP,
432
+ functionName: 'executeSwapMultiHop',
433
+ args: [pathAddresses, amountInWei.toString(), minAmountOutWei.toString()],
434
+ };
435
+ } else if (swapMode === 'router') {
436
+ const poolFee = quote.v3_fee_tier || tokenIn.v3FeeTier || tokenOut.v3FeeTier || 10000;
437
+ swapTransaction = {
438
+ address: SWAP_CONTRACT_ADDRESS,
439
+ abi: SWAP_ABI_ROUTER,
440
+ functionName: 'swap',
441
+ args: [tokenInAddress, tokenOutAddress, amountInWei.toString(), minAmountOutWei.toString(), poolFee],
442
+ };
443
+ } else if (swapMode === 'v3') {
444
+ const poolFee = quote.v3_fee_tier || tokenIn.v3FeeTier || tokenOut.v3FeeTier || 10000;
445
+ swapTransaction = {
446
+ address: SWAP_CONTRACT_ADDRESS,
447
+ abi: SWAP_ABI_V3,
448
+ functionName: 'executeSwap',
449
+ args: [tokenInAddress, tokenOutAddress, amountInWei.toString(), minAmountOutWei.toString(), poolFee],
450
+ };
451
+ } else {
452
+ swapTransaction = {
453
+ address: SWAP_CONTRACT_ADDRESS,
454
+ abi: SWAP_ABI_DIRECT,
455
+ functionName: 'executeSwap',
456
+ args: [tokenInAddress, tokenOutAddress, amountInWei.toString(), minAmountOutWei.toString()],
457
+ };
458
+ }
459
+
460
+ const payload = {
461
+ transaction: [
462
+ {
463
+ address: tokenInAddress,
464
+ abi: TRANSFER_ABI,
465
+ functionName: 'transfer',
466
+ args: [SWAP_CONTRACT_ADDRESS, amountInWei.toString()],
467
+ },
468
+ swapTransaction,
469
+ ],
470
+ };
471
+
472
+ const result = await MiniKit.commandsAsync.sendTransaction(payload);
473
+
474
+ if (result.finalPayload?.status === 'error') {
475
+ throw new Error(result.finalPayload?.error_code || 'Transaction failed');
476
+ }
477
+
478
+ const txHash = result.finalPayload?.transaction_id || result.finalPayload?.transactionId || null;
479
+
480
+ setSwapResult({
481
+ success: true,
482
+ message: `🎉 Swap successful! You received ~${formatBalance(quote.amount_out)} ${tokenOut.symbol}`,
483
+ txHash,
484
+ });
485
+
486
+ setAmountIn('');
487
+ setQuote(null);
488
+ setTimeout(loadBalance, 3000);
489
+ } catch (error) {
490
+ console.error('[world-swap] Error:', error);
491
+ setSwapResult({ success: false, message: error.message || 'Swap failed' });
492
+ } finally {
493
+ setIsLoading(false);
494
+ }
495
+ };
496
+
497
+ // -------------------------------------------------------------------------
498
+ // Handlers
499
+ // -------------------------------------------------------------------------
500
+ const handleSwapDirection = () => {
501
+ const tempIndex = tokenInIndex;
502
+ setTokenInIndex(tokenOutIndex);
503
+ setTokenOutIndex(tempIndex);
504
+ setAmountIn('');
505
+ setQuote(null);
506
+ setSwapResult(null);
507
+ };
508
+
509
+ const handleSelectTokenIn = (index) => {
510
+ if (index === tokenOutIndex) setTokenOutIndex(tokenInIndex);
511
+ setTokenInIndex(index);
512
+ setShowTokenInDropdown(false);
513
+ setAmountIn('');
514
+ setQuote(null);
515
+ };
516
+
517
+ const handleSelectTokenOut = (index) => {
518
+ if (index === tokenInIndex) setTokenInIndex(tokenOutIndex);
519
+ setTokenOutIndex(index);
520
+ setShowTokenOutDropdown(false);
521
+ setQuote(null);
522
+ };
523
+
524
+ const handleSetMax = () => {
525
+ if (parseFloat(tokenInBalance) > 0) setAmountIn(tokenInBalance);
526
+ };
527
+
528
+ const handleSlippageChange = (newSlippage) => setSlippage(newSlippage);
529
+
530
+ const closeAllDropdowns = () => {
531
+ setShowTokenInDropdown(false);
532
+ setShowTokenOutDropdown(false);
533
+ };
534
+
535
+ // -------------------------------------------------------------------------
536
+ // Render
537
+ // -------------------------------------------------------------------------
538
+ if (!walletUser) {
539
+ return (
540
+ <div className="text-center">
541
+ <div className="flex justify-between items-center mb-4">
542
+ <div>
543
+ <div className="flex items-center justify-center gap-2 mb-4">
544
+ <ShuffleIcon className="w-6 h-6 text-orange-400" />
545
+ <h2 className="text-xl font-bold text-orange-400">Swap Tokens</h2>
546
+ </div>
547
+ </div>
548
+ </div>
549
+ <p className="text-gray-400">Connect your wallet to swap</p>
550
+ </div>
551
+ );
552
+ }
553
+
554
+ return (
555
+ <div className="w-full max-w-sm mx-auto px-2">
556
+ {/* Header */}
557
+ <div className="flex justify-between items-center mb-4">
558
+ <div>
559
+ <div className="flex items-center justify-center gap-2 mb-4">
560
+ <ShuffleIcon className="w-6 h-6 text-orange-400" />
561
+ <h2 className="text-xl font-bold text-orange-400">{title}</h2>
562
+ </div>
563
+ </div>
564
+ <button
565
+ onClick={() => setShowSettings(!showSettings)}
566
+ className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
567
+ >
568
+ ⚙️
569
+ </button>
570
+ </div>
571
+
572
+ {/* Balance */}
573
+ <div className="mb-3 bg-purple-900/30 rounded-xl p-2 border border-purple-700/50">
574
+ <div className="flex justify-between items-center">
575
+ <span className="text-gray-400 text-xs">Your {tokenIn.symbol}:</span>
576
+ {isLoadingBalance ? (
577
+ <span className="text-gray-400 text-sm">Loading...</span>
578
+ ) : (
579
+ <span className="text-orange-400 font-bold text-sm">
580
+ {formatBalance(tokenInBalance)} {tokenIn.symbol}
581
+ </span>
582
+ )}
583
+ </div>
584
+ </div>
585
+
586
+ {/* Settings */}
587
+ {showSettings && (
588
+ <div className="mb-3 bg-gray-800/50 rounded-xl p-3 border border-gray-700">
589
+ <div className="flex justify-between items-center">
590
+ <span className="text-gray-400 text-xs">Slippage</span>
591
+ <div className="flex gap-1 flex-wrap justify-end">
592
+ {[1, 3, 5, 10, 15, 20].map((s) => (
593
+ <button
594
+ key={s}
595
+ onClick={() => handleSlippageChange(s)}
596
+ className={`px-2 py-1 rounded text-xs ${slippage === s ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300'}`}
597
+ >
598
+ {s}%
599
+ </button>
600
+ ))}
601
+ </div>
602
+ </div>
603
+ <p className="text-gray-500 text-xs mt-2">Current: {slippage}% ✓ saved</p>
604
+ </div>
605
+ )}
606
+
607
+ {/* Result */}
608
+ {swapResult && (
609
+ <div
610
+ className={`mb-3 p-3 rounded-xl text-sm ${
611
+ swapResult.success
612
+ ? 'bg-green-500/20 text-green-400 border border-green-500/50'
613
+ : 'bg-red-500/20 text-red-400 border border-red-500/50'
614
+ }`}
615
+ >
616
+ <p>{swapResult.message}</p>
617
+ {swapResult.txHash && (
618
+ <a
619
+ href={`${explorerUrl}/tx/${swapResult.txHash}`}
620
+ target="_blank"
621
+ rel="noopener noreferrer"
622
+ className="text-xs underline opacity-70 mt-1 block"
623
+ >
624
+ View on explorer →
625
+ </a>
626
+ )}
627
+ </div>
628
+ )}
629
+
630
+ {/* FROM */}
631
+ <div className="bg-gray-800/50 rounded-xl p-3 border border-gray-700">
632
+ <div className="flex justify-between items-center mb-1">
633
+ <label className="text-gray-400 text-xs">You Pay</label>
634
+ <button
635
+ onClick={handleSetMax}
636
+ disabled={isLoading || parseFloat(tokenInBalance) === 0}
637
+ className="text-xs text-purple-400 hover:text-purple-300 disabled:opacity-50"
638
+ >
639
+ MAX
640
+ </button>
641
+ </div>
642
+ <div className="flex items-center gap-2">
643
+ <div className="relative">
644
+ <button
645
+ onClick={(e) => {
646
+ e.stopPropagation();
647
+ setShowTokenOutDropdown(false);
648
+ setShowTokenInDropdown(!showTokenInDropdown);
649
+ }}
650
+ disabled={isLoading}
651
+ className={`flex items-center gap-2 bg-gradient-to-r ${tokenIn.gradient} px-3 py-2 rounded-lg transition-all hover:opacity-90 disabled:opacity-50`}
652
+ >
653
+ <TokenIcon token={tokenIn} size={24} ImageComponent={ImageComponent} />
654
+ <span className="text-white font-bold text-sm">{tokenIn.symbol}</span>
655
+ <span className="text-white/60 text-xs">▼</span>
656
+ </button>
657
+
658
+ {showTokenInDropdown && (
659
+ <>
660
+ <div className="fixed inset-0 z-40" onClick={closeAllDropdowns} />
661
+ <div className="absolute top-full left-0 mt-2 bg-gray-800 border border-gray-700 rounded-xl shadow-xl z-50 min-w-[200px] overflow-hidden">
662
+ {SUPPORTED_TOKENS.map((token, index) => (
663
+ <button
664
+ key={token.symbol}
665
+ onClick={(e) => {
666
+ e.stopPropagation();
667
+ handleSelectTokenIn(index);
668
+ }}
669
+ className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-700 transition-colors ${
670
+ index === tokenInIndex ? 'bg-gray-700' : ''
671
+ }`}
672
+ >
673
+ <TokenIcon token={token} size={32} ImageComponent={ImageComponent} />
674
+ <div className="text-left flex-1">
675
+ <p className="text-white font-semibold text-sm">{token.symbol}</p>
676
+ <p className="text-gray-400 text-xs">{token.name}</p>
677
+ </div>
678
+ {index === tokenInIndex && <span className="text-green-400">✓</span>}
679
+ </button>
680
+ ))}
681
+ </div>
682
+ </>
683
+ )}
684
+ </div>
685
+
686
+ <input
687
+ type="number"
688
+ placeholder="0.0"
689
+ value={amountIn}
690
+ onChange={(e) => setAmountIn(e.target.value)}
691
+ disabled={isLoading}
692
+ step="0.000001"
693
+ min="0"
694
+ className="flex-1 min-w-0 bg-transparent text-white text-right text-xl font-mono focus:outline-none"
695
+ />
696
+ </div>
697
+ </div>
698
+
699
+ {/* Swap direction */}
700
+ <div className="flex justify-center relative z-20 h-0">
701
+ <button
702
+ onClick={handleSwapDirection}
703
+ disabled={isLoading}
704
+ className="bg-gray-700 hover:bg-gray-600 p-4 rounded-full border-4 border-gray-900 transition-all hover:scale-110 disabled:opacity-50 shadow-lg -translate-y-1/2 flex items-center justify-center"
705
+ >
706
+ <svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
707
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
708
+ </svg>
709
+ </button>
710
+ </div>
711
+
712
+ {/* TO */}
713
+ <div className="mb-3 bg-gray-800/50 rounded-xl p-3 border border-gray-700">
714
+ <div className="flex justify-between items-center mb-1">
715
+ <label className="text-gray-400 text-xs">You Receive</label>
716
+ {quote && quote.price_impact > 1 && (
717
+ <span className={`text-xs ${quote.price_impact > 5 ? 'text-red-400' : 'text-yellow-400'}`}>
718
+ {quote.price_impact.toFixed(1)}% impact
719
+ </span>
720
+ )}
721
+ </div>
722
+ <div className="flex items-center gap-2">
723
+ <div className="relative">
724
+ <button
725
+ onClick={(e) => {
726
+ e.stopPropagation();
727
+ setShowTokenInDropdown(false);
728
+ setShowTokenOutDropdown(!showTokenOutDropdown);
729
+ }}
730
+ disabled={isLoading}
731
+ className={`flex items-center gap-2 bg-gradient-to-r ${tokenOut.gradient} px-3 py-2 rounded-lg transition-all hover:opacity-90 disabled:opacity-50`}
732
+ >
733
+ <TokenIcon token={tokenOut} size={24} ImageComponent={ImageComponent} />
734
+ <span className="text-white font-bold text-sm">{tokenOut.symbol}</span>
735
+ <span className="text-white/60 text-xs">▼</span>
736
+ </button>
737
+
738
+ {showTokenOutDropdown && (
739
+ <>
740
+ <div className="fixed inset-0 z-40" onClick={closeAllDropdowns} />
741
+ <div className="absolute top-full left-0 mt-2 bg-gray-800 border border-gray-700 rounded-xl shadow-xl z-50 min-w-[200px] overflow-hidden">
742
+ {SUPPORTED_TOKENS.map((token, index) => (
743
+ <button
744
+ key={token.symbol}
745
+ onClick={(e) => {
746
+ e.stopPropagation();
747
+ handleSelectTokenOut(index);
748
+ }}
749
+ className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-700 transition-colors ${
750
+ index === tokenOutIndex ? 'bg-gray-700' : ''
751
+ }`}
752
+ >
753
+ <TokenIcon token={token} size={32} ImageComponent={ImageComponent} />
754
+ <div className="text-left flex-1">
755
+ <p className="text-white font-semibold text-sm">{token.symbol}</p>
756
+ <p className="text-gray-400 text-xs">{token.name}</p>
757
+ </div>
758
+ {index === tokenOutIndex && <span className="text-green-400">✓</span>}
759
+ </button>
760
+ ))}
761
+ </div>
762
+ </>
763
+ )}
764
+ </div>
765
+
766
+ <div className="flex-1 text-right">
767
+ {isLoadingQuote ? (
768
+ <span className="text-gray-400 text-xl animate-pulse">...</span>
769
+ ) : quote ? (
770
+ <span className="text-white text-xl font-mono">{formatBalance(quote.amount_out)}</span>
771
+ ) : (
772
+ <span className="text-gray-500 text-xl">0.0</span>
773
+ )}
774
+ </div>
775
+ </div>
776
+ </div>
777
+
778
+ {/* Swap button */}
779
+ <div className="w-full justify-center">
780
+ <Button
781
+ onClick={handleSwap}
782
+ disabled={isLoading || !amountIn || parseFloat(amountIn) <= 0 || !quote || isLoadingQuote}
783
+ >
784
+ {isLoading ? (
785
+ <span className="flex items-center justify-center gap-2">⏳ Swapping...</span>
786
+ ) : isLoadingQuote ? (
787
+ <span className="flex items-center justify-center gap-2">⏳ Loading...</span>
788
+ ) : !quote ? (
789
+ 'Enter amount'
790
+ ) : (
791
+ <span className="flex items-center justify-center gap-2">
792
+ <ShuffleIcon className="w-5 h-5" /> Swap {tokenIn.symbol} → {tokenOut.symbol}
793
+ </span>
794
+ )}
795
+ </Button>
796
+ </div>
797
+
798
+ {/* Quote details */}
799
+ {quote && (
800
+ <div className="mb-3 bg-gray-800/30 rounded-lg p-2 text-xs space-y-1">
801
+ <div className="flex justify-between text-gray-400">
802
+ <span>Rate</span>
803
+ <span>
804
+ 1 {tokenIn.symbol} = {formatBalance(quote.rate)} {tokenOut.symbol}
805
+ </span>
806
+ </div>
807
+ <div className="flex justify-between text-gray-400">
808
+ <span>Fee ({quote.platform_fee_percent}%)</span>
809
+ <span>
810
+ -{formatBalance(quote.platform_fee)} {tokenIn.symbol}
811
+ </span>
812
+ </div>
813
+ {tokenIn.externalTaxPercent > 0 && (
814
+ <div className="flex justify-between text-yellow-400">
815
+ <span>⚠️ Token tax ({tokenIn.externalTaxPercent}%)</span>
816
+ <span>
817
+ -{formatBalance((parseFloat(amountIn) * tokenIn.externalTaxPercent) / 100)} {tokenIn.symbol}
818
+ </span>
819
+ </div>
820
+ )}
821
+ <div className="flex justify-between text-gray-400">
822
+ <span>Slippage</span>
823
+ <span>
824
+ {slippage}%{tokenIn.externalTaxPercent > 0 ? ` + ${tokenIn.externalTaxPercent}% tax` : ''}
825
+ </span>
826
+ </div>
827
+ <div className="flex justify-between text-gray-400">
828
+ <span>Min. received</span>
829
+ <span>
830
+ {formatBalance(quote.amount_out * (1 - (slippage + (tokenIn.externalTaxPercent || 0)) / 100))}{' '}
831
+ {tokenOut.symbol}
832
+ </span>
833
+ </div>
834
+ {swapMode !== 'v2' && quote.v3_fee_tier && (
835
+ <div className="flex justify-between text-blue-400">
836
+ <span>Pool fee tier</span>
837
+ <span>{quote.v3_fee_tier / 10000}%</span>
838
+ </div>
839
+ )}
840
+ </div>
841
+ )}
842
+
843
+ {/* Refresh */}
844
+ <button onClick={loadBalance} className="w-full mt-2 text-gray-500 hover:text-gray-300 text-xs py-1">
845
+ 🔄 Refresh balance
846
+ </button>
847
+ </div>
848
+ );
849
+ }
850
+
851
+ export default SwapCard;