diff --git a/apps/bridge-dapp/src/components/Header/ActiveChainDropdown.tsx b/apps/bridge-dapp/src/components/Header/ActiveChainDropdown.tsx index a7ffdbd41a..50344559e4 100644 --- a/apps/bridge-dapp/src/components/Header/ActiveChainDropdown.tsx +++ b/apps/bridge-dapp/src/components/Header/ActiveChainDropdown.tsx @@ -1,4 +1,4 @@ -import { DropdownMenuTrigger as DropdownButton } from '@radix-ui/react-dropdown-menu'; +import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; import { useWebContext, useConnectWallet, @@ -14,7 +14,7 @@ import { } from '@webb-tools/webb-ui-components/components/Dropdown'; import { MenuItem } from '@webb-tools/webb-ui-components/components/MenuItem'; import { ScrollArea } from '@webb-tools/webb-ui-components/components/ScrollArea'; -import ChainButtonCmp from '@webb-tools/webb-ui-components/components/buttons/ChainButton'; +import ChainOrTokenButton from '@webb-tools/webb-ui-components/components/buttons/ChainOrTokenButton'; import { useWebbUI } from '@webb-tools/webb-ui-components/hooks/useWebbUI'; import { useCallback, useMemo } from 'react'; import useChainsFromRoute from '../../hooks/useChainsFromRoute'; @@ -64,14 +64,15 @@ const ActiveChainDropdown = () => { return ( - - + - +
    diff --git a/apps/bridge-dapp/src/pages/Account/AccountSummaryCard.tsx b/apps/bridge-dapp/src/pages/Account/AccountSummaryCard.tsx index bb1b5cb1c7..c4bef04f1c 100644 --- a/apps/bridge-dapp/src/pages/Account/AccountSummaryCard.tsx +++ b/apps/bridge-dapp/src/pages/Account/AccountSummaryCard.tsx @@ -1,4 +1,4 @@ -import { DropdownMenuTrigger as DropdownButton } from '@radix-ui/react-dropdown-menu'; +import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; import { useWebContext } from '@webb-tools/api-provider-environment'; import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; import ArrowLeftRightLineIcon from '@webb-tools/icons/ArrowLeftRightLineIcon'; @@ -174,7 +174,7 @@ function TotalShieldedBalance() { - - + diff --git a/apps/tangle-dapp/app/bridge/AmountAndTokenInput.tsx b/apps/tangle-dapp/app/bridge/AmountAndTokenInput.tsx new file mode 100644 index 0000000000..afdb380dd4 --- /dev/null +++ b/apps/tangle-dapp/app/bridge/AmountAndTokenInput.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { DropdownMenuTrigger as DropdownTrigger } from '@radix-ui/react-dropdown-menu'; +import { TokenIcon } from '@webb-tools/icons/TokenIcon'; +import ChainOrTokenButton from '@webb-tools/webb-ui-components/components/buttons/ChainOrTokenButton'; +import { + Dropdown, + DropdownBody, +} from '@webb-tools/webb-ui-components/components/Dropdown'; +import { MenuItem } from '@webb-tools/webb-ui-components/components/MenuItem'; +import { ScrollArea } from '@webb-tools/webb-ui-components/components/ScrollArea'; +import { FC } from 'react'; + +import AmountInput from '../../components/AmountInput/AmountInput'; +import { useBridge } from '../../context/BridgeContext'; + +const AmountAndTokenInput: FC = () => { + const { amount, setAmount, selectedToken, setSelectedToken, tokenOptions } = + useBridge(); + + return ( +
    + + + + + + + +
      + {tokenOptions.map((token) => { + return ( +
    • + } + onSelect={() => setSelectedToken(token)} + className="px-3" + > + {token.symbol} + +
    • + ); + })} +
    +
    +
    +
    +
    + ); +}; + +export default AmountAndTokenInput; diff --git a/apps/tangle-dapp/app/bridge/BridgeContainer.tsx b/apps/tangle-dapp/app/bridge/BridgeContainer.tsx new file mode 100644 index 0000000000..996bc3a9ad --- /dev/null +++ b/apps/tangle-dapp/app/bridge/BridgeContainer.tsx @@ -0,0 +1,64 @@ +'use client'; + +import Button from '@webb-tools/webb-ui-components/components/buttons/Button'; +import { FC } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import AddressInput, { + AddressType, +} from '../../components/AddressInput/AddressInput'; +import { useBridge } from '../../context/BridgeContext'; +import AmountAndTokenInput from './AmountAndTokenInput'; +import ChainSelectors from './ChainSelectors'; +import useActionButton from './useActionButton'; + +interface BridgeContainerProps { + className?: string; +} + +const BridgeContainer: FC = ({ className }) => { + const { destinationAddress, setDestinationAddress } = useBridge(); + const { buttonAction, buttonText, isLoading } = useActionButton(); + + return ( +
    +
    +
    + + + + + + + {/* TODO: Tx Info (Fees & Estimated Time) */} +
    + +
    +
    + ); +}; + +export default BridgeContainer; diff --git a/apps/tangle-dapp/app/bridge/ChainSelectors.tsx b/apps/tangle-dapp/app/bridge/ChainSelectors.tsx new file mode 100644 index 0000000000..a620a3011d --- /dev/null +++ b/apps/tangle-dapp/app/bridge/ChainSelectors.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { DropdownMenuTrigger as DropdownTrigger } from '@radix-ui/react-dropdown-menu'; +import { ChainConfig } from '@webb-tools/dapp-config/chains/chain-config.interface'; +import { ArrowRight } from '@webb-tools/icons/ArrowRight'; +import { ChainIcon } from '@webb-tools/icons/ChainIcon'; +import ChainOrTokenButton from '@webb-tools/webb-ui-components/components/buttons/ChainOrTokenButton'; +import { + Dropdown, + DropdownBody, +} from '@webb-tools/webb-ui-components/components/Dropdown'; +import { MenuItem } from '@webb-tools/webb-ui-components/components/MenuItem'; +import { ScrollArea } from '@webb-tools/webb-ui-components/components/ScrollArea'; +import { FC, useCallback } from 'react'; + +import { useBridge } from '../../context/BridgeContext'; + +interface ChainSelectorProps { + selectedChain: ChainConfig; + chainOptions: ChainConfig[]; + onSelectChain: (chain: ChainConfig) => void; + className?: string; +} + +const ChainSelectors: FC = () => { + const { + selectedSourceChain, + setSelectedSourceChain, + selectedDestinationChain, + setSelectedDestinationChain, + sourceChainOptions, + destinationChainOptions, + } = useBridge(); + + const switchChains = useCallback(() => { + const temp = selectedSourceChain; + setSelectedDestinationChain(temp); + setSelectedSourceChain(selectedDestinationChain); + }, [ + setSelectedSourceChain, + setSelectedDestinationChain, + selectedDestinationChain, + selectedSourceChain, + ]); + + return ( +
    + + +
    + +
    + + +
    + ); +}; + +const ChainSelector: FC = ({ + selectedChain, + chainOptions, + onSelectChain, + className, +}) => { + return ( + + + + + + +
      + {chainOptions.map((chain) => { + return ( +
    • + } + onSelect={() => onSelectChain(chain)} + > + {chain.name} + +
    • + ); + })} +
    +
    +
    +
    + ); +}; + +export default ChainSelectors; diff --git a/apps/tangle-dapp/app/bridge/layout.tsx b/apps/tangle-dapp/app/bridge/layout.tsx new file mode 100644 index 0000000000..be0bbdd8af --- /dev/null +++ b/apps/tangle-dapp/app/bridge/layout.tsx @@ -0,0 +1,9 @@ +import { FC, PropsWithChildren } from 'react'; + +import BridgeProvider from '../../context/BridgeContext'; + +const BridgeLayout: FC = ({ children }) => { + return {children}; +}; + +export default BridgeLayout; diff --git a/apps/tangle-dapp/app/bridge/page.tsx b/apps/tangle-dapp/app/bridge/page.tsx new file mode 100644 index 0000000000..aa2ef0e921 --- /dev/null +++ b/apps/tangle-dapp/app/bridge/page.tsx @@ -0,0 +1,19 @@ +import { Metadata } from 'next'; +import { FC } from 'react'; + +import createPageMetadata from '../../utils/createPageMetadata'; +import BridgeContainer from './BridgeContainer'; + +export const metadata: Metadata = createPageMetadata({ + title: 'Bridge', +}); + +const Bridge: FC = () => { + return ( +
    + +
    + ); +}; + +export default Bridge; diff --git a/apps/tangle-dapp/app/bridge/useActionButton.tsx b/apps/tangle-dapp/app/bridge/useActionButton.tsx new file mode 100644 index 0000000000..fa9f820664 --- /dev/null +++ b/apps/tangle-dapp/app/bridge/useActionButton.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { + useConnectWallet, + useWebContext, +} from '@webb-tools/api-provider-environment'; +import { useCallback, useMemo } from 'react'; + +export default function useActionButton() { + const { activeAccount, activeWallet, loading, isConnecting } = + useWebContext(); + + const { toggleModal } = useConnectWallet(); + + const noActiveAccountOrWallet = useMemo(() => { + return !activeAccount || !activeWallet; + }, [activeAccount, activeWallet]); + + const openWalletModal = useCallback(() => { + toggleModal(true); + }, [toggleModal]); + + const bridgeTx = useCallback(() => { + // TODO: handle bridge Tx for each case from the source and destination chain + }, []); + + return { + isLoading: loading || isConnecting, + buttonAction: noActiveAccountOrWallet ? openWalletModal : bridgeTx, + buttonText: noActiveAccountOrWallet ? 'Connect' : 'Approve', + }; +} diff --git a/apps/tangle-dapp/components/AmountInput/AmountInput.tsx b/apps/tangle-dapp/components/AmountInput/AmountInput.tsx index f297fb597b..923cc7c366 100644 --- a/apps/tangle-dapp/components/AmountInput/AmountInput.tsx +++ b/apps/tangle-dapp/components/AmountInput/AmountInput.tsx @@ -20,6 +20,10 @@ export type AmountInputProps = { errorOnEmptyValue?: boolean; setAmount: (newAmount: BN | null) => void; setErrorMessage?: (error: string | null) => void; + placeholder?: string; + wrapperClassName?: string; + bodyClassName?: string; + dropdownBodyClassName?: string; }; const AmountInput: FC = ({ @@ -36,6 +40,10 @@ const AmountInput: FC = ({ baseInputOverrides, errorOnEmptyValue = false, setErrorMessage, + placeholder, + wrapperClassName, + bodyClassName, + dropdownBodyClassName, }) => { const inputRef = useRef(null); const { nativeTokenSymbol } = useNetworkStore(); @@ -93,13 +101,16 @@ const AmountInput: FC = ({ isDisabled={isDisabled} {...baseInputOverrides} actions={actions} + wrapperClassName={wrapperClassName} + bodyClassName={bodyClassName} + dropdownBodyClassName={dropdownBodyClassName} > = { services: , restake: , nomination: , + bridge: , }; // TODO: Need to statically link the breadcrumb labels to the page path for better type safety and to enable fearless refactoring in the future. diff --git a/apps/tangle-dapp/components/Sidebar/sidebarProps.ts b/apps/tangle-dapp/components/Sidebar/sidebarProps.ts index 9b183f032e..73f2cfde2b 100644 --- a/apps/tangle-dapp/components/Sidebar/sidebarProps.ts +++ b/apps/tangle-dapp/components/Sidebar/sidebarProps.ts @@ -1,6 +1,7 @@ import { isAppEnvironmentType } from '@webb-tools/dapp-config/types'; import { AppsLine, + ArrowLeftRightLineIcon, DocumentationIcon, FundsLine, GiftLineIcon, @@ -33,6 +34,15 @@ const SIDEBAR_STATIC_ITEMS: SideBarItemProps[] = [ Icon: UserLineIcon, subItems: [], }, + { + name: 'Bridge', + href: PagePath.BRIDGE, + isInternal: true, + isNext: true, + Icon: ArrowLeftRightLineIcon, + subItems: [], + environments: ['development', 'staging', 'test'], + }, { name: 'Services', href: '', diff --git a/apps/tangle-dapp/constants/bridge.ts b/apps/tangle-dapp/constants/bridge.ts new file mode 100644 index 0000000000..f645797257 --- /dev/null +++ b/apps/tangle-dapp/constants/bridge.ts @@ -0,0 +1,19 @@ +import { chainsConfig } from '@webb-tools/dapp-config'; +import { ChainConfig } from '@webb-tools/dapp-config/chains/chain-config.interface'; +import { PresetTypedChainId } from '@webb-tools/dapp-types'; + +import { BridgeTokenType } from '../types'; + +// This is just a temporary variable to use as supported source and destination chains +export const BRIDGE_SUPPORTED_CHAINS: ChainConfig[] = [ + chainsConfig[PresetTypedChainId.TangleMainnetNative], + chainsConfig[PresetTypedChainId.TangleTestnetNative], +]; + +// This is just a temporary variable to use as supported tokens +export const BRIDGE_SUPPORTED_TOKENS: BridgeTokenType[] = [ + { + id: '0x0', + symbol: 'TNT', + }, +]; diff --git a/apps/tangle-dapp/context/BridgeContext.tsx b/apps/tangle-dapp/context/BridgeContext.tsx new file mode 100644 index 0000000000..99b04159b8 --- /dev/null +++ b/apps/tangle-dapp/context/BridgeContext.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { BN } from '@polkadot/util'; +import { ChainConfig } from '@webb-tools/dapp-config/chains/chain-config.interface'; +import { + createContext, + FC, + PropsWithChildren, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { + BRIDGE_SUPPORTED_CHAINS, + BRIDGE_SUPPORTED_TOKENS, +} from '../constants/bridge'; +import { BridgeTokenType } from '../types'; + +interface BridgeContextProps { + selectedSourceChain: ChainConfig; + setSelectedSourceChain: (chain: ChainConfig) => void; + sourceChainOptions: ChainConfig[]; + + selectedDestinationChain: ChainConfig; + setSelectedDestinationChain: (chain: ChainConfig) => void; + destinationChainOptions: ChainConfig[]; + + destinationAddress: string; + setDestinationAddress: (address: string) => void; + + amount: BN | null; + setAmount: (amount: BN | null) => void; + + selectedToken: BridgeTokenType; + setSelectedToken: (token: BridgeTokenType) => void; + tokenOptions: BridgeTokenType[]; +} + +const BridgeContext = createContext({ + selectedSourceChain: BRIDGE_SUPPORTED_CHAINS[0], + setSelectedSourceChain: () => { + return; + }, + sourceChainOptions: BRIDGE_SUPPORTED_CHAINS, + + selectedDestinationChain: BRIDGE_SUPPORTED_CHAINS[1], + setSelectedDestinationChain: () => { + return; + }, + destinationChainOptions: [BRIDGE_SUPPORTED_CHAINS[1]], + + destinationAddress: '', + setDestinationAddress: () => { + return; + }, + + amount: null, + setAmount: () => { + return; + }, + + selectedToken: BRIDGE_SUPPORTED_TOKENS[0], + setSelectedToken: () => { + return; + }, + tokenOptions: [], +}); + +export const useBridge = () => { + return useContext(BridgeContext); +}; + +const BridgeProvider: FC = ({ children }) => { + const [selectedSourceChain, setSelectedSourceChain] = useState( + BRIDGE_SUPPORTED_CHAINS[0] + ); + const [selectedDestinationChain, setSelectedDestinationChain] = + useState(BRIDGE_SUPPORTED_CHAINS[1]); + + const [destinationAddress, setDestinationAddress] = useState(''); + const [amount, setAmount] = useState(null); + const [selectedToken, setSelectedToken] = useState( + BRIDGE_SUPPORTED_TOKENS[0] + ); + + const selectedDestinationChainOptions = useMemo( + () => + BRIDGE_SUPPORTED_CHAINS.filter( + (chain) => chain.id !== selectedSourceChain.id + ), + [selectedSourceChain] + ); + + useEffect(() => { + // If current destination chain is not in the destination chain options, + // set the first option as the destination chain. + if ( + !selectedDestinationChainOptions.find( + (chain) => chain.id === selectedDestinationChain.id + ) + ) { + setSelectedDestinationChain(selectedDestinationChainOptions[0]); + } + }, [selectedDestinationChainOptions, selectedDestinationChain.id]); + + return ( + + {children} + + ); +}; + +export default BridgeProvider; diff --git a/apps/tangle-dapp/context/RestakeContext.tsx b/apps/tangle-dapp/context/RestakeContext.tsx index 5d330df5d5..800b6a5961 100644 --- a/apps/tangle-dapp/context/RestakeContext.tsx +++ b/apps/tangle-dapp/context/RestakeContext.tsx @@ -9,13 +9,13 @@ import useRestakingEarnings from '../data/restaking/useRestakingEarnings'; import useRestakingRoleLedger from '../data/restaking/useRestakingRoleLedger'; import useSubstrateAddress from '../hooks/useSubstrateAddress'; -export type RestakeContextType = { +interface RestakeContextProps { ledger: Option | null; earningsRecord: EarningRecord | null; isLoading: boolean; -}; +} -export const RestakeContext = createContext({ +export const RestakeContext = createContext({ ledger: null, earningsRecord: null, isLoading: true, diff --git a/apps/tangle-dapp/types/index.ts b/apps/tangle-dapp/types/index.ts index 5ea0e9e072..7a2998de06 100755 --- a/apps/tangle-dapp/types/index.ts +++ b/apps/tangle-dapp/types/index.ts @@ -14,6 +14,7 @@ export enum PagePath { NOMINATION = '/nomination', CLAIM_AIRDROP = '/claim', ACCOUNT = '/', + BRIDGE = '/bridge', SERVICES_OVERVIEW = '/services', SERVICES_RESTAKE = '/restake', } @@ -234,4 +235,10 @@ export type ExposureMap = Record< } >; +// TODO: might need to add more metadata here: name, decimals, etc. +export type BridgeTokenType = { + id: string; + symbol: string; +}; + export type TokenSymbol = 'tTNT' | 'TNT'; diff --git a/libs/webb-ui-components/src/components/MenuItem/MenuItem.tsx b/libs/webb-ui-components/src/components/MenuItem/MenuItem.tsx index b38f64cc8d..993de062a6 100644 --- a/libs/webb-ui-components/src/components/MenuItem/MenuItem.tsx +++ b/libs/webb-ui-components/src/components/MenuItem/MenuItem.tsx @@ -48,7 +48,7 @@ export const MenuItem = React.forwardRef( return ( - {startIcon &&
    {startIcon}
    } + {startIcon &&
    {startIcon}
    } {children} diff --git a/libs/webb-ui-components/src/components/buttons/ChainButton.tsx b/libs/webb-ui-components/src/components/buttons/ChainOrTokenButton.tsx similarity index 56% rename from libs/webb-ui-components/src/components/buttons/ChainButton.tsx rename to libs/webb-ui-components/src/components/buttons/ChainOrTokenButton.tsx index 475f2eb765..af389a3da5 100644 --- a/libs/webb-ui-components/src/components/buttons/ChainButton.tsx +++ b/libs/webb-ui-components/src/components/buttons/ChainOrTokenButton.tsx @@ -1,26 +1,38 @@ -import { ChainIcon, ChevronDown } from '@webb-tools/icons'; +import { ChainIcon, ChevronDown, TokenIcon } from '@webb-tools/icons'; import { getFlexBasic } from '@webb-tools/icons/utils'; import cx from 'classnames'; import { forwardRef, useMemo } from 'react'; import { twMerge } from 'tailwind-merge'; -import { ChainButtonProps } from './types'; +import { ChainOrTokenButtonProps } from './types'; -const ChainButton = forwardRef( +const ChainOrTokenButton = forwardRef< + HTMLButtonElement, + ChainOrTokenButtonProps +>( ( { className, - chain, + value, status, - textClassname, + textClassName, disabled, placeholder = 'Select Chain', + iconType, ...props }, ref ) => { const textClsx = useMemo(() => { - return twMerge('font-bold', textClassname); - }, [textClassname]); + return twMerge( + 'font-bold', + iconType === 'token' ? 'uppercase' : '', + textClassName + ); + }, [iconType, textClassName]); + + const IconCmp = useMemo(() => { + return iconType === 'chain' ? ChainIcon : TokenIcon; + }, [iconType]); return (