diff --git a/README.md b/README.md index 8d6ac14c4..c6f20d23c 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# Helix Bridge xToken UI +# xToken UI diff --git a/public/images/menu.svg b/public/images/menu.svg index 9577a9abe..13850e126 100644 --- a/public/images/menu.svg +++ b/public/images/menu.svg @@ -1,9 +1,9 @@ - - - + + + + p-id="1462" fill="#FFFFFF"> diff --git a/public/images/third-party-bridges/helix-bridge.png b/public/images/third-party-bridges/helix-bridge.png new file mode 100644 index 000000000..a2b766c9f Binary files /dev/null and b/public/images/third-party-bridges/helix-bridge.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 000000000..a39999dc9 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,5 @@ +{ + "name": "Darwinia Bridge", + "description": "Darwinia bridge, assets cross-chain", + "icons": [{ "src": "icon.svg", "sizes": "any" }] +} diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28c5..000000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index d2f842227..000000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/error.tsx b/src/app/error.tsx index 2b382b550..9919176c8 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -9,7 +9,7 @@ export default function Error({ error, reset }: { error: Error; reset: () => voi return (
-
+

Oops, something went wrong !

+ ); +} diff --git a/src/components/record-detail.tsx b/src/components/record-detail.tsx index 7b75909ef..7e0902d2d 100644 --- a/src/components/record-detail.tsx +++ b/src/components/record-detail.tsx @@ -53,7 +53,7 @@ export default function RecordDetail(props: Props) {
-
+
{/* loading */} @@ -140,7 +140,7 @@ export default function RecordDetail(props: Props) { function Item({ label, tips, children }: PropsWithChildren<{ label: string; tips?: string }>) { return ( -
+
{children}
diff --git a/src/components/records-table.tsx b/src/components/records-table.tsx index 01c5f57da..5d1434629 100644 --- a/src/components/records-table.tsx +++ b/src/components/records-table.tsx @@ -65,7 +65,7 @@ export default function RecordsTable({ render: ({ fromChain, sendAmount, sendToken }) => { const token = getChainConfig(fromChain)?.tokens.find((t) => t.symbol === sendToken); return token ? ( -
+
Token {formatBalance(BigInt(sendAmount), token.decimals, { precision: 4 })} {token.symbol} @@ -134,7 +134,7 @@ function FromTo({ network }: { network: Network }) { const chain = getChainConfig(network); return chain ? ( -
+
Logo {chain.name}
diff --git a/src/components/third-party-bridge.tsx b/src/components/third-party-bridge.tsx new file mode 100644 index 000000000..10ac064d4 --- /dev/null +++ b/src/components/third-party-bridge.tsx @@ -0,0 +1,45 @@ +import Image from "next/image"; + +const bridgeOptions: { name: string; logo: string; link: string }[] = [ + { name: "Helix Bridge", logo: "helix-bridge.png", link: "https://helixbridge.app" }, +]; + +export default function ThirdPartyBridge() { + return ( +
+ {bridgeOptions.map((option) => ( + +
+ {option.name} + {option.name} +
+ + +
+ ))} +
+ ); +} + +function ExternalIcon() { + return ( + + + + ); +} diff --git a/src/components/token-to-receive.tsx b/src/components/token-to-receive.tsx index 914188953..bb5e74852 100644 --- a/src/components/token-to-receive.tsx +++ b/src/components/token-to-receive.tsx @@ -17,7 +17,7 @@ export default function TokenToReceive({ record }: Props) { ); return token ? ( -
+
{token.type !== "native" && ( )} @@ -27,7 +27,7 @@ export default function TokenToReceive({ record }: Props) { {/* add to metamask */} {window.ethereum && token.type !== "native" ? (
@@ -198,7 +198,7 @@ export default function TransactionStatus({ record }: Props) { {record?.result === RecordResult.PENDING_TO_REFUND && (
Please request refund on the target chain. -
@@ -209,10 +209,10 @@ export default function TransactionStatus({ record }: Props) { You can request refund or speed up this transaction. - -
diff --git a/src/components/transaction-timestamp.tsx b/src/components/transaction-timestamp.tsx index e8943f3a2..5beceab56 100644 --- a/src/components/transaction-timestamp.tsx +++ b/src/components/transaction-timestamp.tsx @@ -9,7 +9,7 @@ interface Props { export default function TransactionTimestamp({ record }: Props) { return ( -
+
Confirm time {record ? `${toTimeAgo(record.startTime * 1000)} (${formatTime(record.startTime * 1000)})` : null} diff --git a/src/components/transfer-amount-input.tsx b/src/components/transfer-amount-input.tsx new file mode 100644 index 000000000..adb9aeb2d --- /dev/null +++ b/src/components/transfer-amount-input.tsx @@ -0,0 +1,139 @@ +import { ChainConfig, Token } from "@/types"; +import { formatBalance } from "@/utils"; +import Image from "next/image"; +import { ChangeEventHandler, useCallback, useEffect, useRef, useState } from "react"; +import { parseUnits } from "viem"; +import Faucet from "./faucet"; + +interface Value { + input: string; + value: bigint; + valid: boolean; + alert: string; +} + +interface Props { + min?: bigint; + max?: bigint; + token: Token; + value: Value; + balance: bigint; + loading?: boolean; + chain: ChainConfig; + onRefresh?: () => void; + onChange?: (value: Value) => void; +} + +export default function TransferAmountInput({ + min, + max, + chain, + token, + value, + balance, + loading, + onRefresh, + onChange = () => undefined, +}: Props) { + const [dynamicFont, setDynamicFont] = useState("text-[3rem] font-light"); + const inputRef = useRef(null); + const spanRef = useRef(null); + const tokenRef = useRef(token); + + const handleChange = useCallback>( + (e) => { + const input = e.target.value; + let parsed = { value: 0n, input: "" }; + let valid = true; + let alert = ""; + + if (input) { + if (!Number.isNaN(Number(input))) { + parsed = parseAmount(input, token.decimals); + if (balance < parsed.value) { + valid = false; + alert = "* Insufficient"; + } else if (typeof min === "bigint" && parsed.value < min) { + valid = false; + alert = `* Minimum transfer amount: ${formatBalance(min, token.decimals)}`; + } else if (typeof max === "bigint" && max < parsed.value) { + valid = false; + alert = `* Maximum transfer amount: ${formatBalance(max, token.decimals, { + precision: 6, + })}`; + } + onChange({ valid, alert, ...parsed }); + } + } else { + onChange({ valid, alert, ...parsed }); + } + }, + [min, max, balance, token.decimals, onChange], + ); + + useEffect(() => { + const inputWidth = inputRef.current?.clientWidth || 1; + const spanWidth = spanRef.current?.clientWidth || 0; + const percent = (spanWidth / inputWidth) * 100; + if (percent < 20) { + setDynamicFont("text-[3rem] font-light"); + } else if (percent < 30) { + setDynamicFont("text-[2.25rem] font-light"); + } else if (percent < 40) { + setDynamicFont("text-[1.875rem] font-normal"); + } else if (percent < 50) { + setDynamicFont("text-[1.5rem] font-medium"); + } else if (percent < 60) { + setDynamicFont("text-[1.25rem] font-semibold"); + } else { + setDynamicFont("text-[1.25rem] font-bold"); + } + }, [value.input]); + + useEffect(() => { + if (token.decimals !== tokenRef.current.decimals || token.symbol !== tokenRef.current.symbol) { + tokenRef.current = token; + onChange({ input: "", value: 0n, valid: true, alert: "" }); + } + }, [token, onChange]); + + return ( +
+ +
+ Balance: {formatBalance(balance, token.decimals)} + + {chain.testnet ? : null} +
+ + + {value.input} + +
+ ); +} + +function parseAmount(source: string, decimals: number) { + let input = ""; + let value = 0n; + const [i, d] = source.replace(/,/g, "").split(".").concat("-1"); // The commas must be removed or parseUnits will error + if (i) { + input = d === "-1" ? i : d ? `${i}.${d.slice(0, decimals)}` : `${i}.`; + value = parseUnits(input, decimals); + } + return { value, input }; +} diff --git a/src/components/transfer-amount-section.tsx b/src/components/transfer-amount-section.tsx new file mode 100644 index 000000000..0ace802f3 --- /dev/null +++ b/src/components/transfer-amount-section.tsx @@ -0,0 +1,50 @@ +import { ChainConfig, Token } from "@/types"; +import TransferSection from "./transfer-section"; +import TransferAmountInput from "./transfer-amount-input"; + +interface Amount { + input: string; + value: bigint; + valid: boolean; + alert: string; +} + +interface Props { + min?: bigint; + max?: bigint; + token: Token; + amount: Amount; + balance: bigint; + loading?: boolean; + chain: ChainConfig; + onRefresh?: () => void; + onChange?: (amount: Amount) => void; +} + +export default function TransferAmountSection({ + min, + max, + token, + chain, + amount, + balance, + loading, + onRefresh, + onChange, +}: Props) { + return ( + + + + ); +} diff --git a/src/components/transfer-chain-section.tsx b/src/components/transfer-chain-section.tsx new file mode 100644 index 000000000..ebad360c1 --- /dev/null +++ b/src/components/transfer-chain-section.tsx @@ -0,0 +1,131 @@ +import { ChainConfig, Token } from "@/types"; +import TransferSection from "./transfer-section"; +import TransferChainSelect from "./transfer-chain-select"; +import TransferSwitch from "./transfer-switch"; +import ComponentLoading from "@/ui/component-loading"; +import { Address } from "viem"; +import Image from "next/image"; +import { getTokenLogoSrc } from "@/utils"; +import CopyIcon from "@/ui/copy-icon"; + +interface Recipient { + input: string; + value: Address | undefined; + alert?: string; +} + +interface Props { + loading?: boolean; + recipient?: Recipient; + sourceChain: ChainConfig; + targetChain: ChainConfig; + sourceToken: Token; + targetToken: Token; + sourceChainOptions: ChainConfig[]; + targetChainOptions: ChainConfig[]; + sourceTokenOptions: Token[]; + targetTokenOptions: Token[]; + disableSwitch?: boolean; + expandRecipient?: boolean; + recipientOptions?: Address[]; + onSwitch?: () => void; + onExpandRecipient?: () => void; + onSourceChainChange?: (chain: ChainConfig) => void; + onTargetChainChange?: (chain: ChainConfig) => void; + onSourceTokenChange?: (token: Token) => void; + onTargetTokenChange?: (token: Token) => void; + onRecipientChange?: (recipient: Recipient) => void; +} + +export default function TransferChainSection({ + loading, + recipient, + disableSwitch, + expandRecipient, + recipientOptions, + sourceChain, + targetChain, + sourceToken, + targetToken, + sourceChainOptions, + targetChainOptions, + sourceTokenOptions, + targetTokenOptions, + onSwitch, + onExpandRecipient, + onRecipientChange, + onSourceChainChange, + onTargetChainChange, + onSourceTokenChange, + onTargetTokenChange, +}: Props) { + return ( +
+ + } + > + + + + } + recipient={recipient} + alert={recipient?.alert} + expandRecipient={expandRecipient} + recipientOptions={recipientOptions} + onExpandRecipient={onExpandRecipient} + onRecipientChange={onRecipientChange} + > + + +
+ ); +} + +function TokenTips({ token, chain }: { token: Token; chain: ChainConfig }) { + const explorer = new URL(`/address/${token.address}`, chain.blockExplorers?.default.url); + + return ( +
+ ); +} diff --git a/src/components/transfer-chain-select.tsx b/src/components/transfer-chain-select.tsx new file mode 100644 index 000000000..f3119d115 --- /dev/null +++ b/src/components/transfer-chain-select.tsx @@ -0,0 +1,164 @@ +import { ChainConfig, Token } from "@/types"; +import Select from "@/ui/select"; +import { getChainLogoSrc, getTokenLogoSrc } from "@/utils"; +import Image from "next/image"; +import { useState } from "react"; + +interface Props { + chain: ChainConfig; + token: Token; + chainOptions: ChainConfig[]; + tokenOptions: Token[]; + onChainChange?: (chain: ChainConfig) => void; + onTokenChange?: (token: Token) => void; +} + +export default function TransferChainSelect({ + chain, + token, + chainOptions, + tokenOptions, + onChainChange, + onTokenChange, +}: Props) { + const [search, setSearch] = useState(""); + + return ( +
+ { + e.stopPropagation(); + }} + onChange={(e) => { + setSearch(e.target.value); + }} + /> +
+
+
+ {chainOptions + .filter(({ name }) => name.toLowerCase().includes(search.toLowerCase())) + .map((option) => ( + + ))} +
+ + ) : ( +
+ No data +
+ )} + + + {tokenOptions.length > 1 ? ( + + ) : null} +
+ ); +} + +function ChainOption({ + selected, + option, + onSelect = () => undefined, +}: { + selected: ChainConfig; + option: ChainConfig; + onSelect?: (chain: ChainConfig) => void; +}) { + return ( + + ); +} + +function TokenOption({ + selected, + option, + onSelect = () => undefined, +}: { + selected: Token; + option: Token; + onSelect?: (token: Token) => void; +}) { + return ( + + ); +} diff --git a/src/components/transfer-info.tsx b/src/components/transfer-info.tsx index ac7376ee2..835cae79a 100644 --- a/src/components/transfer-info.tsx +++ b/src/components/transfer-info.tsx @@ -35,7 +35,7 @@ export default function TransferInfo({ fee, bridge }: Props) { }, [bridge]); return ( -
+
+ + + ); +} diff --git a/src/components/transfer-information.tsx b/src/components/transfer-information.tsx new file mode 100644 index 000000000..3a517026d --- /dev/null +++ b/src/components/transfer-information.tsx @@ -0,0 +1,80 @@ +import { Token } from "@/types"; +import CountLoading from "@/ui/count-loading"; +import Tooltip from "@/ui/tooltip"; +import { formatBalance } from "@/utils"; +import Image from "next/image"; + +interface Props { + fee: { loading: boolean; value?: bigint; token?: Token; warning?: string }; + dailyLimit?: { loading: boolean; value?: bigint; token?: Token }; + estimatedTime?: { loading: boolean; value?: string }; +} + +export default function TransferInformation({ fee, dailyLimit, estimatedTime }: Props) { + return ( +
+ {estimatedTime ? ( + + ) : null} + {fee ? ( + + ) : null} + {dailyLimit ? ( + + ) : null} +
+ ); +} + +function Row({ + name, + loading, + value, + token, + tips, + warning, +}: { + name: string; + loading?: boolean; + value?: JSX.Element | string | bigint; + token?: Token; + tips?: JSX.Element | string; + warning?: string; +}) { + return ( +
+
+ + {tips ? ( + + Info + + ) : null} +
+ + {loading ? ( + + ) : warning ? ( + + Warning + + ) : typeof value === "bigint" && token ? ( + + ) : typeof value === "string" ? ( + + ) : typeof value !== "bigint" ? ( + value + ) : null} +
+ ); +} + +function Text({ value }: { value: string }) { + return {value}; +} diff --git a/src/components/transfer-route.tsx b/src/components/transfer-route.tsx index fb50c2b2d..d3da8266c 100644 --- a/src/components/transfer-route.tsx +++ b/src/components/transfer-route.tsx @@ -2,9 +2,7 @@ import Tooltip from "@/ui/tooltip"; import { getChainConfig } from "@/utils/chain"; import { getChainLogoSrc } from "@/utils/misc"; import Image from "next/image"; -import BridgeLogo from "./bridge-identicon"; import { HistoryRecord } from "@/types/graphql"; -import { bridgeFactory } from "@/utils/bridge"; interface Props { record?: HistoryRecord | null; @@ -13,14 +11,15 @@ interface Props { export default function TransferRoute({ record }: Props) { const sourceChain = getChainConfig(record?.fromChain); const targetChain = getChainConfig(record?.toChain); - const bridge = record ? bridgeFactory({ category: record.bridge }) : undefined; return (
- - - +
+ + + +
); diff --git a/src/components/transfer-section-title.tsx b/src/components/transfer-section-title.tsx new file mode 100644 index 000000000..91672094d --- /dev/null +++ b/src/components/transfer-section-title.tsx @@ -0,0 +1,20 @@ +import Tooltip from "@/ui/tooltip"; +import Image from "next/image"; + +interface Props { + text: string; + tips?: string | JSX.Element; +} + +export default function TransferSectionTitle({ text, tips }: Props) { + return ( +
+ {text} + {tips ? ( + + Info + + ) : null} +
+ ); +} diff --git a/src/components/transfer-section.tsx b/src/components/transfer-section.tsx new file mode 100644 index 000000000..8eebfe8b8 --- /dev/null +++ b/src/components/transfer-section.tsx @@ -0,0 +1,67 @@ +import { PropsWithChildren } from "react"; +import TransferSectionTitle from "./transfer-section-title"; +import WalletSVG from "./icons/wallet-svg"; +import { Address } from "viem"; +import RecipientInput from "./recipient-input"; + +interface Recipient { + input: string; + value: Address | undefined; + alert?: string; +} + +interface Props { + className?: string; + titleText?: string; + titleTips?: string | JSX.Element; + loading?: boolean; + alert?: string; + recipient?: Recipient; + expandRecipient?: boolean; + recipientOptions?: Address[]; + onExpandRecipient?: () => void; + onRecipientChange?: (value: Recipient) => void; +} + +export default function TransferSection({ + alert, + loading, + children, + titleText, + titleTips, + className, + recipient, + expandRecipient, + recipientOptions, + onExpandRecipient = () => undefined, + onRecipientChange = () => undefined, +}: PropsWithChildren) { + return ( +
+
+ {titleText ? ( +
+ + {recipient ? ( + + ) : null} +
+ ) : null} + {children} + {expandRecipient && ( + + )} +
+ {alert ? {alert} : null} +
+ ); +} diff --git a/src/components/transfer-switch.tsx b/src/components/transfer-switch.tsx new file mode 100644 index 000000000..2c6c58012 --- /dev/null +++ b/src/components/transfer-switch.tsx @@ -0,0 +1,45 @@ +import Tooltip from "@/ui/tooltip"; +import Image from "next/image"; +import { useState } from "react"; + +interface Props { + disabled?: boolean; + onSwitch?: () => void; +} + +export default function TransferSwitch({ disabled, onSwitch = () => undefined }: Props) { + const [switchCount, setSwitchCount] = useState(0); + + return ( +
+ +
{ + if (!disabled) { + setSwitchCount((prev) => prev + 1); + onSwitch(); + } + }} + > + Switch +
+
+
+ ); +} diff --git a/src/components/transfer-token-section.tsx b/src/components/transfer-token-section.tsx new file mode 100644 index 000000000..b1b795548 --- /dev/null +++ b/src/components/transfer-token-section.tsx @@ -0,0 +1,23 @@ +import { TokenCategory, TokenSymbol } from "@/types"; +import TransferSection from "./transfer-section"; +import TransferTokenSelect from "./transfer-token-select"; + +interface TokenOption { + logo: string; + category: TokenCategory; + symbol: TokenSymbol; +} + +interface Props { + token: TokenOption; + options: TokenOption[]; + onChange?: (token: TokenOption) => void; +} + +export default function TransferTokenSection({ token, options, onChange }: Props) { + return ( + + + + ); +} diff --git a/src/components/transfer-token-select.tsx b/src/components/transfer-token-select.tsx new file mode 100644 index 000000000..96dd7c0b5 --- /dev/null +++ b/src/components/transfer-token-select.tsx @@ -0,0 +1,82 @@ +import { TokenCategory, TokenSymbol } from "@/types"; +import { getTokenLogoSrc } from "@/utils"; +import Image from "next/image"; +import { useState } from "react"; + +interface Value { + logo: string; + category: TokenCategory; + symbol: TokenSymbol; +} + +interface Props { + value: Value; + options: Value[]; + onChange?: (value: Value) => void; +} + +export default function TransferTokenSelect({ value, options, onChange }: Props) { + const [hoveIndex, setHoverIndex] = useState(-1); + + return ( +
+ + {value.symbol} +
+ {options + .filter((option) => option.symbol !== value.symbol) + .map((option, index) => ( + + ))} +
+
+ ); +} + +function TokenImage({ + token, + active, + index = 0, + hoveIndex = -1, + onClick = () => undefined, + onHoverChange = () => undefined, +}: { + token: Value; + index?: number; + active?: boolean; + hoveIndex?: number; + onClick?: (token: Value) => void; + onHoverChange?: (index: number) => void; +}) { + return ( + Token image { + !active && onClick(token); + }} + onMouseEnter={() => { + !active && onHoverChange(index); + }} + onMouseLeave={() => { + !active && onHoverChange(-1); + }} + /> + ); +} diff --git a/src/components/transfer-v2.tsx b/src/components/transfer-v2.tsx new file mode 100644 index 000000000..10d0e7859 --- /dev/null +++ b/src/components/transfer-v2.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import TransferTokenSection from "./transfer-token-section"; +import { + bridgeFactory, + getSourceTokenOptions, + getTargetTokenOptions, + getTokenOptions, + notifyError, + notifyTransaction, +} from "@/utils"; +import TransferChainSection from "./transfer-chain-section"; +import TransferAmountSection from "./transfer-amount-section"; +import TransferInformationSection from "./transfer-information-section"; +import Button from "@/ui/button"; +import { useAllowance, useBalance, useDailyLimit, useMessageFee, useTransferV2 } from "@/hooks"; +import { useAccount, useNetwork, usePublicClient, useSwitchNetwork, useWalletClient } from "wagmi"; +import TransferProviderV2 from "@/providers/transfer-provider-v2"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { Address, Hex } from "viem"; +import TransferModalV2 from "./modals/transfer-modal-v2"; +import BridgeTabs from "./bridge-tabs"; +import ThirdPartyBridge from "./third-party-bridge"; + +interface Recipient { + input: string; + value: Address | undefined; + alert?: string; +} + +enum BridgeTab { + OFFICIAL, + THIRD_PARTY, +} + +function Component() { + const [txHash, setTxHash] = useState(); + const [isOpen, setIsOpen] = useState(false); + const [isTransfering, setIsTransfering] = useState(false); + const [bridgeTab, setBridgeTab] = useState(BridgeTab.OFFICIAL); + const { + amount, + token, + sourceChain, + sourceToken, + targetChain, + targetToken, + sourceChainOptions, + targetChainOptions, + setAmount, + isSwitchAvailable, + handleTokenChange, + handleSourceChainChange, + handleSourceTokenChange, + handleTargetChainChange, + handleTargetTokenChange, + handleSwitch, + } = useTransferV2(); + const deferredAmount = useDeferredValue(amount); + + const account = useAccount(); + const { chain } = useNetwork(); + const publicClient = usePublicClient(); + const { data: walletClient } = useWalletClient(); + const { switchNetwork } = useSwitchNetwork(); + const { openConnectModal } = useConnectModal(); + + const [recipient, setRecipient] = useState({ + input: account.address ?? "", + value: account.address, + alert: undefined, + }); + const [expandRecipient, setExpandRecipient] = useState(false); + const isCustomRecipient = useRef(false); // After input recipient manually, set to `true` + useEffect(() => { + if (!isCustomRecipient.current) { + if (account.address) { + setRecipient({ input: account.address, value: account.address, alert: undefined }); + } else { + setRecipient({ input: "", value: undefined, alert: undefined }); + } + } + }, [account.address]); + const handleRecipientChange = useCallback((value: Recipient) => { + setRecipient(value); + isCustomRecipient.current = true; + }, []); + const handleExpandRecipient = useCallback(() => setExpandRecipient((prev) => !prev), []); + + const { + balance, + loading: loadingBalance, + refresh: refreshBalance, + } = useBalance(sourceChain, sourceToken, account.address); + + const [bridge, cross] = useMemo(() => { + const cross = sourceToken.cross.find( + (c) => c.target.network === targetChain.network && c.target.symbol === targetToken.symbol, + ); + const bridge = cross + ? bridgeFactory({ + category: cross.bridge.category, + walletClient, + publicClient, + sourceChain, + sourceToken, + targetChain, + targetToken, + }) + : undefined; + return [bridge, cross]; + }, [publicClient, sourceChain, sourceToken, targetChain, targetToken, walletClient]); + + const { loading: loadingDailyLimit, dailyLimit } = useDailyLimit(bridge); + const { loading: loadingFee, fee } = useMessageFee(bridge, account.address, account.address, deferredAmount.value); + + const { + allowance, + loading: loadingAllowance, + busy: isApproving, + approve, + refresh: refreshAllowance, + } = useAllowance(sourceChain, sourceToken, account.address, bridge?.getContract()?.sourceAddress); + + const [actionText, disableAction] = useMemo(() => { + let text: "Connect Wallet" | "Switch Chain" | "Approve" | "Deposit" | "Withdraw" = "Deposit"; + let disabled = false; + + if (chain?.id) { + if (chain.id !== sourceChain.id) { + text = "Switch Chain"; + disabled = false; + } else if ( + allowance < (fee?.token.type === "native" ? deferredAmount.value : deferredAmount.value + (fee?.value ?? 0n)) + ) { + text = "Approve"; + disabled = false; + } else { + text = cross?.action === "redeem" ? "Withdraw" : "Deposit"; + disabled = + loadingAllowance || + fee?.value === undefined || + !deferredAmount.input || + !deferredAmount.valid || + !recipient.value || + !!recipient.alert; + } + } else { + text = "Connect Wallet"; + disabled = false; + } + + return [text, disabled]; + }, [ + cross, + allowance, + loadingAllowance, + chain?.id, + deferredAmount, + sourceChain.id, + fee?.value, + fee?.token.type, + recipient.alert, + recipient.value, + ]); + + const handleAction = useCallback(async () => { + if (actionText === "Connect Wallet") { + openConnectModal?.(); + } else if (actionText === "Switch Chain") { + switchNetwork?.(sourceChain.id); + } else if (actionText === "Approve") { + const receipt = await approve( + fee?.token.type === "native" ? deferredAmount.value : deferredAmount.value + (fee?.value ?? 0n), + ); + notifyTransaction(receipt, sourceChain); + } else if (actionText === "Deposit" || actionText === "Withdraw") { + setIsOpen(true); + } + }, [ + actionText, + sourceChain, + deferredAmount.value, + fee?.value, + fee?.token.type, + approve, + openConnectModal, + switchNetwork, + ]); + + const handleTransfer = useCallback(async () => { + if (bridge && account.address && recipient.value) { + try { + setIsTransfering(true); + const receipt = await bridge.transfer(account.address, recipient.value, deferredAmount.value, { + totalFee: fee?.value, + }); + notifyTransaction(receipt, sourceChain); + setTxHash(receipt?.transactionHash); + if (receipt?.status === "success") { + setIsTransfering(false); + refreshBalance(); + refreshAllowance(); + } + } catch (err) { + console.error(err); + notifyError(err); + setIsTransfering(false); + } + } + }, [ + account.address, + recipient.value, + bridge, + sourceChain, + fee?.value, + deferredAmount.value, + refreshBalance, + refreshAllowance, + ]); + + return ( + <> +
+ + + + options={[ + { + children: ( + <> + + + +
+ + + © {new Date().getFullYear()} Powered by{" "} + + xToken + + +
+ + ), + tab: BridgeTab.OFFICIAL, + label: "Official Brigde", + }, + { + children: , + tab: BridgeTab.THIRD_PARTY, + label: "Third Party Bridge", + }, + ]} + activeTab={bridgeTab} + onChange={setBridgeTab} + /> +
+ + { + setIsOpen(false); + if (txHash) { + setAmount({ input: "", valid: true, value: 0n, alert: "" }); + } + setTxHash(null); + }} + onConfirm={handleTransfer} + /> + + ); +} + +export default function TransferV2() { + return ( + + + + ); +} diff --git a/src/components/transfer.tsx b/src/components/transfer.tsx deleted file mode 100644 index 82564a19d..000000000 --- a/src/components/transfer.tsx +++ /dev/null @@ -1,341 +0,0 @@ -"use client"; - -import { useToggle, useTransfer } from "@/hooks"; -import { - bridgeFactory, - getAvailableBridges, - getAvailableSourceTokens, - getAvailableTargetChains, - getAvailableTargetTokens, - getCrossDefaultValue, - isProduction, -} from "@/utils"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; -import { Address, useAccount, usePublicClient, useWalletClient } from "wagmi"; -import { Subscription, from } from "rxjs"; -import Label from "@/ui/label"; -import ChainSelect from "./chain-select"; -import SwitchCrossIcon from "@/ui/switch-cross-icon"; -import Faucet from "./faucet"; -import { BalanceInput } from "./balance-input"; -import BridgeSelect from "./bridge-select"; -import TransferInfo from "./transfer-info"; -import TransferAction from "./transfer-action"; -import TransferModal from "./modals/transfer-modal"; - -const { defaultSourceChains } = getCrossDefaultValue(); - -export default function Transfer() { - const { - bridgeFee, - sourceChain, - targetChain, - sourceToken, - targetToken, - sourceBalance, - bridgeInstance, - bridgeCategory, - transferAmount, - setSourceChain, - setTargetChain, - setSourceToken, - setTargetToken, - setTransferAmount, - setBridgeFee, - setBridgeCategory, - setBridgeInstance, - updateSourceBalance, - updateUrlParams, - } = useTransfer(); - const deferredTransferAmount = useDeferredValue(transferAmount); - - const { state: isOpen, setTrue: setIsOpenTrue, setFalse: setIsOpenFalse } = useToggle(false); - const { address } = useAccount(); - const { data: walletClient } = useWalletClient(); - const publicClient = usePublicClient(); - - const [recipient, _setRecipient] = useState
(); - const [isLoadingFee, setIsLoadingFee] = useState(false); - const [estimateGasFee, setEstimateGasFee] = useState(0n); - const [balanceLoading, setBalanceLoading] = useState(false); - - const alert = useMemo(() => { - return null; - }, []); - - const bridgeOptions = useMemo( - () => getAvailableBridges(sourceChain, targetChain, sourceToken), - [sourceChain, targetChain, sourceToken], - ); - - const transferable = useMemo(() => { - let result: bigint | undefined; - let fee = 0n; - - if (sourceBalance) { - const { token, value: balance } = sourceBalance; - result = result === undefined ? balance : result < balance ? result : balance; - if (bridgeFee?.token.symbol === token.symbol) { - fee = bridgeFee.value; - result = fee < result ? result - fee : 0n; - } - } - if (result !== undefined) { - result = estimateGasFee < result ? result - estimateGasFee : 0n; - } - return result; - }, [bridgeFee, estimateGasFee, sourceBalance]); - - const searchParams = useSearchParams(); - const router = useRouter(); - - const refreshBalance = useCallback(async () => { - if (address && bridgeInstance) { - setBalanceLoading(true); - await updateSourceBalance(address, bridgeInstance); - setBalanceLoading(false); - } - }, [address, bridgeInstance, updateSourceBalance]); - - useEffect(() => { - setBridgeCategory((prevValue) => prevValue ?? bridgeOptions.at(0)); - }, [bridgeOptions, setBridgeCategory]); - - useEffect(() => { - setBridgeInstance( - bridgeCategory - ? bridgeFactory({ - category: bridgeCategory, - sourceChain, - targetChain, - sourceToken, - targetToken, - walletClient, - publicClient, - }) - : undefined, - ); - }, [ - sourceChain, - targetChain, - sourceToken, - targetToken, - walletClient, - publicClient, - bridgeCategory, - setBridgeInstance, - ]); - - useEffect(() => { - let sub$$: Subscription | undefined; - - if (bridgeInstance) { - setIsLoadingFee(true); - sub$$ = from( - bridgeInstance.getFee({ - sender: address, - recipient, - transferAmount: deferredTransferAmount.value, - }), - ).subscribe({ - next: setBridgeFee, - error: (err) => { - console.error(err); - setBridgeFee(undefined); - setIsLoadingFee(false); - }, - complete: () => setIsLoadingFee(false), - }); - } else { - setBridgeFee(undefined); - } - - return () => { - sub$$?.unsubscribe(); - }; - }, [address, recipient, bridgeInstance, deferredTransferAmount, setBridgeFee]); - - useEffect(() => { - let sub$$: Subscription | undefined; - - // Note: native token - - if (bridgeInstance && sourceToken?.type === "native" && address && deferredTransferAmount.value) { - sub$$ = from( - bridgeInstance.estimateTransferGasFee(address, recipient ?? address, deferredTransferAmount.value, { - totalFee: bridgeFee?.value, - }), - ).subscribe({ - next: (gasFee) => { - setEstimateGasFee(gasFee ?? 0n); - }, - error: (err) => { - console.error(err); - setEstimateGasFee(0n); - }, - }); - } else { - setEstimateGasFee(0n); - } - - return () => sub$$?.unsubscribe(); - }, [bridgeInstance, sourceToken, bridgeFee, address, recipient, deferredTransferAmount]); - - return ( - <> -
- {/* From-To */} -
- - { - const _sourceChain = targetChain ? { ...targetChain } : undefined; - const _targetChain = sourceChain ? { ...sourceChain } : undefined; - const _sourceToken = targetToken ? { ...targetToken } : undefined; - const _targetToken = sourceToken ? { ...sourceToken } : undefined; - const _category = getAvailableBridges(_sourceChain, _targetChain, _sourceToken).at(0); - - setBridgeCategory(_category); - setSourceChain(_sourceChain); - setTargetChain(_targetChain); - setSourceToken(_sourceToken); - setTargetToken(_targetToken); - updateUrlParams(router, searchParams, { - _category, - _sourceChain, - _targetChain, - _sourceToken, - _targetToken, - }); - }} - /> - -
- - {/* Amount */} - - - {/* Bridge */} - - - {/* Information */} - - - {/* Action */} - -
- - { - setIsOpenFalse(); - setTransferAmount({ input: "", valid: true, value: 0n }); - }} - /> - - ); -} diff --git a/src/components/user.tsx b/src/components/user.tsx index 76eac90a3..9dee12623 100644 --- a/src/components/user.tsx +++ b/src/components/user.tsx @@ -42,12 +42,17 @@ export default function User({ placement, prefixLength = 10, suffixLength = 8, o return address ? ( {toShortAdrress(address)}} + label={ +
+ + {toShortAdrress(address)} +
+ } > -
+
( {/* header */} -
+

{title}

{subTitle ? ( typeof subTitle === "string" ? ( @@ -110,7 +117,7 @@ export default function Modal({ kind="default" onClick={onCancel} disabled={disabledCancel} - className="h-10 flex-1 rounded-xl text-base font-semibold" + className="h-10 flex-1 rounded-full text-sm font-bold" > {cancelText || "Cancel"} @@ -121,7 +128,7 @@ export default function Modal({ onClick={onOk} disabled={disabledOk} busy={busy} - className="h-10 flex-1 rounded-xl text-base font-semibold" + className="h-10 flex-1 rounded-full text-sm font-bold" > {okText || "Ok"} diff --git a/src/ui/notification.tsx b/src/ui/notification.tsx index 5755d0eab..5900cc984 100644 --- a/src/ui/notification.tsx +++ b/src/ui/notification.tsx @@ -15,14 +15,14 @@ type Status = "success" | "info" | "warn" | "error"; const createContainer = () => { const container = document.createElement("div"); - container.className = "fixed top-middle right-middle lg:top-5 lg:right-5 flex flex-col overflow-hidden z-40"; + container.className = "fixed top-medium right-medium lg:top-5 lg:right-5 flex flex-col overflow-hidden z-40"; document.body.appendChild(container); return container; }; const createItem = (config: Config, status: Status, onClose: () => void) => { const domNode = document.createElement("div"); - domNode.className = `rounded-middle border-component border bg-inner p-middle lg:p-5 flex items-center gap-middle mb-middle animate-notification-enter relative w-[82vw] lg:w-96 ${config.className}`; + domNode.className = `rounded-medium border-component border bg-inner p-medium lg:p-5 flex items-center gap-medium mb-medium animate-notification-enter relative w-[82vw] lg:w-96 ${config.className}`; const root = createRoot(domNode); root.render( diff --git a/src/ui/record-item-title.tsx b/src/ui/record-item-title.tsx index 2a184e4c6..157df887d 100644 --- a/src/ui/record-item-title.tsx +++ b/src/ui/record-item-title.tsx @@ -4,12 +4,12 @@ import Tooltip from "./tooltip"; export function RecordItemTitle({ text, tips }: { text: string; tips?: string }) { return (
+ {text} {tips ? ( Info ) : null} - {text}
); } diff --git a/src/ui/record-result-tag.tsx b/src/ui/record-result-tag.tsx index ab6d32057..13502f5f4 100644 --- a/src/ui/record-result-tag.tsx +++ b/src/ui/record-result-tag.tsx @@ -35,7 +35,7 @@ export function RecordResultTag({ result }: { result?: RecordResult | null }) { return (
undefined }: Props) { return (
void; @@ -36,6 +37,7 @@ export default function Select({ clearable, placement, sameWidth, + offsetSize, labelClassName, childClassName, onClear = () => undefined, @@ -47,7 +49,7 @@ export default function Select({ onOpenChange: setIsOpen, placement, middleware: [ - offset(4), + offset(offsetSize ?? 4), sameWidth ? size({ apply({ rects, elements }) { @@ -78,7 +80,7 @@ export default function Select({ disabled={disabled} > {label || placeholder} -
+
{label && clearable ? (
({
{isOverflow && (
)}
{/* header */}
{columns @@ -97,15 +97,15 @@ export default function Table({ {/* body */}
{/* loading */} - + {/* content */} {dataSource.length ? ( -
+
{dataSource.map((row) => (
({ {!loading && ( <> No data - No data + No data )}
@@ -144,7 +144,7 @@ export default function Table({ {total !== undefined && currentPage !== undefined && (
) { const [isOpen, setIsOpen] = useState(false); const arrowRef = useRef(null); @@ -64,12 +66,19 @@ export default function Tooltip({ {isMounted && (
- +
- {typeof content === "string" ? {content} : content} + {typeof content === "string" ? {content} : content}
diff --git a/src/utils/index.ts b/src/utils/index.ts index f2de93d04..5d970a082 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -7,3 +7,4 @@ export * from "./env"; export * from "./misc"; export * from "./notification"; export * from "./time"; +export * from "./transfer"; diff --git a/src/utils/transfer.ts b/src/utils/transfer.ts new file mode 100644 index 000000000..b3e16cd94 --- /dev/null +++ b/src/utils/transfer.ts @@ -0,0 +1,68 @@ +import { ChainConfig, Token, TokenCategory, TokenOption } from "@/types"; +import { getChainConfig, getChainConfigs, isProduction } from "."; + +const allTokenOptions: Record, TokenOption> = { + crab: { logo: "crab.png", category: "crab", symbol: "CRAB" }, + eth: { logo: "eth.png", category: "eth", symbol: "ETH" }, + ring: { logo: "ring.png", category: "ring", symbol: "RING" }, + usdc: { logo: "usdc.png", category: "usdc", symbol: "USDC" }, + usdt: { logo: "usdt.png", category: "usdt", symbol: "USDT" }, + kton: { logo: "kton.png", category: "kton", symbol: "KTON" }, +}; +const sortedTokenCategories: Exclude[] = isProduction() + ? ["usdt", "usdc", "eth", "ring", "crab", "kton"] + : ["usdt", "usdc", "eth", "ring", "crab", "kton"]; +const availableTokenCategories = new Set(); +const sourceChainOptions = new Map(); + +getChainConfigs() + .filter(({ hidden }) => !hidden) + .forEach((sourceChain) => { + sourceChain.tokens + .filter(({ category }) => sortedTokenCategories.some((c) => c === category)) + .forEach((sourceToken) => { + sourceToken.cross + .filter(({ hidden }) => !hidden) + .forEach((cross) => { + const targetChain = getChainConfig(cross.target.network); + const targetToken = targetChain?.tokens.find(({ symbol }) => symbol === cross.target.symbol); + + if (targetToken) { + availableTokenCategories.add(sourceToken.category); + sourceChainOptions.set( + sourceToken.category, + (sourceChainOptions.get(sourceToken.category) || []) + .filter(({ id }) => id !== sourceChain.id) + .concat(sourceChain), + ); + } + }); + }); + }); + +export function getTokenOptions() { + return sortedTokenCategories.filter((c) => availableTokenCategories.has(c)).map((c) => allTokenOptions[c]); +} + +export function getSourceChainOptions(category: TokenCategory) { + return sourceChainOptions.get(category) || []; +} + +export function getSourceTokenOptions(sourceChain: ChainConfig, tokenCategory: TokenCategory) { + return sourceChain.tokens.filter( + ({ category, cross }) => category === tokenCategory && cross.filter(({ hidden }) => !hidden).length, + ); +} + +export function getTargetChainOptions(sourceToken: Token) { + return sourceToken.cross + .filter(({ hidden }) => !hidden) + .map(({ target }) => getChainConfig(target.network)) + .filter((c) => c) as ChainConfig[]; +} + +export function getTargetTokenOptions(sourceToken: Token, targetChain: ChainConfig) { + return targetChain.tokens.filter(({ symbol }) => + sourceToken.cross.some((c) => !c.hidden && c.target.symbol === symbol && c.target.network === targetChain.network), + ); +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 63c7b17b5..e1654d5b8 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -16,6 +16,8 @@ const config: Config = { }, colors: { primary: "#FF0083", + secondary: "#1F282C", + background: "#00141D", component: "#303A44", inner: "#242D30", "app-bg": "#00141D", @@ -25,21 +27,20 @@ const config: Config = { }, borderRadius: { small: "0.25rem", // 4px - middle: "0.5rem", // 8px + medium: "0.5rem", // 8px large: "1rem", // 16px extralarge: "1.5rem", // 24px }, spacing: { small: "0.3125rem", // 5px - middle: "0.625rem", // 10px + medium: "0.625rem", // 10px large: "0.9375rem", // 15px }, maxWidth: { "8xl": "90rem", }, screens: { - xl: "1200px", - "2xl": "1200px", + "2xl": "1280px", }, keyframes: { "right-enter": { @@ -94,4 +95,5 @@ const config: Config = { }, plugins: [], }; + export default config;