@hongming-wang/usdc-bridge-widget 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/README.md +272 -0
- package/dist/chunk-6JW37N76.mjs +211 -0
- package/dist/chunk-GJBJYQCU.mjs +218 -0
- package/dist/chunk-JHG7XCWW.mjs +218 -0
- package/dist/index.d.mts +765 -0
- package/dist/index.d.ts +765 -0
- package/dist/index.js +2356 -0
- package/dist/index.mjs +2295 -0
- package/dist/useBridge-LDEXWLEC.mjs +10 -0
- package/dist/useBridge-VGN5DMO6.mjs +10 -0
- package/dist/useBridge-WJA4XLLR.mjs +10 -0
- package/package.json +63 -0
- package/src/BridgeWidget.tsx +1133 -0
- package/src/__tests__/BridgeWidget.test.tsx +310 -0
- package/src/__tests__/chains.test.ts +131 -0
- package/src/__tests__/constants.test.ts +77 -0
- package/src/__tests__/hooks.test.ts +127 -0
- package/src/__tests__/icons.test.tsx +159 -0
- package/src/__tests__/setup.ts +8 -0
- package/src/__tests__/theme.test.ts +148 -0
- package/src/__tests__/useBridge.test.ts +133 -0
- package/src/__tests__/utils.test.ts +255 -0
- package/src/chains.ts +209 -0
- package/src/constants.ts +97 -0
- package/src/hooks.ts +349 -0
- package/src/icons.tsx +228 -0
- package/src/index.tsx +111 -0
- package/src/theme.ts +131 -0
- package/src/types.ts +160 -0
- package/src/useBridge.ts +424 -0
- package/src/utils.ts +239 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, useId, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useAccount,
|
|
4
|
+
useChainId,
|
|
5
|
+
useSwitchChain,
|
|
6
|
+
useWaitForTransactionReceipt,
|
|
7
|
+
useConnect,
|
|
8
|
+
} from "wagmi";
|
|
9
|
+
import type {
|
|
10
|
+
BridgeWidgetProps,
|
|
11
|
+
BridgeWidgetTheme,
|
|
12
|
+
BridgeChainConfig,
|
|
13
|
+
} from "./types";
|
|
14
|
+
import { useUSDCBalance, useUSDCAllowance, useAllUSDCBalances } from "./hooks";
|
|
15
|
+
import { useBridge } from "./useBridge";
|
|
16
|
+
import { DEFAULT_CHAIN_CONFIGS } from "./chains";
|
|
17
|
+
import { USDC_BRAND_COLOR } from "./constants";
|
|
18
|
+
import { formatNumber, getErrorMessage, validateAmountInput, validateChainConfigs } from "./utils";
|
|
19
|
+
import { mergeTheme } from "./theme";
|
|
20
|
+
import { ChevronDownIcon, SwapIcon } from "./icons";
|
|
21
|
+
|
|
22
|
+
// Chain icon with fallback
|
|
23
|
+
function ChainIcon({
|
|
24
|
+
chainConfig,
|
|
25
|
+
theme,
|
|
26
|
+
size = 24,
|
|
27
|
+
}: {
|
|
28
|
+
chainConfig: BridgeChainConfig;
|
|
29
|
+
theme: Required<BridgeWidgetTheme>;
|
|
30
|
+
size?: number;
|
|
31
|
+
}) {
|
|
32
|
+
const [hasError, setHasError] = useState(false);
|
|
33
|
+
|
|
34
|
+
if (!chainConfig.iconUrl || hasError) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
style={{
|
|
38
|
+
width: `${size}px`,
|
|
39
|
+
height: `${size}px`,
|
|
40
|
+
borderRadius: "50%",
|
|
41
|
+
display: "flex",
|
|
42
|
+
alignItems: "center",
|
|
43
|
+
justifyContent: "center",
|
|
44
|
+
fontSize: `${size * 0.5}px`,
|
|
45
|
+
fontWeight: "bold",
|
|
46
|
+
color: theme.textColor,
|
|
47
|
+
background: `linear-gradient(135deg, ${theme.primaryColor}, ${theme.secondaryColor})`,
|
|
48
|
+
}}
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
>
|
|
51
|
+
{chainConfig.chain.name.charAt(0)}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<img
|
|
58
|
+
src={chainConfig.iconUrl}
|
|
59
|
+
alt=""
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
style={{ width: `${size}px`, height: `${size}px`, borderRadius: "50%" }}
|
|
62
|
+
onError={() => setHasError(true)}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Small loading spinner for balance display
|
|
68
|
+
function BalanceSpinner({ size = 12 }: { size?: number }) {
|
|
69
|
+
return (
|
|
70
|
+
<svg
|
|
71
|
+
width={size}
|
|
72
|
+
height={size}
|
|
73
|
+
viewBox="0 0 24 24"
|
|
74
|
+
fill="none"
|
|
75
|
+
stroke="currentColor"
|
|
76
|
+
strokeWidth="2"
|
|
77
|
+
style={{
|
|
78
|
+
animation: "cc-balance-spin 1s linear infinite",
|
|
79
|
+
opacity: 0.6,
|
|
80
|
+
}}
|
|
81
|
+
aria-hidden="true"
|
|
82
|
+
>
|
|
83
|
+
<style>{`@keyframes cc-balance-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
|
|
84
|
+
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
85
|
+
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
|
86
|
+
</svg>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Chain Selector Component
|
|
91
|
+
function ChainSelector({
|
|
92
|
+
label,
|
|
93
|
+
chains,
|
|
94
|
+
selectedChain,
|
|
95
|
+
onSelect,
|
|
96
|
+
excludeChainId,
|
|
97
|
+
theme,
|
|
98
|
+
id,
|
|
99
|
+
balances,
|
|
100
|
+
isLoadingBalances,
|
|
101
|
+
disabled,
|
|
102
|
+
}: {
|
|
103
|
+
label: string;
|
|
104
|
+
chains: BridgeChainConfig[];
|
|
105
|
+
selectedChain: BridgeChainConfig;
|
|
106
|
+
onSelect: (chain: BridgeChainConfig) => void;
|
|
107
|
+
excludeChainId?: number;
|
|
108
|
+
theme: Required<BridgeWidgetTheme>;
|
|
109
|
+
id: string;
|
|
110
|
+
balances?: Record<number, { balance: bigint; formatted: string }>;
|
|
111
|
+
isLoadingBalances?: boolean;
|
|
112
|
+
disabled?: boolean;
|
|
113
|
+
}) {
|
|
114
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
115
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
116
|
+
const [typeAhead, setTypeAhead] = useState("");
|
|
117
|
+
const typeAheadTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
118
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
119
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
120
|
+
const availableChains = chains.filter(
|
|
121
|
+
(c) => c.chain.id !== excludeChainId
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Clear type-ahead timer on unmount
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
return () => {
|
|
127
|
+
if (typeAheadTimeoutRef.current) {
|
|
128
|
+
clearTimeout(typeAheadTimeoutRef.current);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
// Handle keyboard navigation on button
|
|
134
|
+
const handleButtonKeyDown = useCallback(
|
|
135
|
+
(e: React.KeyboardEvent) => {
|
|
136
|
+
if (e.key === "Escape") {
|
|
137
|
+
setIsOpen(false);
|
|
138
|
+
buttonRef.current?.focus();
|
|
139
|
+
} else if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
|
140
|
+
if (!isOpen) {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
setIsOpen(true);
|
|
143
|
+
setFocusedIndex(0);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
[isOpen]
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Handle keyboard navigation in listbox with type-ahead search
|
|
151
|
+
const handleListKeyDown = useCallback(
|
|
152
|
+
(e: React.KeyboardEvent) => {
|
|
153
|
+
if (e.key === "Escape") {
|
|
154
|
+
setIsOpen(false);
|
|
155
|
+
setTypeAhead("");
|
|
156
|
+
buttonRef.current?.focus();
|
|
157
|
+
} else if (e.key === "ArrowDown") {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
setFocusedIndex((prev) =>
|
|
160
|
+
prev < availableChains.length - 1 ? prev + 1 : 0
|
|
161
|
+
);
|
|
162
|
+
} else if (e.key === "ArrowUp") {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
setFocusedIndex((prev) =>
|
|
165
|
+
prev > 0 ? prev - 1 : availableChains.length - 1
|
|
166
|
+
);
|
|
167
|
+
} else if (e.key === "Enter" || e.key === " ") {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
if (focusedIndex >= 0 && focusedIndex < availableChains.length) {
|
|
170
|
+
onSelect(availableChains[focusedIndex]);
|
|
171
|
+
setIsOpen(false);
|
|
172
|
+
setTypeAhead("");
|
|
173
|
+
buttonRef.current?.focus();
|
|
174
|
+
}
|
|
175
|
+
} else if (e.key === "Home") {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
setFocusedIndex(0);
|
|
178
|
+
} else if (e.key === "End") {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
setFocusedIndex(availableChains.length - 1);
|
|
181
|
+
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
182
|
+
// Type-ahead search
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
const newTypeAhead = typeAhead + e.key.toLowerCase();
|
|
185
|
+
setTypeAhead(newTypeAhead);
|
|
186
|
+
|
|
187
|
+
// Clear previous timeout
|
|
188
|
+
if (typeAheadTimeoutRef.current) {
|
|
189
|
+
clearTimeout(typeAheadTimeoutRef.current);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Reset type-ahead after 1 second of inactivity
|
|
193
|
+
typeAheadTimeoutRef.current = setTimeout(() => {
|
|
194
|
+
setTypeAhead("");
|
|
195
|
+
}, 1000);
|
|
196
|
+
|
|
197
|
+
// Find matching chain
|
|
198
|
+
const matchIndex = availableChains.findIndex((chain) =>
|
|
199
|
+
chain.chain.name.toLowerCase().startsWith(newTypeAhead)
|
|
200
|
+
);
|
|
201
|
+
if (matchIndex !== -1) {
|
|
202
|
+
setFocusedIndex(matchIndex);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
[availableChains, focusedIndex, onSelect, typeAhead]
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Focus the list when opened
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (isOpen && listRef.current) {
|
|
212
|
+
listRef.current.focus();
|
|
213
|
+
}
|
|
214
|
+
}, [isOpen]);
|
|
215
|
+
|
|
216
|
+
// Close on escape key globally when open
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!isOpen) return;
|
|
219
|
+
|
|
220
|
+
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
221
|
+
if (e.key === "Escape") {
|
|
222
|
+
setIsOpen(false);
|
|
223
|
+
setTypeAhead("");
|
|
224
|
+
buttonRef.current?.focus();
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
document.addEventListener("keydown", handleGlobalKeyDown);
|
|
229
|
+
return () => document.removeEventListener("keydown", handleGlobalKeyDown);
|
|
230
|
+
}, [isOpen]);
|
|
231
|
+
|
|
232
|
+
// Reset type-ahead when dropdown closes
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!isOpen) {
|
|
235
|
+
setTypeAhead("");
|
|
236
|
+
if (typeAheadTimeoutRef.current) {
|
|
237
|
+
clearTimeout(typeAheadTimeoutRef.current);
|
|
238
|
+
typeAheadTimeoutRef.current = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}, [isOpen]);
|
|
242
|
+
|
|
243
|
+
const buttonId = `${id}-button`;
|
|
244
|
+
const listboxId = `${id}-listbox`;
|
|
245
|
+
|
|
246
|
+
// Get selected chain balance
|
|
247
|
+
const selectedBalance = balances?.[selectedChain.chain.id];
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div style={{ position: "relative", flex: 1 }}>
|
|
251
|
+
<label
|
|
252
|
+
id={`${id}-label`}
|
|
253
|
+
htmlFor={buttonId}
|
|
254
|
+
style={{
|
|
255
|
+
display: "block",
|
|
256
|
+
fontSize: "10px",
|
|
257
|
+
color: theme.mutedTextColor,
|
|
258
|
+
textTransform: "uppercase",
|
|
259
|
+
letterSpacing: "0.05em",
|
|
260
|
+
fontWeight: 500,
|
|
261
|
+
marginBottom: "4px",
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{label}
|
|
265
|
+
</label>
|
|
266
|
+
<button
|
|
267
|
+
ref={buttonRef}
|
|
268
|
+
id={buttonId}
|
|
269
|
+
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
270
|
+
onKeyDown={disabled ? undefined : handleButtonKeyDown}
|
|
271
|
+
disabled={disabled}
|
|
272
|
+
aria-haspopup="listbox"
|
|
273
|
+
aria-expanded={isOpen}
|
|
274
|
+
aria-labelledby={`${id}-label`}
|
|
275
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
276
|
+
aria-disabled={disabled}
|
|
277
|
+
style={{
|
|
278
|
+
width: "100%",
|
|
279
|
+
display: "flex",
|
|
280
|
+
alignItems: "center",
|
|
281
|
+
justifyContent: "space-between",
|
|
282
|
+
padding: "10px 12px",
|
|
283
|
+
borderRadius: `${theme.borderRadius}px`,
|
|
284
|
+
background: theme.cardBackgroundColor,
|
|
285
|
+
border: `1px solid ${theme.borderColor}`,
|
|
286
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
287
|
+
opacity: disabled ? 0.6 : 1,
|
|
288
|
+
transition: "all 0.2s",
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
292
|
+
<ChainIcon chainConfig={selectedChain} theme={theme} />
|
|
293
|
+
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
|
294
|
+
<span
|
|
295
|
+
style={{
|
|
296
|
+
fontSize: "14px",
|
|
297
|
+
fontWeight: 500,
|
|
298
|
+
color: theme.textColor,
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{selectedChain.chain.name}
|
|
302
|
+
</span>
|
|
303
|
+
{isLoadingBalances ? (
|
|
304
|
+
<span
|
|
305
|
+
style={{
|
|
306
|
+
fontSize: "10px",
|
|
307
|
+
color: theme.mutedTextColor,
|
|
308
|
+
display: "flex",
|
|
309
|
+
alignItems: "center",
|
|
310
|
+
gap: "4px",
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
<BalanceSpinner size={10} /> Loading...
|
|
314
|
+
</span>
|
|
315
|
+
) : selectedBalance ? (
|
|
316
|
+
<span
|
|
317
|
+
style={{
|
|
318
|
+
fontSize: "10px",
|
|
319
|
+
color: theme.mutedTextColor,
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
{formatNumber(selectedBalance.formatted, 2)} USDC
|
|
323
|
+
</span>
|
|
324
|
+
) : null}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
<ChevronDownIcon
|
|
328
|
+
size={16}
|
|
329
|
+
color={theme.mutedTextColor}
|
|
330
|
+
style={{
|
|
331
|
+
transition: "transform 0.2s",
|
|
332
|
+
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
|
|
333
|
+
}}
|
|
334
|
+
/>
|
|
335
|
+
</button>
|
|
336
|
+
|
|
337
|
+
{isOpen && (
|
|
338
|
+
<>
|
|
339
|
+
<div
|
|
340
|
+
style={{
|
|
341
|
+
position: "fixed",
|
|
342
|
+
inset: 0,
|
|
343
|
+
zIndex: 10,
|
|
344
|
+
}}
|
|
345
|
+
onClick={() => setIsOpen(false)}
|
|
346
|
+
aria-hidden="true"
|
|
347
|
+
/>
|
|
348
|
+
<ul
|
|
349
|
+
ref={listRef}
|
|
350
|
+
id={listboxId}
|
|
351
|
+
role="listbox"
|
|
352
|
+
aria-labelledby={`${id}-label`}
|
|
353
|
+
aria-activedescendant={
|
|
354
|
+
focusedIndex >= 0
|
|
355
|
+
? `${id}-option-${availableChains[focusedIndex]?.chain.id}`
|
|
356
|
+
: undefined
|
|
357
|
+
}
|
|
358
|
+
tabIndex={0}
|
|
359
|
+
onKeyDown={handleListKeyDown}
|
|
360
|
+
style={{
|
|
361
|
+
position: "absolute",
|
|
362
|
+
zIndex: 20,
|
|
363
|
+
width: "100%",
|
|
364
|
+
marginTop: "8px",
|
|
365
|
+
borderRadius: `${theme.borderRadius}px`,
|
|
366
|
+
boxShadow: "0 10px 40px rgba(0,0,0,0.3)",
|
|
367
|
+
background: theme.cardBackgroundColor,
|
|
368
|
+
backdropFilter: "blur(10px)",
|
|
369
|
+
border: `1px solid ${theme.borderColor}`,
|
|
370
|
+
maxHeight: "300px",
|
|
371
|
+
overflowY: "auto",
|
|
372
|
+
overflowX: "hidden",
|
|
373
|
+
padding: 0,
|
|
374
|
+
margin: 0,
|
|
375
|
+
listStyle: "none",
|
|
376
|
+
outline: "none",
|
|
377
|
+
}}
|
|
378
|
+
>
|
|
379
|
+
{availableChains.map((chainConfig, index) => {
|
|
380
|
+
const chainBalance = balances?.[chainConfig.chain.id];
|
|
381
|
+
const isFocused = index === focusedIndex;
|
|
382
|
+
const isSelected = chainConfig.chain.id === selectedChain.chain.id;
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<li
|
|
386
|
+
key={chainConfig.chain.id}
|
|
387
|
+
id={`${id}-option-${chainConfig.chain.id}`}
|
|
388
|
+
role="option"
|
|
389
|
+
aria-selected={isSelected}
|
|
390
|
+
onClick={() => {
|
|
391
|
+
onSelect(chainConfig);
|
|
392
|
+
setIsOpen(false);
|
|
393
|
+
buttonRef.current?.focus();
|
|
394
|
+
}}
|
|
395
|
+
style={{
|
|
396
|
+
width: "100%",
|
|
397
|
+
display: "flex",
|
|
398
|
+
alignItems: "center",
|
|
399
|
+
gap: "8px",
|
|
400
|
+
padding: "10px 12px",
|
|
401
|
+
background: isFocused ? theme.hoverColor : "transparent",
|
|
402
|
+
border: "none",
|
|
403
|
+
cursor: "pointer",
|
|
404
|
+
transition: "background 0.2s",
|
|
405
|
+
}}
|
|
406
|
+
onMouseEnter={() => setFocusedIndex(index)}
|
|
407
|
+
>
|
|
408
|
+
<ChainIcon chainConfig={chainConfig} theme={theme} />
|
|
409
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
|
410
|
+
<span
|
|
411
|
+
style={{
|
|
412
|
+
fontSize: "14px",
|
|
413
|
+
color: isSelected ? theme.textColor : theme.mutedTextColor,
|
|
414
|
+
fontWeight: isSelected ? 500 : 400,
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
{chainConfig.chain.name}
|
|
418
|
+
</span>
|
|
419
|
+
{isLoadingBalances ? (
|
|
420
|
+
<span
|
|
421
|
+
style={{
|
|
422
|
+
fontSize: "10px",
|
|
423
|
+
color: theme.mutedTextColor,
|
|
424
|
+
display: "flex",
|
|
425
|
+
alignItems: "center",
|
|
426
|
+
gap: "4px",
|
|
427
|
+
}}
|
|
428
|
+
>
|
|
429
|
+
<BalanceSpinner size={10} />
|
|
430
|
+
</span>
|
|
431
|
+
) : chainBalance ? (
|
|
432
|
+
<span
|
|
433
|
+
style={{
|
|
434
|
+
fontSize: "10px",
|
|
435
|
+
color: parseFloat(chainBalance.formatted) > 0 ? theme.successColor : theme.mutedTextColor,
|
|
436
|
+
}}
|
|
437
|
+
>
|
|
438
|
+
{formatNumber(chainBalance.formatted, 2)} USDC
|
|
439
|
+
</span>
|
|
440
|
+
) : (
|
|
441
|
+
<span
|
|
442
|
+
style={{
|
|
443
|
+
fontSize: "10px",
|
|
444
|
+
color: theme.mutedTextColor,
|
|
445
|
+
}}
|
|
446
|
+
>
|
|
447
|
+
0.00 USDC
|
|
448
|
+
</span>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
</li>
|
|
452
|
+
);
|
|
453
|
+
})}
|
|
454
|
+
</ul>
|
|
455
|
+
</>
|
|
456
|
+
)}
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Swap Button Component
|
|
462
|
+
function SwapButton({
|
|
463
|
+
onClick,
|
|
464
|
+
theme,
|
|
465
|
+
disabled,
|
|
466
|
+
}: {
|
|
467
|
+
onClick: () => void;
|
|
468
|
+
theme: Required<BridgeWidgetTheme>;
|
|
469
|
+
disabled?: boolean;
|
|
470
|
+
}) {
|
|
471
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<button
|
|
475
|
+
onClick={onClick}
|
|
476
|
+
disabled={disabled}
|
|
477
|
+
aria-label="Swap source and destination chains"
|
|
478
|
+
style={{
|
|
479
|
+
padding: "8px",
|
|
480
|
+
borderRadius: `${theme.borderRadius}px`,
|
|
481
|
+
background: `${theme.primaryColor}15`,
|
|
482
|
+
border: `1px solid ${theme.primaryColor}40`,
|
|
483
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
484
|
+
opacity: disabled ? 0.5 : 1,
|
|
485
|
+
transition: "all 0.2s",
|
|
486
|
+
display: "flex",
|
|
487
|
+
alignItems: "center",
|
|
488
|
+
justifyContent: "center",
|
|
489
|
+
alignSelf: "flex-end",
|
|
490
|
+
marginBottom: "4px",
|
|
491
|
+
transform: isHovered && !disabled ? "scale(1.1)" : "scale(1)",
|
|
492
|
+
}}
|
|
493
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
494
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
495
|
+
>
|
|
496
|
+
<SwapIcon size={20} color={theme.primaryColor} />
|
|
497
|
+
</button>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Amount Input Component
|
|
502
|
+
function AmountInput({
|
|
503
|
+
value,
|
|
504
|
+
onChange,
|
|
505
|
+
balance,
|
|
506
|
+
onMaxClick,
|
|
507
|
+
theme,
|
|
508
|
+
id,
|
|
509
|
+
disabled,
|
|
510
|
+
}: {
|
|
511
|
+
value: string;
|
|
512
|
+
onChange: (value: string) => void;
|
|
513
|
+
balance: string;
|
|
514
|
+
onMaxClick: () => void;
|
|
515
|
+
theme: Required<BridgeWidgetTheme>;
|
|
516
|
+
id: string;
|
|
517
|
+
disabled?: boolean;
|
|
518
|
+
}) {
|
|
519
|
+
const inputId = `${id}-input`;
|
|
520
|
+
const labelId = `${id}-label`;
|
|
521
|
+
|
|
522
|
+
// Handle input change with comprehensive validation
|
|
523
|
+
const handleInputChange = useCallback(
|
|
524
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
525
|
+
if (disabled) return;
|
|
526
|
+
const result = validateAmountInput(e.target.value);
|
|
527
|
+
if (result.isValid) {
|
|
528
|
+
onChange(result.sanitized);
|
|
529
|
+
} else if (result.sanitized) {
|
|
530
|
+
// Use sanitized value (e.g., truncated decimals)
|
|
531
|
+
onChange(result.sanitized);
|
|
532
|
+
}
|
|
533
|
+
// Invalid input is rejected silently
|
|
534
|
+
},
|
|
535
|
+
[onChange, disabled]
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// Prevent 'e' key from being entered
|
|
539
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
540
|
+
if (e.key === "e" || e.key === "E" || e.key === "+" || e.key === "-") {
|
|
541
|
+
e.preventDefault();
|
|
542
|
+
}
|
|
543
|
+
}, []);
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<div>
|
|
547
|
+
<div
|
|
548
|
+
style={{
|
|
549
|
+
display: "flex",
|
|
550
|
+
justifyContent: "space-between",
|
|
551
|
+
marginBottom: "4px",
|
|
552
|
+
}}
|
|
553
|
+
>
|
|
554
|
+
<label
|
|
555
|
+
id={labelId}
|
|
556
|
+
htmlFor={inputId}
|
|
557
|
+
style={{
|
|
558
|
+
fontSize: "10px",
|
|
559
|
+
color: theme.mutedTextColor,
|
|
560
|
+
textTransform: "uppercase",
|
|
561
|
+
letterSpacing: "0.05em",
|
|
562
|
+
fontWeight: 500,
|
|
563
|
+
}}
|
|
564
|
+
>
|
|
565
|
+
Amount
|
|
566
|
+
</label>
|
|
567
|
+
<span
|
|
568
|
+
style={{ fontSize: "10px", color: theme.mutedTextColor }}
|
|
569
|
+
aria-live="polite"
|
|
570
|
+
>
|
|
571
|
+
Balance:{" "}
|
|
572
|
+
<span style={{ color: theme.textColor }}>
|
|
573
|
+
{formatNumber(balance)} USDC
|
|
574
|
+
</span>
|
|
575
|
+
</span>
|
|
576
|
+
</div>
|
|
577
|
+
<div
|
|
578
|
+
style={{
|
|
579
|
+
display: "flex",
|
|
580
|
+
alignItems: "center",
|
|
581
|
+
borderRadius: `${theme.borderRadius}px`,
|
|
582
|
+
overflow: "hidden",
|
|
583
|
+
background: theme.cardBackgroundColor,
|
|
584
|
+
border: `1px solid ${theme.borderColor}`,
|
|
585
|
+
opacity: disabled ? 0.6 : 1,
|
|
586
|
+
}}
|
|
587
|
+
>
|
|
588
|
+
<input
|
|
589
|
+
id={inputId}
|
|
590
|
+
type="text"
|
|
591
|
+
inputMode="decimal"
|
|
592
|
+
pattern="[0-9]*\.?[0-9]*"
|
|
593
|
+
value={value}
|
|
594
|
+
onChange={handleInputChange}
|
|
595
|
+
onKeyDown={handleKeyDown}
|
|
596
|
+
placeholder="0.00"
|
|
597
|
+
disabled={disabled}
|
|
598
|
+
aria-labelledby={labelId}
|
|
599
|
+
aria-describedby={`${id}-currency`}
|
|
600
|
+
aria-disabled={disabled}
|
|
601
|
+
style={{
|
|
602
|
+
flex: 1,
|
|
603
|
+
background: "transparent",
|
|
604
|
+
border: "none",
|
|
605
|
+
padding: "12px",
|
|
606
|
+
fontSize: "18px",
|
|
607
|
+
color: theme.textColor,
|
|
608
|
+
fontWeight: 500,
|
|
609
|
+
outline: "none",
|
|
610
|
+
minWidth: 0,
|
|
611
|
+
fontFamily: theme.fontFamily,
|
|
612
|
+
cursor: disabled ? "not-allowed" : "text",
|
|
613
|
+
}}
|
|
614
|
+
/>
|
|
615
|
+
<div
|
|
616
|
+
style={{
|
|
617
|
+
display: "flex",
|
|
618
|
+
alignItems: "center",
|
|
619
|
+
gap: "8px",
|
|
620
|
+
paddingRight: "12px",
|
|
621
|
+
}}
|
|
622
|
+
>
|
|
623
|
+
<button
|
|
624
|
+
onClick={onMaxClick}
|
|
625
|
+
disabled={disabled}
|
|
626
|
+
aria-label="Set maximum amount"
|
|
627
|
+
style={{
|
|
628
|
+
padding: "4px 8px",
|
|
629
|
+
fontSize: "10px",
|
|
630
|
+
fontWeight: 600,
|
|
631
|
+
borderRadius: "4px",
|
|
632
|
+
background: `${theme.primaryColor}20`,
|
|
633
|
+
color: theme.primaryColor,
|
|
634
|
+
border: "none",
|
|
635
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
636
|
+
opacity: disabled ? 0.5 : 1,
|
|
637
|
+
}}
|
|
638
|
+
>
|
|
639
|
+
MAX
|
|
640
|
+
</button>
|
|
641
|
+
<div
|
|
642
|
+
id={`${id}-currency`}
|
|
643
|
+
style={{ display: "flex", alignItems: "center", gap: "4px" }}
|
|
644
|
+
>
|
|
645
|
+
<div
|
|
646
|
+
style={{
|
|
647
|
+
width: "20px",
|
|
648
|
+
height: "20px",
|
|
649
|
+
borderRadius: "50%",
|
|
650
|
+
background: USDC_BRAND_COLOR,
|
|
651
|
+
display: "flex",
|
|
652
|
+
alignItems: "center",
|
|
653
|
+
justifyContent: "center",
|
|
654
|
+
}}
|
|
655
|
+
aria-hidden="true"
|
|
656
|
+
>
|
|
657
|
+
<span
|
|
658
|
+
style={{
|
|
659
|
+
fontSize: "10px",
|
|
660
|
+
fontWeight: "bold",
|
|
661
|
+
color: "#fff",
|
|
662
|
+
}}
|
|
663
|
+
>
|
|
664
|
+
$
|
|
665
|
+
</span>
|
|
666
|
+
</div>
|
|
667
|
+
<span
|
|
668
|
+
style={{
|
|
669
|
+
fontSize: "14px",
|
|
670
|
+
fontWeight: 500,
|
|
671
|
+
color: theme.textColor,
|
|
672
|
+
}}
|
|
673
|
+
>
|
|
674
|
+
USDC
|
|
675
|
+
</span>
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Main Bridge Widget Component
|
|
684
|
+
export function BridgeWidget({
|
|
685
|
+
chains = DEFAULT_CHAIN_CONFIGS,
|
|
686
|
+
defaultSourceChainId,
|
|
687
|
+
defaultDestinationChainId,
|
|
688
|
+
onBridgeStart,
|
|
689
|
+
onBridgeSuccess,
|
|
690
|
+
onBridgeError,
|
|
691
|
+
onConnectWallet,
|
|
692
|
+
theme: themeOverrides,
|
|
693
|
+
className,
|
|
694
|
+
style,
|
|
695
|
+
}: BridgeWidgetProps) {
|
|
696
|
+
const theme = mergeTheme(themeOverrides);
|
|
697
|
+
const { address, isConnected } = useAccount();
|
|
698
|
+
const currentChainId = useChainId();
|
|
699
|
+
const { switchChainAsync } = useSwitchChain();
|
|
700
|
+
const { connect, connectors } = useConnect();
|
|
701
|
+
|
|
702
|
+
// Validate chain configs on mount/change
|
|
703
|
+
const [configError, setConfigError] = useState<string | null>(null);
|
|
704
|
+
useEffect(() => {
|
|
705
|
+
const validation = validateChainConfigs(chains);
|
|
706
|
+
if (!validation.isValid) {
|
|
707
|
+
const errorMsg = validation.errors.join("; ");
|
|
708
|
+
console.error("[BridgeWidget] Invalid chain configuration:", errorMsg);
|
|
709
|
+
setConfigError(errorMsg);
|
|
710
|
+
} else {
|
|
711
|
+
setConfigError(null);
|
|
712
|
+
}
|
|
713
|
+
}, [chains]);
|
|
714
|
+
|
|
715
|
+
// Generate unique IDs for accessibility
|
|
716
|
+
const baseId = useId();
|
|
717
|
+
const sourceChainId = `${baseId}-source`;
|
|
718
|
+
const destChainId = `${baseId}-dest`;
|
|
719
|
+
const amountId = `${baseId}-amount`;
|
|
720
|
+
|
|
721
|
+
// Find initial chains
|
|
722
|
+
const getChainConfig = useCallback(
|
|
723
|
+
(chainId?: number) => {
|
|
724
|
+
if (!chainId) return chains[0];
|
|
725
|
+
return chains.find((c) => c.chain.id === chainId) || chains[0];
|
|
726
|
+
},
|
|
727
|
+
[chains]
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
const [sourceChainConfig, setSourceChainConfig] = useState<BridgeChainConfig>(
|
|
731
|
+
() => getChainConfig(defaultSourceChainId)
|
|
732
|
+
);
|
|
733
|
+
const [destChainConfig, setDestChainConfig] = useState<BridgeChainConfig>(
|
|
734
|
+
() =>
|
|
735
|
+
getChainConfig(defaultDestinationChainId) ||
|
|
736
|
+
chains.find((c) => c.chain.id !== sourceChainConfig.chain.id) ||
|
|
737
|
+
chains[1] ||
|
|
738
|
+
chains[0]
|
|
739
|
+
);
|
|
740
|
+
const [amount, setAmount] = useState("");
|
|
741
|
+
const [txHash, setTxHash] = useState<`0x${string}` | undefined>();
|
|
742
|
+
const [error, setError] = useState<string | null>(null);
|
|
743
|
+
|
|
744
|
+
// Hooks - fetch balances for all chains
|
|
745
|
+
const { balances: allBalances, isLoading: isLoadingAllBalances, refetch: refetchAllBalances } = useAllUSDCBalances(chains);
|
|
746
|
+
const { balanceFormatted, refetch: refetchBalance } = useUSDCBalance(
|
|
747
|
+
sourceChainConfig
|
|
748
|
+
);
|
|
749
|
+
const { needsApproval, approve, isApproving } = useUSDCAllowance(
|
|
750
|
+
sourceChainConfig
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
// Combined refetch for all balances
|
|
754
|
+
const refetchBalances = useCallback(() => {
|
|
755
|
+
refetchBalance();
|
|
756
|
+
refetchAllBalances();
|
|
757
|
+
}, [refetchBalance, refetchAllBalances]);
|
|
758
|
+
|
|
759
|
+
// Bridge hook
|
|
760
|
+
const { bridge: executeBridge, state: bridgeState, reset: resetBridge } = useBridge();
|
|
761
|
+
|
|
762
|
+
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
|
|
763
|
+
hash: txHash,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Bridge operation states
|
|
767
|
+
const isBridging = bridgeState.status === "loading" ||
|
|
768
|
+
bridgeState.status === "approving" ||
|
|
769
|
+
bridgeState.status === "burning" ||
|
|
770
|
+
bridgeState.status === "fetching-attestation" ||
|
|
771
|
+
bridgeState.status === "minting";
|
|
772
|
+
|
|
773
|
+
// Disable inputs when any operation is pending to prevent race conditions
|
|
774
|
+
const isOperationPending = isBridging || isConfirming || isApproving;
|
|
775
|
+
|
|
776
|
+
// Store callbacks in refs to avoid useEffect dependency issues
|
|
777
|
+
const onBridgeSuccessRef = useRef(onBridgeSuccess);
|
|
778
|
+
onBridgeSuccessRef.current = onBridgeSuccess;
|
|
779
|
+
const onBridgeErrorRef = useRef(onBridgeError);
|
|
780
|
+
onBridgeErrorRef.current = onBridgeError;
|
|
781
|
+
|
|
782
|
+
// Check if we need to switch chains
|
|
783
|
+
const needsChainSwitch =
|
|
784
|
+
isConnected && currentChainId !== sourceChainConfig.chain.id;
|
|
785
|
+
|
|
786
|
+
// Swap chains
|
|
787
|
+
const handleSwapChains = useCallback(() => {
|
|
788
|
+
setSourceChainConfig((prev) => {
|
|
789
|
+
setDestChainConfig(prev);
|
|
790
|
+
return destChainConfig;
|
|
791
|
+
});
|
|
792
|
+
}, [destChainConfig]);
|
|
793
|
+
|
|
794
|
+
// Handle max click
|
|
795
|
+
const handleMaxClick = useCallback(() => {
|
|
796
|
+
setAmount(balanceFormatted);
|
|
797
|
+
}, [balanceFormatted]);
|
|
798
|
+
|
|
799
|
+
// Handle chain switch
|
|
800
|
+
const handleSwitchChain = useCallback(async () => {
|
|
801
|
+
try {
|
|
802
|
+
await switchChainAsync({ chainId: sourceChainConfig.chain.id });
|
|
803
|
+
} catch (err) {
|
|
804
|
+
setError(getErrorMessage(err));
|
|
805
|
+
}
|
|
806
|
+
}, [switchChainAsync, sourceChainConfig.chain.id]);
|
|
807
|
+
|
|
808
|
+
// Handle bridge
|
|
809
|
+
const handleBridge = useCallback(async () => {
|
|
810
|
+
if (!address || !amount || parseFloat(amount) <= 0) return;
|
|
811
|
+
|
|
812
|
+
setError(null);
|
|
813
|
+
resetBridge();
|
|
814
|
+
|
|
815
|
+
try {
|
|
816
|
+
// Notify that bridge is starting
|
|
817
|
+
onBridgeStart?.({
|
|
818
|
+
sourceChainId: sourceChainConfig.chain.id,
|
|
819
|
+
destChainId: destChainConfig.chain.id,
|
|
820
|
+
amount,
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
if (needsApproval(amount)) {
|
|
824
|
+
// Store pending bridge info for after approval
|
|
825
|
+
pendingBridgeRef.current = {
|
|
826
|
+
amount,
|
|
827
|
+
sourceChainConfig,
|
|
828
|
+
destChainConfig,
|
|
829
|
+
};
|
|
830
|
+
// First approve, then bridge will be triggered after approval
|
|
831
|
+
const approveTx = await approve(amount);
|
|
832
|
+
setTxHash(approveTx);
|
|
833
|
+
} else {
|
|
834
|
+
// Already approved, execute bridge directly
|
|
835
|
+
await executeBridge({
|
|
836
|
+
sourceChainConfig,
|
|
837
|
+
destChainConfig,
|
|
838
|
+
amount,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
} catch (err: unknown) {
|
|
842
|
+
const errorMessage = getErrorMessage(err);
|
|
843
|
+
setError(errorMessage);
|
|
844
|
+
onBridgeErrorRef.current?.(
|
|
845
|
+
err instanceof Error ? err : new Error(errorMessage)
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}, [
|
|
849
|
+
address,
|
|
850
|
+
amount,
|
|
851
|
+
needsApproval,
|
|
852
|
+
approve,
|
|
853
|
+
executeBridge,
|
|
854
|
+
resetBridge,
|
|
855
|
+
onBridgeStart,
|
|
856
|
+
sourceChainConfig,
|
|
857
|
+
destChainConfig,
|
|
858
|
+
]);
|
|
859
|
+
|
|
860
|
+
// Store amount in ref for bridge execution after approval
|
|
861
|
+
const pendingBridgeRef = useRef<{
|
|
862
|
+
amount: string;
|
|
863
|
+
sourceChainConfig: BridgeChainConfig;
|
|
864
|
+
destChainConfig: BridgeChainConfig;
|
|
865
|
+
} | null>(null);
|
|
866
|
+
|
|
867
|
+
// After approval success, execute the bridge
|
|
868
|
+
useEffect(() => {
|
|
869
|
+
if (isSuccess && txHash && pendingBridgeRef.current) {
|
|
870
|
+
const { amount: pendingAmount, sourceChainConfig: pendingSource, destChainConfig: pendingDest } = pendingBridgeRef.current;
|
|
871
|
+
pendingBridgeRef.current = null;
|
|
872
|
+
setTxHash(undefined);
|
|
873
|
+
|
|
874
|
+
// Execute bridge after approval
|
|
875
|
+
void executeBridge({
|
|
876
|
+
sourceChainConfig: pendingSource,
|
|
877
|
+
destChainConfig: pendingDest,
|
|
878
|
+
amount: pendingAmount,
|
|
879
|
+
}).catch((err) => {
|
|
880
|
+
setError(getErrorMessage(err));
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}, [isSuccess, txHash, executeBridge]);
|
|
884
|
+
|
|
885
|
+
// Handle bridge state changes
|
|
886
|
+
useEffect(() => {
|
|
887
|
+
if (bridgeState.status === "success") {
|
|
888
|
+
const currentAmount = amount;
|
|
889
|
+
const currentSourceChainId = sourceChainConfig.chain.id;
|
|
890
|
+
const currentDestChainId = destChainConfig.chain.id;
|
|
891
|
+
const currentTxHash = bridgeState.txHash;
|
|
892
|
+
|
|
893
|
+
setAmount("");
|
|
894
|
+
refetchBalances();
|
|
895
|
+
|
|
896
|
+
if (currentTxHash) {
|
|
897
|
+
onBridgeSuccessRef.current?.({
|
|
898
|
+
sourceChainId: currentSourceChainId,
|
|
899
|
+
destChainId: currentDestChainId,
|
|
900
|
+
amount: currentAmount,
|
|
901
|
+
txHash: currentTxHash,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
} else if (bridgeState.status === "error" && bridgeState.error) {
|
|
905
|
+
setError(bridgeState.error.message);
|
|
906
|
+
}
|
|
907
|
+
}, [
|
|
908
|
+
bridgeState.status,
|
|
909
|
+
bridgeState.txHash,
|
|
910
|
+
bridgeState.error,
|
|
911
|
+
refetchBalances,
|
|
912
|
+
amount,
|
|
913
|
+
sourceChainConfig.chain.id,
|
|
914
|
+
destChainConfig.chain.id,
|
|
915
|
+
]);
|
|
916
|
+
|
|
917
|
+
// Computed disabled state
|
|
918
|
+
const isButtonDisabled =
|
|
919
|
+
!isConnected ||
|
|
920
|
+
needsChainSwitch ||
|
|
921
|
+
!amount ||
|
|
922
|
+
parseFloat(amount) <= 0 ||
|
|
923
|
+
parseFloat(amount) > parseFloat(balanceFormatted) ||
|
|
924
|
+
isConfirming ||
|
|
925
|
+
isApproving ||
|
|
926
|
+
isBridging;
|
|
927
|
+
|
|
928
|
+
const isButtonActuallyDisabled =
|
|
929
|
+
isButtonDisabled && !needsChainSwitch && isConnected;
|
|
930
|
+
|
|
931
|
+
const getButtonText = useCallback(() => {
|
|
932
|
+
if (!isConnected) return "Connect Wallet";
|
|
933
|
+
if (needsChainSwitch) return `Switch to ${sourceChainConfig.chain.name}`;
|
|
934
|
+
|
|
935
|
+
// Bridge states
|
|
936
|
+
if (bridgeState.status === "loading") return "Preparing Bridge...";
|
|
937
|
+
if (bridgeState.status === "approving") return "Approving...";
|
|
938
|
+
if (bridgeState.status === "burning") return "Burning USDC...";
|
|
939
|
+
if (bridgeState.status === "fetching-attestation") return "Fetching Attestation...";
|
|
940
|
+
if (bridgeState.status === "minting") return "Minting on Destination...";
|
|
941
|
+
|
|
942
|
+
// Approval states
|
|
943
|
+
if (isConfirming || isApproving) {
|
|
944
|
+
return "Approving...";
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (!amount || parseFloat(amount) <= 0) return "Enter Amount";
|
|
948
|
+
if (parseFloat(amount) > parseFloat(balanceFormatted)) {
|
|
949
|
+
return "Insufficient Balance";
|
|
950
|
+
}
|
|
951
|
+
if (needsApproval(amount)) return "Approve & Bridge USDC";
|
|
952
|
+
return "Bridge USDC";
|
|
953
|
+
}, [
|
|
954
|
+
isConnected,
|
|
955
|
+
needsChainSwitch,
|
|
956
|
+
sourceChainConfig.chain.name,
|
|
957
|
+
bridgeState.status,
|
|
958
|
+
isConfirming,
|
|
959
|
+
isApproving,
|
|
960
|
+
amount,
|
|
961
|
+
balanceFormatted,
|
|
962
|
+
needsApproval,
|
|
963
|
+
]);
|
|
964
|
+
|
|
965
|
+
const handleButtonClick = useCallback(() => {
|
|
966
|
+
if (!isConnected) {
|
|
967
|
+
if (onConnectWallet) {
|
|
968
|
+
onConnectWallet();
|
|
969
|
+
} else if (connectors.length > 0) {
|
|
970
|
+
// Use the first injected connector if available, otherwise first connector
|
|
971
|
+
const injectedConnector = connectors.find(
|
|
972
|
+
(c) => c.type === "injected"
|
|
973
|
+
);
|
|
974
|
+
connect({ connector: injectedConnector || connectors[0] });
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (needsChainSwitch) {
|
|
979
|
+
handleSwitchChain();
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
handleBridge();
|
|
983
|
+
}, [
|
|
984
|
+
isConnected,
|
|
985
|
+
onConnectWallet,
|
|
986
|
+
connectors,
|
|
987
|
+
connect,
|
|
988
|
+
needsChainSwitch,
|
|
989
|
+
handleSwitchChain,
|
|
990
|
+
handleBridge,
|
|
991
|
+
]);
|
|
992
|
+
|
|
993
|
+
// Memoize button styles to avoid recalculation on every render
|
|
994
|
+
const buttonStyles = useMemo(
|
|
995
|
+
() => ({
|
|
996
|
+
width: "100%",
|
|
997
|
+
padding: "14px",
|
|
998
|
+
borderRadius: `${theme.borderRadius}px`,
|
|
999
|
+
fontSize: "14px",
|
|
1000
|
+
fontWeight: 600,
|
|
1001
|
+
border: "none",
|
|
1002
|
+
cursor: isButtonActuallyDisabled ? "not-allowed" : "pointer",
|
|
1003
|
+
transition: "all 0.2s",
|
|
1004
|
+
color: isButtonActuallyDisabled ? theme.mutedTextColor : theme.textColor,
|
|
1005
|
+
background: isButtonActuallyDisabled
|
|
1006
|
+
? "rgba(255,255,255,0.1)"
|
|
1007
|
+
: `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.secondaryColor} 100%)`,
|
|
1008
|
+
boxShadow: isButtonActuallyDisabled
|
|
1009
|
+
? "none"
|
|
1010
|
+
: `0 4px 14px ${theme.primaryColor}60, inset 0 1px 0 rgba(255,255,255,0.2)`,
|
|
1011
|
+
}),
|
|
1012
|
+
[
|
|
1013
|
+
theme.borderRadius,
|
|
1014
|
+
theme.mutedTextColor,
|
|
1015
|
+
theme.textColor,
|
|
1016
|
+
theme.primaryColor,
|
|
1017
|
+
theme.secondaryColor,
|
|
1018
|
+
isButtonActuallyDisabled,
|
|
1019
|
+
]
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
return (
|
|
1023
|
+
<div
|
|
1024
|
+
className={className}
|
|
1025
|
+
role="region"
|
|
1026
|
+
aria-label="USDC Bridge Widget"
|
|
1027
|
+
style={{
|
|
1028
|
+
fontFamily: theme.fontFamily,
|
|
1029
|
+
maxWidth: "480px",
|
|
1030
|
+
width: "100%",
|
|
1031
|
+
borderRadius: `${theme.borderRadius}px`,
|
|
1032
|
+
padding: "16px",
|
|
1033
|
+
background: theme.backgroundColor,
|
|
1034
|
+
border: `1px solid ${theme.borderColor}`,
|
|
1035
|
+
boxShadow: "0 4px 24px rgba(0,0,0,0.3)",
|
|
1036
|
+
...style,
|
|
1037
|
+
}}
|
|
1038
|
+
>
|
|
1039
|
+
{/* Chain Selectors */}
|
|
1040
|
+
<div
|
|
1041
|
+
style={{
|
|
1042
|
+
display: "flex",
|
|
1043
|
+
alignItems: "flex-end",
|
|
1044
|
+
gap: "12px",
|
|
1045
|
+
marginBottom: "16px",
|
|
1046
|
+
}}
|
|
1047
|
+
>
|
|
1048
|
+
<ChainSelector
|
|
1049
|
+
id={sourceChainId}
|
|
1050
|
+
label="From"
|
|
1051
|
+
chains={chains}
|
|
1052
|
+
selectedChain={sourceChainConfig}
|
|
1053
|
+
onSelect={setSourceChainConfig}
|
|
1054
|
+
excludeChainId={destChainConfig.chain.id}
|
|
1055
|
+
theme={theme}
|
|
1056
|
+
balances={allBalances}
|
|
1057
|
+
isLoadingBalances={isLoadingAllBalances}
|
|
1058
|
+
disabled={isOperationPending}
|
|
1059
|
+
/>
|
|
1060
|
+
<SwapButton onClick={handleSwapChains} theme={theme} disabled={isOperationPending} />
|
|
1061
|
+
<ChainSelector
|
|
1062
|
+
id={destChainId}
|
|
1063
|
+
label="To"
|
|
1064
|
+
chains={chains}
|
|
1065
|
+
selectedChain={destChainConfig}
|
|
1066
|
+
onSelect={setDestChainConfig}
|
|
1067
|
+
excludeChainId={sourceChainConfig.chain.id}
|
|
1068
|
+
theme={theme}
|
|
1069
|
+
balances={allBalances}
|
|
1070
|
+
isLoadingBalances={isLoadingAllBalances}
|
|
1071
|
+
disabled={isOperationPending}
|
|
1072
|
+
/>
|
|
1073
|
+
</div>
|
|
1074
|
+
|
|
1075
|
+
{/* Amount Input */}
|
|
1076
|
+
<div style={{ marginBottom: "16px" }}>
|
|
1077
|
+
<AmountInput
|
|
1078
|
+
id={amountId}
|
|
1079
|
+
value={amount}
|
|
1080
|
+
onChange={setAmount}
|
|
1081
|
+
balance={balanceFormatted}
|
|
1082
|
+
onMaxClick={handleMaxClick}
|
|
1083
|
+
theme={theme}
|
|
1084
|
+
disabled={isOperationPending}
|
|
1085
|
+
/>
|
|
1086
|
+
</div>
|
|
1087
|
+
|
|
1088
|
+
{/* Config Error */}
|
|
1089
|
+
{configError && (
|
|
1090
|
+
<div
|
|
1091
|
+
role="alert"
|
|
1092
|
+
style={{
|
|
1093
|
+
fontSize: "12px",
|
|
1094
|
+
color: theme.errorColor,
|
|
1095
|
+
background: `${theme.errorColor}15`,
|
|
1096
|
+
padding: "8px 12px",
|
|
1097
|
+
borderRadius: `${theme.borderRadius}px`,
|
|
1098
|
+
marginBottom: "16px",
|
|
1099
|
+
}}
|
|
1100
|
+
>
|
|
1101
|
+
Configuration Error: {configError}
|
|
1102
|
+
</div>
|
|
1103
|
+
)}
|
|
1104
|
+
|
|
1105
|
+
{/* Error */}
|
|
1106
|
+
{error && !configError && (
|
|
1107
|
+
<div
|
|
1108
|
+
role="alert"
|
|
1109
|
+
style={{
|
|
1110
|
+
fontSize: "12px",
|
|
1111
|
+
color: theme.errorColor,
|
|
1112
|
+
background: `${theme.errorColor}15`,
|
|
1113
|
+
padding: "8px 12px",
|
|
1114
|
+
borderRadius: `${theme.borderRadius}px`,
|
|
1115
|
+
marginBottom: "16px",
|
|
1116
|
+
}}
|
|
1117
|
+
>
|
|
1118
|
+
{error}
|
|
1119
|
+
</div>
|
|
1120
|
+
)}
|
|
1121
|
+
|
|
1122
|
+
{/* Action Button */}
|
|
1123
|
+
<button
|
|
1124
|
+
onClick={handleButtonClick}
|
|
1125
|
+
disabled={isButtonActuallyDisabled}
|
|
1126
|
+
aria-busy={isConfirming || isApproving || isBridging}
|
|
1127
|
+
style={buttonStyles}
|
|
1128
|
+
>
|
|
1129
|
+
{getButtonText()}
|
|
1130
|
+
</button>
|
|
1131
|
+
</div>
|
|
1132
|
+
);
|
|
1133
|
+
}
|