@xswap-link/sdk 0.14.0 → 0.14.1

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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.d.mts +4 -0
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.global.js +86 -86
  5. package/dist/index.js +2129 -1857
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +2225 -1953
  8. package/dist/index.mjs.map +1 -1
  9. package/localTheme.ts +5 -3
  10. package/package.json +1 -1
  11. package/src/assets/icons/AdjustmentsIcon.tsx +5 -7
  12. package/src/assets/icons/ClockIcon.tsx +3 -7
  13. package/src/assets/icons/CloseIcon.tsx +6 -2
  14. package/src/assets/icons/WarningIcon.tsx +8 -5
  15. package/src/components/Modal/index.tsx +14 -9
  16. package/src/components/PoweredBy/index.tsx +6 -8
  17. package/src/components/Swap/PickerView/index.tsx +151 -0
  18. package/src/components/Swap/SettingsView/Delivery/index.tsx +1 -1
  19. package/src/components/Swap/SwapView/ConfirmationView/TxOverview/index.tsx +50 -4
  20. package/src/components/Swap/SwapView/FeesPanel/DeliveryInfo/index.tsx +1 -1
  21. package/src/components/Swap/SwapView/FeesPanel/index.tsx +1 -1
  22. package/src/components/Swap/SwapView/SwapButton/index.tsx +2 -2
  23. package/src/components/Swap/SwapView/SwapPanel/AmountPanel/Balance/index.tsx +2 -2
  24. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/ChainPicker/index.tsx +143 -0
  25. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/NetworkFilter/index.tsx +8 -6
  26. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/TokenItem/index.tsx +11 -4
  27. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/index.tsx +102 -73
  28. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/index.tsx +53 -119
  29. package/src/components/Swap/SwapView/SwapPanel/index.tsx +1 -1
  30. package/src/components/Swap/index.tsx +18 -4
  31. package/src/components/TxConfigForm/index.tsx +2 -2
  32. package/src/context/HistoryProvider.tsx +1 -1
  33. package/src/context/SwapProvider.tsx +87 -62
  34. package/src/context/TxUIWrapper.tsx +3 -3
  35. package/src/hooks/useEvmContractApi.ts +28 -4
  36. package/src/models/payloads/GetBalancesPayload.ts +1 -0
  37. package/src/services/api.ts +37 -0
  38. package/src/utils/index.ts +27 -1
  39. package/src/utils/tokens.ts +20 -1
  40. package/tailwind.config.js +4 -1
@@ -56,7 +56,7 @@ export const NetworkFilter = ({ type, selectedChain, onSelect }: Props) => {
56
56
  return () => document.removeEventListener("mousedown", onPointerDown);
57
57
  }, []);
58
58
 
59
- const previewChains = chains.slice(0, 4);
59
+ const previewChains = chains.slice(0, 3);
60
60
 
61
61
  return (
62
62
  <div className="relative" ref={containerRef}>
@@ -72,19 +72,21 @@ export const NetworkFilter = ({ type, selectedChain, onSelect }: Props) => {
72
72
  className="w-5 h-5 rounded-full"
73
73
  />
74
74
  ) : (
75
- <div className="grid grid-cols-2 gap-0.5 w-6 shrink-0">
75
+ <div className="flex -space-x-1.5 shrink-0">
76
76
  {previewChains.map((chain) => (
77
77
  <img
78
78
  key={chain.chainId}
79
79
  src={chain.image}
80
80
  alt={chain.displayName}
81
- className="w-[11px] h-[11px] rounded-full"
81
+ className="w-5 h-5 rounded-full border border-solid border-t_bg_primary bg-t_bg_primary"
82
82
  />
83
83
  ))}
84
84
  </div>
85
85
  )}
86
86
  <p className="text-sm leading-6 tracking-[0.01em] text-t_text_primary whitespace-nowrap max-w-[120px] truncate">
87
- {selectedChain ? selectedChain.displayName : "All Networks"}
87
+ {selectedChain
88
+ ? selectedChain.displayName
89
+ : `${chains.length} Networks`}
88
90
  </p>
89
91
  <div
90
92
  className={`flex items-center justify-center w-6 h-6 fill-t_text_primary opacity-50 transition-transform [&_svg]:w-3 [&_svg]:h-3 ${
@@ -132,13 +134,13 @@ export const NetworkFilter = ({ type, selectedChain, onSelect }: Props) => {
132
134
  className="w-5 h-5 rounded-full"
133
135
  />
134
136
  ) : (
135
- <div className="grid grid-cols-2 gap-px w-5 shrink-0">
137
+ <div className="flex -space-x-1 shrink-0">
136
138
  {previewChains.map((previewChain) => (
137
139
  <img
138
140
  key={previewChain.chainId}
139
141
  src={previewChain.image}
140
142
  alt={previewChain.displayName}
141
- className="w-[9px] h-[9px] rounded-full"
143
+ className="w-4 h-4 rounded-full border border-solid border-t_bg_primary bg-t_bg_primary"
142
144
  />
143
145
  ))}
144
146
  </div>
@@ -69,7 +69,7 @@ export const TokenItem = ({
69
69
  return (
70
70
  <div className="pb-2">
71
71
  <div
72
- className={`flex gap-3 items-center p-3 sm:p-4 rounded-xl cursor-pointer bg-gradient-to-r from-t_bg_tertiary/[0.02] to-t_bg_tertiary/[0.02] bg-t_bg_secondary bg-opacity-15 hover:bg-t_bg_tertiary hover:bg-opacity-10 transition-colors ${
72
+ className={`flex gap-3 items-center p-3 sm:p-4 rounded-xl cursor-pointer bg-gradient-to-r from-t_bg_tertiary/[0.02] to-t_bg_tertiary/[0.02] bg-[rgba(40,40,40,0.15)] hover:bg-t_bg_tertiary hover:bg-opacity-10 transition-colors ${
73
73
  isSelected ? "!bg-t_bg_tertiary !bg-opacity-10" : ""
74
74
  }`}
75
75
  onClick={onClick}
@@ -142,9 +142,16 @@ export const TokenItem = ({
142
142
  <div className="flex items-center justify-end gap-1.5 text-[#D77F02]">
143
143
  <WarningIcon className="w-3.5 h-3.5 shrink-0" />
144
144
  <span className="text-xs leading-[18px] tracking-[0.01em] whitespace-nowrap">
145
- {resetWarning === "source"
146
- ? "Source will reset after selection."
147
- : "Destination will reset after selection."}
145
+ <span className="sm:hidden">
146
+ {resetWarning === "source"
147
+ ? "Source will reset."
148
+ : "Destination will reset."}
149
+ </span>
150
+ <span className="hidden sm:inline">
151
+ {resetWarning === "source"
152
+ ? "Source will reset after selection."
153
+ : "Destination will reset after selection."}
154
+ </span>
148
155
  </span>
149
156
  </div>
150
157
  ) : (
@@ -9,10 +9,9 @@ import {
9
9
  } from "@src/components";
10
10
  import { SwapPanelType } from "@src/components/Swap/SwapView";
11
11
  import { useSwapContext } from "@src/context";
12
- import { ERC20Abi } from "@src/contracts";
13
- import { useEvmContractApi } from "@src/hooks";
14
12
  import { Chain, Ecosystem, ImportedTokenData, TokenOption } from "@src/models";
15
13
  import {
14
+ isBridgeTokenConnectionSupported,
16
15
  isChainConnectionSupported,
17
16
  isETHAddressValid,
18
17
  isServer,
@@ -28,6 +27,7 @@ import { useAccount } from "wagmi";
28
27
  import { Connection, PublicKey } from "@solana/web3.js";
29
28
  import { METADATA_PROGRAM_ID } from "@src/constants";
30
29
  import { useWallet } from "@src/context/WalletProvider";
30
+ import { getTokenMetadata } from "@src/services/api";
31
31
 
32
32
  type Props = {
33
33
  type: SwapPanelType;
@@ -67,21 +67,26 @@ export const TokenPicker = ({
67
67
  findTokenDataByAddressAndChain,
68
68
  tokenPrices,
69
69
  addTokenToChainIfNotExists,
70
+ bridgeTokensDictionary,
70
71
  } = useSwapContext();
71
72
  const { address } = useAccount();
72
73
  const {
73
74
  solana: { address: solanaAddress },
74
75
  } = useWallet();
75
- const evmContractApi = useEvmContractApi();
76
76
 
77
77
  useEffect(() => {
78
78
  setShowImportWarning(false);
79
79
  setAcceptRisks(false);
80
80
  }, [isOpen]);
81
81
 
82
+ // Custom token imports follow the network filter, not the chain currently
83
+ // set on the swap panel — the picker lists tokens across networks, so a
84
+ // pasted address is resolved on the network the user is browsing.
85
+ const importChain = networkFilter ?? chain;
86
+
82
87
  const importCustomToken = useCallback(() => {
83
88
  if (
84
- chain &&
89
+ importChain &&
85
90
  customTokenData &&
86
91
  (isETHAddressValid(searchValue) || isSolanaAddressValid(searchValue))
87
92
  ) {
@@ -101,12 +106,12 @@ export const TokenPicker = ({
101
106
  quickPick: false,
102
107
  supported: true,
103
108
  priority: 0,
104
- chainId: chain.chainId,
109
+ chainId: importChain.chainId,
105
110
  };
106
111
 
107
112
  if (!isServer) {
108
- customTokens[chain.chainId] = {
109
- ...(customTokens[chain.chainId] || {}),
113
+ customTokens[importChain.chainId] = {
114
+ ...(customTokens[importChain.chainId] || {}),
110
115
  [searchValue.toLowerCase()]: newCustomToken,
111
116
  };
112
117
  localStorage.setItem("custom-tokens", JSON.stringify(customTokens));
@@ -117,7 +122,7 @@ export const TokenPicker = ({
117
122
  setModalOpen(false);
118
123
  }
119
124
  }, [
120
- chain,
125
+ importChain,
121
126
  customTokenData,
122
127
  searchValue,
123
128
  addTokenToChainIfNotExists,
@@ -125,42 +130,32 @@ export const TokenPicker = ({
125
130
  ]);
126
131
 
127
132
  const getCustomTokenData = useCallback(async () => {
133
+ if (!importChain) {
134
+ return;
135
+ }
128
136
  const nonce = Date.now();
129
137
  currentRouteRequestNonce.current = nonce;
130
- const rpcUrl = supportedChains.find(
131
- ({ ecosystem, chainId }) =>
132
- ecosystem === Ecosystem.EVM && chainId === chain?.chainId,
133
- )?.publicRpcUrls[0];
134
- const customTokenContract = new ethers.Contract(
135
- searchValue.toLowerCase(),
136
- ERC20Abi,
137
- ethers.getDefaultProvider(rpcUrl),
138
- );
139
138
  setLoadingCustomTokenData(true);
140
139
  try {
141
- const responses = await Promise.all([
142
- evmContractApi.query(customTokenContract, "decimals"),
143
- evmContractApi.query(customTokenContract, "name"),
144
- evmContractApi.query(customTokenContract, "symbol"),
145
- ]);
140
+ // Resolved server-side through private RPCs — public ones often 429
141
+ const tokenData = await getTokenMetadata({
142
+ chainId: importChain.chainId,
143
+ tokenAddress: searchValue.toLowerCase(),
144
+ });
146
145
  if (nonce !== currentRouteRequestNonce.current) {
147
146
  return;
148
147
  }
149
- const [decimals, name, symbol] = responses;
150
- setCustomTokenData({
151
- address: searchValue.toLowerCase(),
152
- decimals,
153
- name,
154
- symbol,
155
- });
148
+ setCustomTokenData(tokenData);
156
149
  } catch (_) {
157
- setCustomTokenData(null);
150
+ if (nonce === currentRouteRequestNonce.current) {
151
+ setCustomTokenData(null);
152
+ }
158
153
  } finally {
159
154
  if (nonce === currentRouteRequestNonce.current) {
160
155
  setLoadingCustomTokenData(false);
161
156
  }
162
157
  }
163
- }, [chain, searchValue, evmContractApi, supportedChains]);
158
+ }, [importChain, searchValue]);
164
159
 
165
160
  const getCustomSolanaTokenData = useCallback(async () => {
166
161
  const nonce = Date.now();
@@ -168,7 +163,7 @@ export const TokenPicker = ({
168
163
 
169
164
  const rpcUrl = supportedChains.find(
170
165
  ({ ecosystem, chainId }) =>
171
- ecosystem === Ecosystem.SOLANA && chainId === chain?.chainId,
166
+ ecosystem === Ecosystem.SOLANA && chainId === importChain?.chainId,
172
167
  )?.publicRpcUrls[0];
173
168
 
174
169
  if (!rpcUrl || !searchValue || !isSolanaAddressValid(searchValue)) {
@@ -258,23 +253,26 @@ export const TokenPicker = ({
258
253
  setLoadingCustomTokenData(false);
259
254
  }
260
255
  }
261
- }, [chain, searchValue, supportedChains]);
256
+ }, [importChain, searchValue, supportedChains]);
262
257
 
263
258
  useEffect(() => {
264
259
  if (
265
260
  !bridgeUI &&
266
- chain &&
261
+ importChain &&
267
262
  !findTokenDataByAddressAndChain(
268
- chain.ecosystem === Ecosystem.EVM
263
+ importChain.ecosystem === Ecosystem.EVM
269
264
  ? searchValue.toLowerCase()
270
265
  : searchValue,
271
- chain.chainId,
266
+ importChain.chainId,
272
267
  )
273
268
  ) {
274
- if (chain.ecosystem === Ecosystem.EVM && isETHAddressValid(searchValue)) {
269
+ if (
270
+ importChain.ecosystem === Ecosystem.EVM &&
271
+ isETHAddressValid(searchValue)
272
+ ) {
275
273
  getCustomTokenData();
276
274
  } else if (
277
- chain.ecosystem === Ecosystem.SOLANA &&
275
+ importChain.ecosystem === Ecosystem.SOLANA &&
278
276
  isSolanaAddressValid(searchValue)
279
277
  ) {
280
278
  getCustomSolanaTokenData();
@@ -292,7 +290,7 @@ export const TokenPicker = ({
292
290
  getCustomTokenData,
293
291
  getCustomSolanaTokenData,
294
292
  findTokenDataByAddressAndChain,
295
- chain,
293
+ importChain,
296
294
  ]);
297
295
 
298
296
  useEffect(() => {
@@ -330,6 +328,47 @@ export const TokenPicker = ({
330
328
  [tokenPrices],
331
329
  );
332
330
 
331
+ const getResetWarning = useCallback(
332
+ (token: TokenOption): SwapPanelType | null => {
333
+ if (type === "destination") {
334
+ if (
335
+ srcChain &&
336
+ !isChainConnectionSupported(srcChain, token.chainId, bridgeUI)
337
+ ) {
338
+ return "source";
339
+ }
340
+ return null;
341
+ }
342
+ const tokenChain = supportedChains.find(
343
+ ({ chainId }) => chainId === token.chainId,
344
+ );
345
+ if (
346
+ dstChain &&
347
+ tokenChain &&
348
+ (!isChainConnectionSupported(tokenChain, dstChain.chainId, bridgeUI) ||
349
+ (bridgeUI &&
350
+ !isBridgeTokenConnectionSupported({
351
+ token,
352
+ tokenChain,
353
+ dstChainId: dstChain.chainId,
354
+ bridgeTokensDictionary,
355
+ })))
356
+ ) {
357
+ return "destination";
358
+ }
359
+ return null;
360
+ },
361
+ [type, srcChain, dstChain, bridgeUI, supportedChains, bridgeTokensDictionary],
362
+ );
363
+
364
+ // Tokens that reset the other side of the swap on selection go below
365
+ // the ones that can be picked freely.
366
+ const compareByResetWarning = useCallback(
367
+ (tokenA: TokenOption, tokenB: TokenOption) =>
368
+ Number(!!getResetWarning(tokenA)) - Number(!!getResetWarning(tokenB)),
369
+ [getResetWarning],
370
+ );
371
+
333
372
  const ownedTokens = useMemo(() => {
334
373
  if (!address && !solanaAddress) {
335
374
  return [];
@@ -337,6 +376,10 @@ export const TokenPicker = ({
337
376
  return visibleTokens
338
377
  .filter((token) => BigNumber.from(token?.balance || "0").gt(0))
339
378
  .sort((tokenA, tokenB) => {
379
+ const warningOrder = compareByResetWarning(tokenA, tokenB);
380
+ if (warningOrder !== 0) {
381
+ return warningOrder;
382
+ }
340
383
  const valueA = calculateTokenValue(tokenA);
341
384
  const valueB = calculateTokenValue(tokenB);
342
385
  // First sort by USD value
@@ -346,7 +389,13 @@ export const TokenPicker = ({
346
389
  // If USD values are equal (or both 0), sort by priority
347
390
  return tokenB.priority - tokenA.priority;
348
391
  });
349
- }, [visibleTokens, address, solanaAddress, calculateTokenValue]);
392
+ }, [
393
+ visibleTokens,
394
+ address,
395
+ solanaAddress,
396
+ calculateTokenValue,
397
+ compareByResetWarning,
398
+ ]);
350
399
 
351
400
  const recommendedTokens = useMemo(
352
401
  () =>
@@ -357,9 +406,11 @@ export const TokenPicker = ({
357
406
  BigNumber.from(token?.balance || "0").eq(0),
358
407
  )
359
408
  .sort(
360
- (tokenDataA, tokenDataB) => tokenDataB.priority - tokenDataA.priority,
409
+ (tokenDataA, tokenDataB) =>
410
+ compareByResetWarning(tokenDataA, tokenDataB) ||
411
+ tokenDataB.priority - tokenDataA.priority,
361
412
  ),
362
- [visibleTokens, address, solanaAddress],
413
+ [visibleTokens, address, solanaAddress, compareByResetWarning],
363
414
  );
364
415
 
365
416
  const tokenGroups = useMemo(() => {
@@ -376,32 +427,6 @@ export const TokenPicker = ({
376
427
  [tokenGroups],
377
428
  );
378
429
 
379
- const getResetWarning = useCallback(
380
- (token: TokenOption): SwapPanelType | null => {
381
- if (type === "destination") {
382
- if (
383
- srcChain &&
384
- !isChainConnectionSupported(srcChain, token.chainId, bridgeUI)
385
- ) {
386
- return "source";
387
- }
388
- return null;
389
- }
390
- const tokenChain = supportedChains.find(
391
- ({ chainId }) => chainId === token.chainId,
392
- );
393
- if (
394
- dstChain &&
395
- tokenChain &&
396
- !isChainConnectionSupported(tokenChain, dstChain.chainId, bridgeUI)
397
- ) {
398
- return "destination";
399
- }
400
- return null;
401
- },
402
- [type, srcChain, dstChain, bridgeUI, supportedChains],
403
- );
404
-
405
430
  useEffect(() => {
406
431
  if (isOpen && tokenSearchRef.current) {
407
432
  setTimeout(() => {
@@ -549,7 +574,7 @@ export const TokenPicker = ({
549
574
  <div className="flex gap-3 py-3 px-4 items-center rounded-xl bg-t_bg_tertiary bg-opacity-[0.03]">
550
575
  <TokenLogoWithChain
551
576
  token={customTokenData}
552
- chain={chain}
577
+ chain={importChain}
553
578
  tokenLogoClassName="w-9 h-9"
554
579
  generatedLogoClassName="w-9 h-9 text-[16px]"
555
580
  chainLogoClassName="w-5"
@@ -560,7 +585,7 @@ export const TokenPicker = ({
560
585
  {customTokenData.name}
561
586
  </div>
562
587
  <div className="text-xs text-t_text_primary !text-opacity-50 font-medium">
563
- {chain?.displayName}
588
+ {importChain?.displayName}
564
589
  </div>
565
590
  </div>
566
591
  <div className="text-sm text-t_text_primary text-opacity-50">
@@ -583,12 +608,16 @@ export const TokenPicker = ({
583
608
  style={{ maxHeight: "700px", height: "100%" }}
584
609
  groupCounts={tokenGroups.map((group) => group.tokens.length)}
585
610
  groupContent={(index) => (
586
- <div className="pb-3 bg-t_bg_primary">
587
- <p className="text-[10px] font-medium leading-4 tracking-[0.015em] text-t_text_primary text-opacity-30 text-left">
611
+ // Sticky section label: a near-opaque, blurred, widget-glass-colored
612
+ // band rows dissolve under it while scrolling instead
613
+ // of showing through, and the band still picks up a hint
614
+ // of the page glow like the glass around it.
615
+ <div className="backdrop-blur-xl bg-gradient-to-r from-t_bg_secondary/40 to-t_bg_secondary/40 bg-t_bg_primary bg-opacity-75">
616
+ <p className="pb-3 text-[10px] font-medium leading-4 tracking-[0.015em] text-t_text_primary text-opacity-30 text-left">
588
617
  {tokenGroups[index]?.label}
589
618
  </p>
590
619
  {!tokenGroups[index]?.tokens.length && (
591
- <div className="text-center py-1 text-sm text-t_text_primary text-opacity-50">
620
+ <div className="text-center py-1 pb-3 text-sm text-t_text_primary text-opacity-50">
592
621
  No token found
593
622
  </div>
594
623
  )}
@@ -1,11 +1,9 @@
1
1
  import { ChevronDownIcon } from "@src/assets/icons";
2
- import { Modal, TokenLogoWithChain } from "@src/components";
2
+ import { TokenLogoWithChain } from "@src/components";
3
3
  import { useSwapContext } from "@src/context";
4
- import { Chain, Token, TokenOption } from "@src/models";
5
- import { isChainConnectionSupported } from "@src/utils";
6
- import { useMemo, useState } from "react";
4
+ import { Chain, Token } from "@src/models";
5
+ import { useMemo } from "react";
7
6
  import { SwapPanelType } from "../../index";
8
- import { TokenPicker } from "./TokenPicker";
9
7
 
10
8
  type Props = {
11
9
  chain: Chain | undefined;
@@ -17,19 +15,10 @@ type Props = {
17
15
  export const TokenPanel = ({ chain, token, type, className }: Props) => {
18
16
  const {
19
17
  bridgeUI,
20
- srcChain,
21
- dstChain,
22
- srcChainTokensOptions,
23
- srcChainOtherTokensOptions,
24
- dstChainTokensOptions,
25
- dstChainOtherTokensOptions,
26
- setSrcToken,
27
- setSrcChain,
28
- supportedChains,
29
- setDstToken,
30
- setDstChain,
31
18
  dstTokenLocked,
32
19
  srcTokenLocked,
20
+ dstChainLocked,
21
+ setActivePicker,
33
22
  } = useSwapContext();
34
23
 
35
24
  const integratorLockedDestinationToken = useMemo(
@@ -42,120 +31,65 @@ export const TokenPanel = ({ chain, token, type, className }: Props) => {
42
31
  [type, srcTokenLocked],
43
32
  );
44
33
 
45
- const [tokenPickerModalOpen, setTokenPickerModalOpen] = useState(false);
46
-
47
- const tokensWithBalances = useMemo(
48
- () => (type === "source" ? srcChainTokensOptions : dstChainTokensOptions),
49
- [dstChainTokensOptions, srcChainTokensOptions, type],
50
- );
51
-
52
- const pickerDisabled = useMemo(
34
+ // In bridge mode the destination token is derived from the source token,
35
+ // so the destination panel picks a chain instead of a token.
36
+ const bridgeDestination = useMemo(
53
37
  () => type === "destination" && bridgeUI,
54
38
  [bridgeUI, type],
55
39
  );
56
40
 
57
- const otherTokensWithBalances = useMemo(
58
- () =>
59
- type === "source"
60
- ? srcChainOtherTokensOptions
61
- : dstChainOtherTokensOptions,
62
- [dstChainOtherTokensOptions, srcChainOtherTokensOptions, type],
63
- );
64
-
65
- const onTokenSelect = (token: TokenOption) => {
66
- const newChain = supportedChains.find(
67
- (chain) => chain.chainId === token.chainId,
68
- );
69
-
70
- if (type === "source") {
71
- setSrcToken(token);
72
- setSrcChain(newChain);
73
- // The new source chain cannot reach the current destination —
74
- // reset the destination so the user picks a reachable one.
75
- if (
76
- dstChain &&
77
- !isChainConnectionSupported(newChain, dstChain.chainId, bridgeUI)
78
- ) {
79
- setDstChain(undefined);
80
- setDstToken(undefined);
81
- }
82
- } else {
83
- setDstToken(token);
84
- setDstChain(newChain);
85
- // The current source cannot reach the new destination chain —
86
- // reset the source so the user picks a reachable one.
87
- if (
88
- srcChain &&
89
- newChain &&
90
- !isChainConnectionSupported(srcChain, newChain.chainId, bridgeUI)
91
- ) {
92
- setSrcChain(undefined);
93
- setSrcToken(undefined);
94
- }
95
- }
96
- setTokenPickerModalOpen(false);
97
- };
98
-
99
41
  const locked =
100
- pickerDisabled ||
42
+ (bridgeDestination && dstChainLocked) ||
101
43
  integratorLockedDestinationToken ||
102
44
  integratorLockedSourceToken;
103
45
 
104
46
  return (
105
- <>
106
- {tokenPickerModalOpen && (
107
- <Modal onClose={() => setTokenPickerModalOpen(false)} className="z-[3]">
108
- <TokenPicker
109
- type={type}
110
- chain={chain!}
111
- tokens={tokensWithBalances}
112
- otherTokens={otherTokensWithBalances}
113
- selectedTokenAddress={token?.address}
114
- onSelect={onTokenSelect}
115
- isOpen={tokenPickerModalOpen}
116
- setModalOpen={setTokenPickerModalOpen}
47
+ <button
48
+ type="button"
49
+ disabled={locked}
50
+ onClick={() => {
51
+ setActivePicker(type);
52
+ }}
53
+ className={`flex items-center gap-1 shrink-0 rounded-lg ${
54
+ locked ? "" : "hover:opacity-80 transition-opacity"
55
+ } ${className || ""}`}
56
+ >
57
+ <div className="flex items-center gap-3">
58
+ <p
59
+ className={`text-sm font-medium leading-6 tracking-[0.01em] text-t_text_primary ${
60
+ token ? "" : "opacity-60"
61
+ }`}
62
+ >
63
+ {token
64
+ ? token.symbol
65
+ : bridgeDestination && chain
66
+ ? chain.displayName
67
+ : "Select"}
68
+ </p>
69
+ {token ? (
70
+ <TokenLogoWithChain
71
+ token={token}
72
+ chain={chain}
73
+ tokenLogoClassName="w-10 h-10"
74
+ generatedLogoClassName="w-10 h-10 text-[16px]"
75
+ chainLogoClassName="w-[18px]"
117
76
  />
118
- </Modal>
119
- )}
120
-
121
- <button
122
- type="button"
123
- disabled={locked}
124
- onClick={() => {
125
- setTokenPickerModalOpen(true);
126
- }}
127
- className={`flex items-center gap-1 shrink-0 rounded-lg ${
128
- locked ? "" : "hover:opacity-80 transition-opacity"
129
- } ${className || ""}`}
130
- >
131
- <div className="flex items-center gap-3">
132
- <p
133
- className={`text-sm font-medium leading-6 tracking-[0.01em] text-t_text_primary ${
134
- token ? "" : "opacity-60"
135
- }`}
136
- >
137
- {token
138
- ? token.symbol
139
- : type === "destination" && bridgeUI
140
- ? "-"
141
- : "Select"}
142
- </p>
143
- {token && (
144
- <TokenLogoWithChain
145
- token={token}
146
- chain={chain}
147
- tokenLogoClassName="w-10 h-10"
148
- generatedLogoClassName="w-10 h-10 text-[16px]"
149
- chainLogoClassName="w-[18px]"
77
+ ) : (
78
+ bridgeDestination &&
79
+ chain && (
80
+ <img
81
+ src={chain.image}
82
+ alt={chain.displayName}
83
+ className="w-10 h-10 rounded-full"
150
84
  />
151
- )}
152
- </div>
153
- {!locked && (
154
- <div className="flex items-center justify-center w-6 h-6 fill-t_text_primary opacity-50 [&_svg]:w-3 [&_svg]:h-3">
155
- <ChevronDownIcon />
156
- </div>
85
+ )
157
86
  )}
158
- </button>
159
- </>
87
+ </div>
88
+ {!locked && (
89
+ <div className="flex items-center justify-center w-6 h-6 fill-t_text_primary opacity-50 [&_svg]:w-3 [&_svg]:h-3">
90
+ <ChevronDownIcon />
91
+ </div>
92
+ )}
93
+ </button>
160
94
  );
161
95
  };
@@ -27,7 +27,7 @@ export const SwapPanel = ({ type }: Props) => {
27
27
  <div
28
28
  className={`flex flex-col gap-2 px-4 sm:px-6 py-4 rounded-2xl bg-t_bg_secondary bg-opacity-60 ${
29
29
  type === "source"
30
- ? "bg-gradient-to-r from-t_bg_tertiary/5 to-t_bg_tertiary/5 border border-solid border-t_text_primary border-opacity-10"
30
+ ? "bg-gradient-to-r from-t_bg_tertiary/5 to-t_bg_tertiary/5 border border-solid border-t_text_primary border-opacity-25"
31
31
  : "bg-gradient-to-r from-t_bg_tertiary/[0.03] to-t_bg_tertiary/[0.03]"
32
32
  }`}
33
33
  >
@@ -3,6 +3,7 @@ import { useSwapContext } from "@src/context";
3
3
  import { useMemo } from "react";
4
4
  import { Header } from "./Header";
5
5
  import { HistoryView } from "./HistoryView";
6
+ import { PickerView } from "./PickerView";
6
7
  import { SettingsView } from "./SettingsView";
7
8
  import { SwapView } from "./SwapView";
8
9
  import { UnifiedRoute } from "@src/models";
@@ -12,7 +13,7 @@ type Props = {
12
13
  onClose: () => void;
13
14
  };
14
15
  export const Swap = ({ onClose }: Props) => {
15
- const { tab } = useSwapContext();
16
+ const { tab, activePicker } = useSwapContext();
16
17
 
17
18
  const view = useMemo(() => {
18
19
  return tab === "Settings" ? (
@@ -26,10 +27,23 @@ export const Swap = ({ onClose }: Props) => {
26
27
 
27
28
  return (
28
29
  <div
29
- className={`relative flex flex-col gap-6 p-5 sm:p-6 text-t_text_primary bg-t_bg_secondary bg-opacity-50 backdrop-blur-[12px] rounded-[20px] border border-solid border-t_text_primary border-opacity-10 flex-1`}
30
+ className={`relative flex flex-col gap-6 p-4 sm:p-8 text-t_text_primary bg-gradient-to-r from-t_bg_tertiary/[0.01] to-t_bg_tertiary/[0.01] bg-t_bg_secondary bg-opacity-50 backdrop-blur-[48px] rounded-[32px] border border-solid border-t_text_primary border-opacity-10 flex-1`}
30
31
  >
31
- <Header onClose={onClose} />
32
- <div className="flex flex-col flex-1 min-h-0">{view}</div>
32
+ {/* On sm+ the picker replaces the header and the current view in
33
+ place; below sm it renders as a bottom sheet over them. The
34
+ replaced content stays in the layout (visibility only), so it keeps
35
+ defining the widget height for the picker to fill. */}
36
+ <div className="relative flex flex-col flex-1 min-h-0">
37
+ {activePicker && <PickerView />}
38
+ <div
39
+ className={`flex flex-col gap-6 flex-1 min-h-0 ${
40
+ activePicker ? "sm:invisible" : ""
41
+ }`}
42
+ >
43
+ <Header onClose={onClose} />
44
+ <div className="flex flex-col flex-1 min-h-0">{view}</div>
45
+ </div>
46
+ </div>
33
47
  <PoweredBy />
34
48
  </div>
35
49
  );