diff --git a/apps/extension/src/ui/api/api.ts b/apps/extension/src/ui/api/api.ts index 9b43d51860..28ff194479 100644 --- a/apps/extension/src/ui/api/api.ts +++ b/apps/extension/src/ui/api/api.ts @@ -36,8 +36,6 @@ export const api: MessageTypes = { allowPhishingSite: (url) => messageService.sendMessage("pri(app.phishing.addException)", { url }), // app messages ------------------------------------------------------- - modalOpen: (request) => messageService.sendMessage("pri(app.modalOpen.request)", request), - modalOpenSubscribe: (cb) => messageService.subscribe("pri(app.modalOpen.subscribe)", null, cb), analyticsCapture: (request) => messageService.sendMessage("pri(app.analyticsCapture)", request), sendFundsOpen: (request = {}) => messageService.sendMessage("pri(app.sendFunds.open)", request), resetWallet: () => messageService.sendMessage("pri(app.resetWallet)"), diff --git a/apps/extension/src/ui/api/types.ts b/apps/extension/src/ui/api/types.ts index b35d35cced..6f8c74e05d 100644 --- a/apps/extension/src/ui/api/types.ts +++ b/apps/extension/src/ui/api/types.ts @@ -41,7 +41,6 @@ import { EvmAddress, LoggedinType, MetadataUpdateStatus, - ModalOpenRequest, NftData, ProviderType, RequestAccountCreateLedgerSubstrate, @@ -119,8 +118,6 @@ export default interface MessageTypes { cancelEncryptRequest: (id: DecryptRequestId | EncryptRequestId) => Promise // app message types ------------------------------------------------------- - modalOpen: (modal: ModalOpenRequest) => Promise - modalOpenSubscribe: (cb: (val: ModalOpenRequest) => void) => UnsubscribeFn analyticsCapture: (request: AnalyticsCaptureRequest) => Promise sendFundsOpen: (request?: SendFundsOpenRequest) => Promise resetWallet: () => Promise diff --git a/apps/extension/src/ui/apps/dashboard/index.tsx b/apps/extension/src/ui/apps/dashboard/index.tsx index 9b85371d7f..10e7de991b 100644 --- a/apps/extension/src/ui/apps/dashboard/index.tsx +++ b/apps/extension/src/ui/apps/dashboard/index.tsx @@ -9,7 +9,6 @@ import { SuspenseTracker } from "@talisman/components/SuspenseTracker" import { api } from "@ui/api" import { DatabaseErrorAlert } from "@ui/domains/Settings/DatabaseErrorAlert" import { useLoginCheck } from "@ui/hooks/useLoginCheck" -import { useModalSubscription } from "@ui/hooks/useModalSubscription" import { AccountAddMenu } from "./routes/AccountAdd" import { AccountAddDcentDashboardWizard } from "./routes/AccountAdd/AccountAddDcentWizard" @@ -46,8 +45,6 @@ import { TokensPage } from "./routes/Tokens/TokensPage" import { TxHistory } from "./routes/TxHistory" const DashboardInner = () => { - useModalSubscription() - return ( }> diff --git a/apps/extension/src/ui/apps/dashboard/routes/Portfolio/index.tsx b/apps/extension/src/ui/apps/dashboard/routes/Portfolio/index.tsx index e5f79beedb..6399227697 100644 --- a/apps/extension/src/ui/apps/dashboard/routes/Portfolio/index.tsx +++ b/apps/extension/src/ui/apps/dashboard/routes/Portfolio/index.tsx @@ -3,7 +3,7 @@ import { Route, Routes, useSearchParams } from "react-router-dom" import { NavigateWithQuery } from "@talisman/components/NavigateWithQuery" import { DashboardLayout } from "@ui/apps/dashboard/layout" -import { useBuyTokensModal } from "@ui/domains/Asset/Buy/useBuyTokensModal" +import { useBuyTokensModal } from "@ui/domains/Asset/Buy/hooks/useBuyTokensModal" import { DashboardPortfolioHeader } from "@ui/domains/Portfolio/DashboardPortfolioHeader" import { PortfolioContainer } from "@ui/domains/Portfolio/PortfolioContainer" import { PortfolioToolbarNfts } from "@ui/domains/Portfolio/PortfolioToolbarNfts" diff --git a/apps/extension/src/ui/apps/popup/components/TotalFiatBalance.tsx b/apps/extension/src/ui/apps/popup/components/TotalFiatBalance.tsx index 6142b3c58a..9978e0b0e4 100644 --- a/apps/extension/src/ui/apps/popup/components/TotalFiatBalance.tsx +++ b/apps/extension/src/ui/apps/popup/components/TotalFiatBalance.tsx @@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui" import { api } from "@ui/api" import { AnalyticsEventName, AnalyticsPage, sendAnalyticsEvent } from "@ui/api/analytics" +import { useBuyTokensModal } from "@ui/domains/Asset/Buy/hooks/useBuyTokensModal" import { currencyConfig } from "@ui/domains/Asset/currencyConfig" import { Fiat } from "@ui/domains/Asset/Fiat" import { useCopyAddressModal } from "@ui/domains/CopyAddress" @@ -162,6 +163,7 @@ const ANALYTICS_PAGE: AnalyticsPage = { const TopActions = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation() const { open: openCopyAddressModal } = useCopyAddressModal() + const { open: openBuyTokensModal } = useBuyTokensModal() const ownedAccounts = useAccounts("owned") const canBuy = useFeatureFlag("BUY_CRYPTO") const showQuestLink = useFeatureFlag("QUEST_LINK") @@ -211,15 +213,23 @@ const TopActions = ({ disabled }: { disabled?: boolean }) => { ? { analyticsName: "Goto", analyticsAction: "Buy Crypto button", - label: t("Buy"), + label: t("Buy/Sell"), icon: CreditCardIcon, - onClick: () => api.modalOpen({ modalType: "buy" }).then(() => window.close()), + onClick: () => openBuyTokensModal(), disabled: disableActions, disabledReason, } : null, ].filter(Boolean) as Array, - [canBuy, disableActions, disabledReason, handleSwapClick, openCopyAddressModal, t], + [ + canBuy, + disableActions, + disabledReason, + handleSwapClick, + openBuyTokensModal, + openCopyAddressModal, + t, + ], ) return ( diff --git a/apps/extension/src/ui/apps/popup/index.tsx b/apps/extension/src/ui/apps/popup/index.tsx index 877f922895..29b7c37610 100644 --- a/apps/extension/src/ui/apps/popup/index.tsx +++ b/apps/extension/src/ui/apps/popup/index.tsx @@ -17,6 +17,7 @@ import { AccountExportModal } from "@ui/domains/Account/AccountExportModal" import { AccountExportPrivateKeyModal } from "@ui/domains/Account/AccountExportPrivateKeyModal" import { AccountRemoveModal } from "@ui/domains/Account/AccountRemoveModal" import { AccountRenameModal } from "@ui/domains/Account/AccountRenameModal" +import { BuyTokensModal } from "@ui/domains/Asset/Buy/BuyTokensModal" import { CopyAddressModal } from "@ui/domains/CopyAddress" import { DatabaseErrorAlert } from "@ui/domains/Settings/DatabaseErrorAlert" import { BondModal } from "@ui/domains/Staking/Bond/BondModal" @@ -88,6 +89,7 @@ const Popup = () => { + diff --git a/apps/extension/src/ui/apps/popup/pages/Portfolio/index.tsx b/apps/extension/src/ui/apps/popup/pages/Portfolio/index.tsx index ea82ebe332..fdb2cd33d2 100644 --- a/apps/extension/src/ui/apps/popup/pages/Portfolio/index.tsx +++ b/apps/extension/src/ui/apps/popup/pages/Portfolio/index.tsx @@ -4,6 +4,7 @@ import { Route, Routes, useLocation } from "react-router-dom" import { ScrollContainer } from "@talisman/components/ScrollContainer" import { SuspenseTracker } from "@talisman/components/SuspenseTracker" +import { BuyTokensModal } from "@ui/domains/Asset/Buy/BuyTokensModal" import { PortfolioContainer } from "@ui/domains/Portfolio/PortfolioContainer" import BraveWarningPopupBanner from "@ui/domains/Settings/BraveWarning/BraveWarningPopupBanner" import MigratePasswordAlert from "@ui/domains/Settings/MigratePasswordAlert" @@ -22,6 +23,7 @@ const PortfolioRoutes = () => ( } /> } /> } /> + } /> } /> }> diff --git a/apps/extension/src/ui/domains/Asset/Buy/BuyTokensAmountField.tsx b/apps/extension/src/ui/domains/Asset/Buy/BuyTokensAmountField.tsx deleted file mode 100644 index 8fc0819147..0000000000 --- a/apps/extension/src/ui/domains/Asset/Buy/BuyTokensAmountField.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { Token } from "@talismn/chaindata-provider" -import { ChevronRightIcon, XIcon } from "@talismn/icons" -import { classNames } from "@talismn/util" -import { useCallback } from "react" -import { useTranslation } from "react-i18next" -import { Modal } from "talisman-ui" - -import { useOpenClose } from "@talisman/hooks/useOpenClose" -import { useToken } from "@ui/state" - -import { TokenLogo } from "../TokenLogo" -import { BuyTokensPicker } from "./BuyTokensPicker" - -type TokenAmountFieldProps = { - fieldProps: React.DetailedHTMLProps, HTMLInputElement> -} & { - prefix?: string - tokenId?: string - address?: string - onTokenChanged?: (tokenId: string) => void - tokensFilter?: (token: Token) => boolean - onTokenButtonClick?: () => void // use for analytics only -} - -/* - amount is uncontrolled - tokenId is controlled so it can be changed by parent -**/ -export const BuyTokensAmountField = ({ - prefix, - tokenId, - address, - onTokenChanged, - onTokenButtonClick, - fieldProps, - tokensFilter, -}: TokenAmountFieldProps) => { - const { t } = useTranslation("common") - const { open, isOpen, close } = useOpenClose() - const token = useToken(tokenId) - - const handleTokenSelect = useCallback( - (id: string) => { - onTokenChanged?.(id) - close() - }, - [close, onTokenChanged], - ) - - const handleTokenButtonClick = useCallback(() => { - onTokenButtonClick?.() - open() - }, [onTokenButtonClick, open]) - - return ( - <> -
- {/* CSS trick here we need prefix to be after input to have a valid CSS rule for prefix color change base on input beeing empty - items will be displayed in reverse order to make this workaround possible */} - - - {!!prefix && ( - - {prefix} - - )} -
- -
-
-
-
{t("Select a token")}
- -
- -
-
- - ) -} diff --git a/apps/extension/src/ui/domains/Asset/Buy/BuyTokensForm.tsx b/apps/extension/src/ui/domains/Asset/Buy/BuyTokensForm.tsx deleted file mode 100644 index 93c14e6d39..0000000000 --- a/apps/extension/src/ui/domains/Asset/Buy/BuyTokensForm.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { yupResolver } from "@hookform/resolvers/yup" -import { isEthereumAddress } from "@polkadot/util-crypto" -import { Chain, Token } from "@talismn/chaindata-provider" -import { encodeAnyAddress } from "@talismn/util" -import { useCallback, useEffect, useMemo, useRef } from "react" -import { useForm } from "react-hook-form" -import { useTranslation } from "react-i18next" -import { Button, Dropdown, DropdownOptionRender } from "talisman-ui" -import * as yup from "yup" - -import { - AccountJsonAny, - activeChainsStore, - activeEvmNetworksStore, - activeTokensStore, -} from "@extension/core" -import { BANXA_URL } from "@extension/shared" -import { AnalyticsPage, sendAnalyticsEvent } from "@ui/api/analytics" -import { FormattedAddress } from "@ui/domains/Account/FormattedAddress" -import { useAnalyticsPageView } from "@ui/hooks/useAnalyticsPageView" -import { - useAccounts, - useChains, - useChainsMap, - useEvmNetworksMap, - useRemoteConfig, - useTokens, - useTokensMap, -} from "@ui/state" - -import { BuyTokensAmountField } from "./BuyTokensAmountField" -import { useBuyTokensModal } from "./useBuyTokensModal" - -type FormData = { - address: string - amountUSD: number - tokenId: string -} - -const schema = yup.object({ - address: yup.string().required(" "), - amountUSD: yup.number().required(" ").min(0), - tokenId: yup.string().required(" "), -}) - -const ANALYTICS_PAGE: AnalyticsPage = { - container: "Fullscreen", - feature: "Account Funding", - featureVersion: 1, - page: "Buy Crypto Modal", -} - -const useSupportedTokenIds = (chains?: Chain[], tokens?: Token[], address?: string) => { - const config = useRemoteConfig() - - const supportedTokens = useMemo( - () => tokens?.filter((t) => config.buyTokens.tokenIds?.includes(t.id)) ?? [], - [config.buyTokens.tokenIds, tokens], - ) - - const { substrateTokenIds, ethereumTokenIds } = useMemo(() => { - return { - substrateTokenIds: - supportedTokens - ?.filter((t) => { - if (!["substrate-native"].includes(t.type)) return false - const chain = chains?.find((c) => c.id === t.chain?.id) - return chain && chain.account !== "secp256k1" - }) - .map((t) => t.id) ?? [], - ethereumTokenIds: - supportedTokens - ?.filter((t) => { - if (!["substrate-native", "evm-native", "evm-erc20", "evm-uniswapv2"].includes(t.type)) - return false - const chain = chains?.find((c) => c.id === t.chain?.id) - return !chain || (chain.account === "secp256k1" && chain.evmNetworks.length > 0) - }) - .map((t) => t.id) ?? [], - } - }, [chains, supportedTokens]) - - const filterTokens = useCallback( - (token: Token) => { - if (!address) return config.buyTokens.tokenIds.includes(token.id) - const allowedTokens = isEthereumAddress(address) ? ethereumTokenIds : substrateTokenIds - return allowedTokens.includes(token.id) - }, - [address, config.buyTokens.tokenIds, ethereumTokenIds, substrateTokenIds], - ) - - return { substrateTokenIds, ethereumTokenIds, filterTokens } -} - -const renderAccountItem: DropdownOptionRender = (account) => { - return ( -
- -
- ) -} - -export const BuyTokensForm = () => { - const [t] = useTranslation() - useAnalyticsPageView(ANALYTICS_PAGE) - const { close } = useBuyTokensModal() - const accounts = useAccounts("portfolio") - - const { - register, - handleSubmit, - setValue, - watch, - formState: { isValid }, - } = useForm({ - mode: "all", - resolver: yupResolver(schema), - }) - - const [address, tokenId] = watch(["address", "tokenId"]) - const tokens = useTokens({ activeOnly: false, includeTestnets: false }) - const tokensMap = useTokensMap({ activeOnly: false, includeTestnets: false }) - const chains = useChains({ activeOnly: false, includeTestnets: false }) - const chainsMap = useChainsMap({ activeOnly: false, includeTestnets: false }) - const evmNetworksMap = useEvmNetworksMap({ activeOnly: false, includeTestnets: false }) - - const { ethereumTokenIds, substrateTokenIds, filterTokens } = useSupportedTokenIds( - chains, - tokens, - address, - ) - - const autoSelectFirstAccountInit = useRef(false) - useEffect(() => { - if (autoSelectFirstAccountInit.current) return - if (address !== undefined) { - // disable auto-select if an address has already been picked - autoSelectFirstAccountInit.current = true - return - } - if (accounts?.length !== 1) { - // disable auto-select if user has more than one account - autoSelectFirstAccountInit.current = true - return - } - - // auto-select the one available account - setValue("address", accounts[0]?.address, { - shouldTouch: true, - shouldDirty: true, - shouldValidate: true, - }) - autoSelectFirstAccountInit.current = true - }, [accounts, accounts?.length, address, setValue]) - - const submit = useCallback( - async (formData: FormData) => { - if (!formData.tokenId) throw new Error(t("Token not found")) - if (!formData.address) throw new Error(t("Address not found")) - - const account = accounts.find(({ address }) => address === formData.address) - if (!account) throw new Error(t("Account not found")) - - const token = tokensMap[formData.tokenId] - if (!token) throw new Error(t("Token not found")) - activeTokensStore.setActive(token.id, true) - - const chain = token.chain?.id ? chainsMap[token.chain.id] : undefined - if (chain) activeChainsStore.setActive(chain.id, true) - - const isEthereum = isEthereumAddress(account.address) - if (!isEthereum && !chain) throw new Error(t("Chain not found")) - const evmNetwork = token.evmNetwork?.id ? evmNetworksMap[token.evmNetwork.id] : undefined - if (evmNetwork) activeEvmNetworksStore.setActive(evmNetwork.id, true) - - const walletAddress = isEthereum - ? account.address - : encodeAnyAddress(account.address, chain?.prefix ?? undefined) - - const qs = new URLSearchParams({ - walletAddress, - coinType: token?.symbol, - fiatAmount: String(formData.amountUSD), - fiatType: "USD", - }) - - sendAnalyticsEvent({ - ...ANALYTICS_PAGE, - name: "GotoExternal", - action: "Continue button - go to Banxa", - }) - - // close modal before redirect or chrome will keep it visible until user comes back - close() - - const redirectUrl = `${BANXA_URL}?${qs}` - window.open(redirectUrl, "_blank") - }, - [t, accounts, tokensMap, chainsMap, evmNetworksMap, close], - ) - - const handleAccountChange = useCallback( - (acc: AccountJsonAny | null) => { - if (!acc) return - sendAnalyticsEvent({ - ...ANALYTICS_PAGE, - name: "Interact", - action: "Account selection", - properties: { - accountType: isEthereumAddress(acc.address) ? "Ethereum" : "Substrate", - }, - }) - - if (tokenId && ethereumTokenIds.includes(tokenId) && !isEthereumAddress(acc.address)) - setValue("tokenId", "") - if (tokenId && substrateTokenIds.includes(tokenId) && isEthereumAddress(acc.address)) - setValue("tokenId", "") - - setValue("address", acc?.address, { shouldValidate: true }) - }, - [ethereumTokenIds, setValue, substrateTokenIds, tokenId], - ) - - const handleTokenChanged = useCallback( - (tokenId: string) => { - sendAnalyticsEvent({ - ...ANALYTICS_PAGE, - name: "Interact", - action: "Select Token", - properties: { - asset: tokenId, - }, - }) - if (ethereumTokenIds.includes(tokenId) && (!address || !isEthereumAddress(address))) { - const acc = accounts.find((acc) => isEthereumAddress(acc.address)) - setValue("address", acc?.address ?? "") - } - if (substrateTokenIds.includes(tokenId) && (!address || isEthereumAddress(address))) { - const acc = accounts.find((acc) => !isEthereumAddress(acc.address)) - setValue("address", acc?.address ?? "") - } - - setValue("tokenId", tokenId, { shouldValidate: true }) - }, - [accounts, address, ethereumTokenIds, setValue, substrateTokenIds], - ) - - const selectedAccount = useMemo( - () => accounts.find((acc) => acc.address === address), - [accounts, address], - ) - - const handleTokenButtonClick = useCallback(() => { - sendAnalyticsEvent({ - ...ANALYTICS_PAGE, - name: "Interact", - action: "Choose Token button", - }) - }, []) - - return ( -
- - - -
- {t("You will be redirected to Banxa to complete this transaction")} -
- - ) -} diff --git a/apps/extension/src/ui/domains/Asset/Buy/BuyTokensModal.tsx b/apps/extension/src/ui/domains/Asset/Buy/BuyTokensModal.tsx index 723c3407e3..26a8a276a1 100644 --- a/apps/extension/src/ui/domains/Asset/Buy/BuyTokensModal.tsx +++ b/apps/extension/src/ui/domains/Asset/Buy/BuyTokensModal.tsx @@ -1,23 +1,24 @@ -import { useTranslation } from "react-i18next" -import { Modal, ModalDialog } from "talisman-ui" +import { classNames } from "@talismn/util" +import { Modal } from "talisman-ui" -import { BuyTokensForm } from "./BuyTokensForm" -import { useBuyTokensModal } from "./useBuyTokensModal" +import { BuyTokensWizard } from "./components/BuyTokensWizard" +import { useBuyTokensModal } from "./hooks/useBuyTokensModal" // This control is injected directly in the layout of dashboard export const BuyTokensModal = () => { - const { t } = useTranslation() const { isOpen, close } = useBuyTokensModal() return ( - - - - + + ) } diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/BuyTokensLayout.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/BuyTokensLayout.tsx new file mode 100644 index 0000000000..694283c3ff --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/BuyTokensLayout.tsx @@ -0,0 +1,40 @@ +import { ChevronLeftIcon } from "@talismn/icons" +import { FC, ReactNode } from "react" +import { IconButton } from "talisman-ui" + +import { useBuyTokensWizard } from "../useBuyTokensWizard" +import { BuyTokensOptionSwitch } from "./form/BuyTokensOptionSwitch" + +type BuyTokensLayoutProps = { + title?: ReactNode + withBackLink?: boolean + children?: ReactNode +} + +export const BuyTokensLayout: FC = ({ title, children, withBackLink }) => { + const { close, route, setRoute } = useBuyTokensWizard() + const handleClick = () => { + route === "mainForm" ? close() : setRoute("mainForm") + } + + return ( +
+
+
+ {withBackLink && ( + + + + )} +
+
{title}
+
+
+
+ {route === "mainForm" && } +
+
+
{children}
+
+ ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/BuyTokensWizard.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/BuyTokensWizard.tsx new file mode 100644 index 0000000000..049b09f9ef --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/BuyTokensWizard.tsx @@ -0,0 +1,28 @@ +import { BuyTokensWizardProvider, useBuyTokensWizard } from "../useBuyTokensWizard" +import { BuyTokensAccountPicker } from "./form/routes/BuyTokensAccountPicker" +import { BuyTokensFiatPicker } from "./form/routes/BuyTokensFiatPicker" +import { BuyTokensForm } from "./form/routes/BuyTokensForm" +import { BuyTokensTokenPicker } from "./form/routes/BuyTokensTokenPicker" + +const Routes = () => { + const { route } = useBuyTokensWizard() + + switch (route) { + case "mainForm": + return + case "pickFiat": + return + case "pickToken": + return + case "pickWallet": + return + } +} + +export const BuyTokensWizard = () => { + return ( + + + + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/InputWithSideComponent.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/InputWithSideComponent.tsx new file mode 100644 index 0000000000..4867f73b21 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/InputWithSideComponent.tsx @@ -0,0 +1,59 @@ +import { classNames } from "@talismn/util" + +export type InputWithSideComponentProps = { + inputFieldProps: React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + > + inputFieldLabel: string | number + inputType: "string" | "number" + inputPlaceholder: string + isLoading?: boolean + isDisabled: boolean + minStep?: string + errorMessage?: string | null + sideComponent: React.ReactNode + onInputChange?: (event: React.ChangeEvent) => void +} + +export const InputWithSideComponent = ({ + inputFieldProps, + inputFieldLabel, + inputType, + inputPlaceholder, + isLoading, + minStep, + errorMessage, + sideComponent, + isDisabled, + onInputChange, +}: InputWithSideComponentProps) => { + return ( + <> +
+
+ +
+ {inputFieldLabel ?? "..."} +
+
+ {sideComponent} +
+ {errorMessage &&
{errorMessage}
} + + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensButton.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensButton.tsx new file mode 100644 index 0000000000..b828dfcdaf --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensButton.tsx @@ -0,0 +1,54 @@ +import { PlusIcon, XIcon } from "@talismn/icons" +import { classNames } from "@talismn/util" +import { ReactNode } from "react" +import { IconButton } from "talisman-ui" + +type BuyTokensButtonProps = { + shouldRenderSelected: boolean + selectedItem: ReactNode + label: string + isDisabled: boolean + onClick: () => void + onClear: (e: React.MouseEvent) => void +} + +export const BuyTokensButton = ({ + shouldRenderSelected, + selectedItem, + label, + isDisabled, + onClick, + onClear, +}: BuyTokensButtonProps) => { + return ( + null} + className={classNames( + "border-grey-750 bg-grey-800 flex h-full w-[14rem] items-center gap-4 rounded-[12px] px-4 py-3", + isDisabled && "cursor-not-allowed opacity-50", + )} + > + {shouldRenderSelected ? ( +
+ {selectedItem} +
onClear(e)} + role="button" + tabIndex={0} + onKeyDown={() => null} + > + +
+
+ ) : ( +
+
+ +
+
{label}
+
+ )} +
+ ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensConnectAccount.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensConnectAccount.tsx new file mode 100644 index 0000000000..cb896fcaed --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensConnectAccount.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" + +import { api } from "@ui/api" +import { IS_POPUP } from "@ui/util/constants" + +import { useBuyTokensWizard } from "../../useBuyTokensWizard" + +type BuyTokensConnectAccountProps = { + isEvm: boolean +} + +export const BuyTokensConnectAccount = ({ isEvm }: BuyTokensConnectAccountProps) => { + const { t } = useTranslation() + const navigate = useNavigate() + const { close } = useBuyTokensWizard() + + return ( + + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensFiatAmountInput.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensFiatAmountInput.tsx new file mode 100644 index 0000000000..2e69d1454e --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensFiatAmountInput.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from "react-i18next" + +import { useBuyTokensWizard } from "../../useBuyTokensWizard" +import { InputWithSideComponent } from "../InputWithSideComponent" +import { BuyTokensSelectFiatButton } from "./BuyTokensSelectFiatButton" + +export const BuyTokensFiatAmountInput = () => { + const { t } = useTranslation() + const { + isRampNotSupported, + buySellForm: { register, watch, setValue }, + setDebouncedFiatAmount, + isFiatAboveMinPurchaseAmount, + } = useBuyTokensWizard() + + const [fiatCurrency, { minPurchaseAmount, symbol }] = watch(["fiatCurrency", "rampTokenAsset"]) + + const handleFiatAmountChange = (e: React.ChangeEvent) => { + const amount = e.target.value + setDebouncedFiatAmount(amount) + + if (!amount) { + setValue("tokenAmount", 0, { shouldValidate: true }) + } + + setValue("dirtyAmountField", "fiatAmount", { shouldValidate: true }) + } + + return ( + { + handleFiatAmountChange(e) + register("fiatAmount").onChange(e) + }} + sideComponent={} + minStep="0.01" + isDisabled={isRampNotSupported} + errorMessage={ + !isFiatAboveMinPurchaseAmount + ? t(`The minimum purchase amount for ${symbol} is ${minPurchaseAmount} ${fiatCurrency}`) + : "" + } + /> + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensNotAvailableDrawer.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensNotAvailableDrawer.tsx new file mode 100644 index 0000000000..3eb2911861 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensNotAvailableDrawer.tsx @@ -0,0 +1,29 @@ +import { XCircleIcon } from "@talismn/icons" +import { useTranslation } from "react-i18next" +import { Button, Drawer } from "talisman-ui" + +type BuyTokensNotAvailableDrawerProps = { + containerId: string | undefined + isOpen: boolean + onDismiss: () => void +} + +export const BuyTokensNotAvailableDrawer = ({ + isOpen, + containerId, + onDismiss, +}: BuyTokensNotAvailableDrawerProps) => { + const { t } = useTranslation() + + return ( + +
+ + {t("This service is not available in your region yet")} + +
+
+ ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensOptionSwitch.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensOptionSwitch.tsx new file mode 100644 index 0000000000..a17f1ec331 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensOptionSwitch.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next" + +import { OptionSwitch } from "@talisman/components/OptionSwitch" + +import { useBuyTokensWizard } from "../../useBuyTokensWizard" + +export const BuyTokensOptionSwitch = () => { + const { t } = useTranslation() + const { isBuyForm, handleToggleFormType } = useBuyTokensWizard() + + return ( + handleToggleFormType(option)} + /> + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectAccountInput.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectAccountInput.tsx new file mode 100644 index 0000000000..85ebefbf2d --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectAccountInput.tsx @@ -0,0 +1,50 @@ +import { classNames } from "@talismn/util" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +import { AccountRow } from "@ui/domains/SendFunds/AccountRow" + +import { useBuyTokensWizard } from "../../useBuyTokensWizard" +import { BuyTokensConnectAccount } from "./BuyTokensConnectAccount" + +export const BuyTokensSelectAccountInput = () => { + const { t } = useTranslation() + const { + isRampNotSupported, + supportedAccountsWithBalance, + setRoute, + buySellForm: { watch, setValue }, + } = useBuyTokensWizard() + const [{ symbol, isEvm }, address] = watch(["rampTokenAsset", "address"]) + + const selectedAccount = useMemo( + () => supportedAccountsWithBalance.find((acc) => acc.address === address), + [supportedAccountsWithBalance, address], + ) + + return !!symbol && supportedAccountsWithBalance.length === 0 ? ( + + ) : selectedAccount ? ( + setRoute("pickWallet")} + onClear={(e: React.MouseEvent) => { + e.stopPropagation() + setValue("address", "", { shouldValidate: true }) + }} + /> + ) : ( + + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectFiatButton.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectFiatButton.tsx new file mode 100644 index 0000000000..5bb480399c --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectFiatButton.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next" + +import { RampCurrency } from "../../types" +import { useBuyTokensWizard } from "../../useBuyTokensWizard" +import { currencyInfo } from "../../utils/currencyInfo" +import { BuyTokensButton } from "./BuyTokensButton" + +const RenderFiatCurrencyItem = ({ item }: { item: RampCurrency | undefined }) => { + if (!item) return + const fiatCurrencyIfo = currencyInfo[item.fiatCurrency ?? ""] + return ( +
+
+ {item.fiatCurrency} +
+
+
{item.fiatCurrency}
+
{item.name}
+
+
+ ) +} + +export const BuyTokensSelectFiatButton = () => { + const { t } = useTranslation() + const { + isRampNotSupported, + buySellForm: { watch, setValue }, + setRoute, + supportedRampCurrencies, + } = useBuyTokensWizard() + const [fiatCurrency] = watch(["fiatCurrency"]) + + const selectedFiatCurrency = supportedRampCurrencies.find( + (curr) => curr.fiatCurrency === fiatCurrency, + ) + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + setValue("fiatCurrency", "", { shouldValidate: true }) + } + + return ( + setRoute("pickFiat")} + onClear={handleClear} + shouldRenderSelected={!!fiatCurrency} + selectedItem={} + label={t("Select currency")} + /> + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectTokenButton.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectTokenButton.tsx new file mode 100644 index 0000000000..f903a1583b --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensSelectTokenButton.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from "react-i18next" + +import { RampTokenAsset } from "../../types" +import { DEFAULT_RAMP_TOKEN_ASSET, useBuyTokensWizard } from "../../useBuyTokensWizard" +import { BuyTokensButton } from "./BuyTokensButton" + +const RenderSelectedToken = ({ item }: { item: RampTokenAsset }) => { + return ( +
+
+ {item.symbol} +
+
+
{item.symbol}
+
{item.chainName}
+
+
+ ) +} + +export const BuyTokensSelectTokenButton = () => { + const { t } = useTranslation() + const { + isRampNotSupported, + setRoute, + buySellForm: { watch, setValue }, + } = useBuyTokensWizard() + + const [rampTokenAsset] = watch(["rampTokenAsset"]) + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + setValue("rampTokenAsset", DEFAULT_RAMP_TOKEN_ASSET, { shouldValidate: true }) + } + + return ( + setRoute("pickToken")} + onClear={handleClear} + shouldRenderSelected={!!rampTokenAsset.symbol} + selectedItem={} + label={t("Select token")} + /> + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensTokenAmountInput.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensTokenAmountInput.tsx new file mode 100644 index 0000000000..4d1fc0be20 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/BuyTokensTokenAmountInput.tsx @@ -0,0 +1,40 @@ +import { useBuyTokensWizard } from "../../useBuyTokensWizard" +import { InputWithSideComponent } from "../InputWithSideComponent" +import { BuyTokensSelectTokenButton } from "./BuyTokensSelectTokenButton" + +export const BuyTokensTokenAmountInput = () => { + const { + isRampNotSupported, + setDebouncedTokenAmount, + buySellForm: { setValue, register, watch }, + } = useBuyTokensWizard() + + const handleTokenAmountChange = (e: React.ChangeEvent) => { + const amount = e.target.value + setDebouncedTokenAmount(amount) + + if (!amount) { + setValue("fiatAmount", 0, { shouldValidate: true }) + } + + setValue("dirtyAmountField", "tokenAmount", { shouldValidate: true }) + } + + const [fiatAmount, { decimals }] = watch(["fiatAmount", "rampTokenAsset"]) + + return ( + ) => { + handleTokenAmountChange(e) + register("tokenAmount").onChange(e) + }} + minStep={`1e-${decimals}`} + sideComponent={} + isDisabled={isRampNotSupported} + /> + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensAccountPicker.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensAccountPicker.tsx new file mode 100644 index 0000000000..9a0e7221e0 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensAccountPicker.tsx @@ -0,0 +1,89 @@ +import { isEthereumAddress } from "@polkadot/util-crypto" +import { useCallback, useState } from "react" +import { useTranslation } from "react-i18next" + +import { ScrollContainer } from "@talisman/components/ScrollContainer" +import { SearchInput } from "@talisman/components/SearchInput" +import { AccountRow } from "@ui/domains/SendFunds/AccountRow" + +import { AccountWithBalance } from "../../../types" +import { useBuyTokensWizard } from "../../../useBuyTokensWizard" +import { BuyTokensLayout } from "../../BuyTokensLayout" + +export const BuyTokensAccountPicker = () => { + const [filteredAccounts, setFilteredAccounts] = useState([]) + const { + supportedAccountsWithBalance, + buySellForm: { watch, setValue }, + setRoute, + } = useBuyTokensWizard() + + const [address, { isEvm }] = watch(["address", "rampTokenAsset"]) + + const { t } = useTranslation() + + const handleAccountChange = useCallback( + (selectedAddress: string) => { + if (!selectedAddress) return + + setValue("address", selectedAddress, { shouldValidate: true }) + + if (isEvm !== isEthereumAddress(selectedAddress)) { + setValue( + "rampTokenAsset", + { + id: "", + symbol: "", + chain: "", + decimals: 0, + isEvm: false, + chainId: "", + chainPrefix: null, + minPurchaseAmount: 0, + }, + { shouldValidate: true }, + ) + } + + setRoute("mainForm") + }, + + [isEvm, setRoute, setValue], + ) + + const handleSearch = useCallback( + (value: string) => { + const filteredAccounts = supportedAccountsWithBalance.filter( + (account) => !value || account.name?.toLowerCase().includes(value), + ) + setFilteredAccounts(filteredAccounts) + }, + [supportedAccountsWithBalance], + ) + + return ( + +
+
+ +
+ + {filteredAccounts.map((account) => ( + handleAccountChange(account.address)} + /> + ))} + {!filteredAccounts?.length && ( +
+ {t("No account matches your search")} +
+ )} +
+
+
+ ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensFiatPicker.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensFiatPicker.tsx new file mode 100644 index 0000000000..f5e23bcf24 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensFiatPicker.tsx @@ -0,0 +1,139 @@ +import { CheckCircleIcon, XIcon } from "@talismn/icons" +import { classNames } from "@talismn/util" +import { useCallback, useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +import { ScrollContainer } from "@talisman/components/ScrollContainer" +import { SearchInput } from "@talisman/components/SearchInput" + +import { RampCurrency } from "../../../types" +import { useBuyTokensWizard } from "../../../useBuyTokensWizard" +import { currencyInfo } from "../../../utils/currencyInfo" +import { BuyTokensLayout } from "../../BuyTokensLayout" + +export const BuyTokensFiatPicker = () => { + const [filteredCurrency, setFilteredCurrency] = useState([]) + const { t } = useTranslation() + + const { + buySellForm: { watch, setValue }, + supportedRampCurrencies, + setRoute, + } = useBuyTokensWizard() + + const [fiatCurrency, { minPurchaseAmount }] = watch(["fiatCurrency", "rampTokenAsset"]) + + useEffect(() => { + // selected currency first + const sortedCurrencies = supportedRampCurrencies.sort((a, b) => { + // Sort by selected currency + if (a.fiatCurrency === fiatCurrency) return -1 + if (b.fiatCurrency === fiatCurrency) return 1 + // Then sort alphabetically + return a.fiatCurrency.localeCompare(b.fiatCurrency) + }) + + setFilteredCurrency(sortedCurrencies) + }, [fiatCurrency, supportedRampCurrencies]) + + const handleSearch = useCallback( + (value: string) => { + const filteredCurrencies = supportedRampCurrencies.filter( + (currency) => + !value || + currency.name.toLowerCase().includes(value.toLowerCase()) || + currency.fiatCurrency.toLowerCase().includes(value.toLowerCase()), + ) + setFilteredCurrency(filteredCurrencies) + }, + [supportedRampCurrencies], + ) + + const handleFiatCurrencySelect = useMemo( + () => (fiatCurrency: RampCurrency | null) => { + const newFiatCurrency = fiatCurrency?.fiatCurrency ?? "" + + setValue("fiatCurrency", newFiatCurrency, { shouldValidate: true }) + setValue("rampTokenAsset.minPurchaseAmount", minPurchaseAmount ?? 0, { shouldValidate: true }) + + setRoute("mainForm") + }, + [minPurchaseAmount, setRoute, setValue], + ) + + return ( + +
+
+ +
+ + {filteredCurrency.map((item) => ( + handleFiatCurrencySelect(item)} + selected={fiatCurrency === item.fiatCurrency} + /> + ))} + +
+
+ ) +} + +type FiatCurrencyItemProps = { + item: RampCurrency + onClick: () => void + onClear?: (e: React.MouseEvent) => void + selected: boolean + className?: string +} + +export const FiatCurrencyItem = ({ + item, + selected, + className, + onClick, + onClear, +}: FiatCurrencyItemProps) => { + const fiatCurrencyIfo = currencyInfo[item.fiatCurrency ?? ""] + return ( + + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensForm.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensForm.tsx new file mode 100644 index 0000000000..a4da17a863 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensForm.tsx @@ -0,0 +1,97 @@ +import { ExternalLinkIcon } from "@talismn/icons" +import { classNames } from "@talismn/util" +import { useCallback, useMemo } from "react" +import { useTranslation } from "react-i18next" +import { Button } from "talisman-ui" + +import { useBuyTokensWizard } from "../../../useBuyTokensWizard" +import { truncateToSignificantDigits } from "../../../utils/truncateToSignificantDigits" +import { BuyTokensLayout } from "../../BuyTokensLayout" +import { BuyTokensFiatAmountInput } from "../BuyTokensFiatAmountInput" +import { BuyTokensNotAvailableDrawer } from "../BuyTokensNotAvailableDrawer" +import { BuyTokensSelectAccountInput } from "../BuyTokensSelectAccountInput" +import { BuyTokensTokenAmountInput } from "../BuyTokensTokenAmountInput" + +export const BuyTokensForm = () => { + const { t } = useTranslation() + const { + isRampNotSupported, + supportedTokens, + isBuyForm, + isFormDisabled, + close, + buySellForm: { watch }, + submit, + } = useBuyTokensWizard() + + const [{ symbol, chain }, fiatCurrency] = watch(["rampTokenAsset", "fiatCurrency"]) + + const getTokenRateByCurrency = useCallback( + ({ fiatCurrency, tokenId, chain }: { fiatCurrency: string; tokenId: string; chain: string }) => + supportedTokens.find((asset) => asset.symbol === tokenId && asset.chain === chain)?.price[ + fiatCurrency + ], + [supportedTokens], + ) + + const tokenRateByCurrency = useMemo( + () => + getTokenRateByCurrency({ + fiatCurrency, + tokenId: symbol, + chain: chain, + }), + [getTokenRateByCurrency, fiatCurrency, chain, symbol], + ) + + return ( + +
+
+
+
{t("Step 1")}
+
{t("Select asset")}
+
+
{isBuyForm ? t("You Pay") : t("You Sell")}
+ {isBuyForm ? : } +
+
{t("You're receiving (estimate)")}
+ {symbol && ( +
{`1 ${symbol} ≈ ${truncateToSignificantDigits(tokenRateByCurrency ?? 0)} ${fiatCurrency || "$"}`}
+ )} +
+ {isBuyForm ? : } +
+
+
+
{t("Step 2")}
+
{t("Select account")}
+
+
{t("Deposit Account")}
+ +
+ {isRampNotSupported && ( + + )} + + +
+ ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensTokenPicker.tsx b/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensTokenPicker.tsx new file mode 100644 index 0000000000..d04aff97b0 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/components/form/routes/BuyTokensTokenPicker.tsx @@ -0,0 +1,85 @@ +import { Token, TokenId } from "@talismn/chaindata-provider" +import { isEthereumAddress } from "@talismn/util" +import { useCallback, useMemo } from "react" +import { useTranslation } from "react-i18next" + +import { ScrollContainer } from "@talisman/components/ScrollContainer" +import { TokenPicker } from "@ui/domains/Asset/TokenPicker" +import { useAccounts } from "@ui/state" + +import { useBuyTokensWizard } from "../../../useBuyTokensWizard" +import { BuyTokensLayout } from "../../BuyTokensLayout" + +export const BuyTokensTokenPicker = () => { + const { t } = useTranslation() + const accounts = useAccounts("portfolio") + const { + supportedTokens, + buySellForm: { setValue, watch }, + setRoute, + } = useBuyTokensWizard() + + const [address, { id }] = watch(["address", "rampTokenAsset"]) + + const supportedTokensIds = useMemo( + () => supportedTokens.map((token) => token.tokenData.id), + [supportedTokens], + ) + + const handleTokenSelect = useCallback( + (tokenId: TokenId) => { + const rampAsset = supportedTokens.find((token) => token.tokenData.id === tokenId) + const isEvmToken = !!rampAsset?.tokenData.token?.evmNetwork?.id + setValue( + "rampTokenAsset", + { + id: rampAsset?.tokenData.id ?? "", + symbol: rampAsset?.symbol ?? "", + chain: rampAsset?.chain ?? "", + decimals: rampAsset?.decimals ?? 0, + isEvm: isEvmToken, + chainId: rampAsset?.tokenData.chain?.id ?? "", + chainPrefix: + rampAsset?.tokenData?.chain && "prefix" in rampAsset.tokenData.chain + ? rampAsset.tokenData.chain.prefix + : null, + chainName: rampAsset?.tokenData.chain?.name ?? "", + logo: rampAsset?.logoUrl ?? "", + minPurchaseAmount: rampAsset?.minPurchaseAmount ?? 0, + }, + { shouldValidate: true }, + ) + if (isEvmToken && (!address || !isEthereumAddress(address))) { + const acc = accounts.find((acc) => isEthereumAddress(acc.address)) + setValue("address", acc?.address ?? "", { shouldValidate: true }) + } + if (!isEvmToken && (!address || isEthereumAddress(address))) { + const acc = accounts.find((acc) => !isEthereumAddress(acc.address)) + setValue("address", acc?.address ?? "", { shouldValidate: true }) + } + setRoute("mainForm") + }, + [accounts, address, setRoute, setValue, supportedTokens], + ) + + const tokenFilter = useCallback( + (token: Token) => { + return supportedTokensIds.includes(token.id) + }, + [supportedTokensIds], + ) + + return ( + + + + + + ) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/useBuyTokensModal.ts b/apps/extension/src/ui/domains/Asset/Buy/hooks/useBuyTokensModal.ts similarity index 100% rename from apps/extension/src/ui/domains/Asset/Buy/useBuyTokensModal.ts rename to apps/extension/src/ui/domains/Asset/Buy/hooks/useBuyTokensModal.ts diff --git a/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampCurrencies.ts b/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampCurrencies.ts new file mode 100644 index 0000000000..6836d35abc --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampCurrencies.ts @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query" + +import { useRemoteConfig } from "@ui/state" + +import { RampCurrency } from "../types" + +const fetchRampCurrencies = async ({ + rampApiBasePath, +}: { + rampApiBasePath: string +}): Promise => { + try { + return await ( + await fetch(`${rampApiBasePath}/currencies`, { + method: "GET", + }) + ).json() + } catch (cause) { + throw new Error("Failed to fetch Ramp currencies", { cause }) + } +} + +export const useGetRampCurrencies = () => { + const { + rampConfig: { rampApiBasePath }, + } = useRemoteConfig() + + return useQuery({ + queryKey: ["useGetRampCurrencies"], + queryFn: () => fetchRampCurrencies({ rampApiBasePath }), + staleTime: 1000 * 60 * 5, + }) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampOfframpAssetsByCurrency.ts b/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampOfframpAssetsByCurrency.ts new file mode 100644 index 0000000000..01ff71181b --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampOfframpAssetsByCurrency.ts @@ -0,0 +1,52 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query" + +import { useRemoteConfig } from "@ui/state" + +import { RampCurrencyWithAssets } from "../types" + +// note: currencyCode must be upper case +const fetchRampOfframpAssetsByCurrency = async ({ + currencyCode, + rampApiBasePath, +}: { + currencyCode: string | undefined + rampApiBasePath: string +}): Promise => { + try { + const apiUrl = currencyCode + ? `${rampApiBasePath}/offramp/assets?currencyCode=${currencyCode.toUpperCase()}` + : `${rampApiBasePath}/offramp/assets` + return await ( + await fetch(apiUrl, { + method: "GET", + }) + ).json() + } catch (cause) { + throw new Error("Failed to fetch Ramp offramp assets", { cause }) + } +} + +export const useGetRampOfframpAssetsByCurrency = ({ + currencyCode, + fiatAmount, + tokenAmount, + tokenId, + isEnabled, +}: { + currencyCode: string | undefined + fiatAmount: string + tokenAmount: string + tokenId: string + isEnabled: boolean +}) => { + const { + rampConfig: { rampApiBasePath }, + } = useRemoteConfig() + return useQuery({ + queryKey: ["useGetRampOfframpAssets", currencyCode, fiatAmount, tokenAmount, tokenId], + queryFn: () => fetchRampOfframpAssetsByCurrency({ currencyCode, rampApiBasePath }), + staleTime: 1000 * 60, + placeholderData: keepPreviousData, + enabled: isEnabled, + }) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampOnrampAssetsByCurrency.ts b/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampOnrampAssetsByCurrency.ts new file mode 100644 index 0000000000..8c7d24c4fd --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampOnrampAssetsByCurrency.ts @@ -0,0 +1,49 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query" + +import { useRemoteConfig } from "@ui/state" + +import { RampCurrencyWithAssets } from "../types" + +// note: currencyCode must be upper case +const fetchRampOnrampAssetsByCurrency = async ({ + currencyCode, + rampApiBasePath, +}: { + currencyCode: string | undefined + rampApiBasePath: string +}): Promise => { + try { + const apiUrl = currencyCode + ? `${rampApiBasePath}/assets?currencyCode=${currencyCode.toUpperCase()}` + : `${rampApiBasePath}/assets` + + return await (await fetch(apiUrl)).json() + } catch (cause) { + throw new Error("Failed to fetch Ramp assets", { cause }) + } +} + +export const useGetRampOnrampAssetsByCurrency = ({ + currencyCode, + fiatAmount, + tokenAmount, + tokenId, + isEnabled, +}: { + currencyCode: string | undefined + fiatAmount: string + tokenAmount: string + tokenId: string + isEnabled: boolean +}) => { + const { + rampConfig: { rampApiBasePath }, + } = useRemoteConfig() + return useQuery({ + queryKey: ["useGetRampOnrampAssets", currencyCode, fiatAmount, tokenAmount, tokenId], + queryFn: () => fetchRampOnrampAssetsByCurrency({ currencyCode, rampApiBasePath }), + staleTime: 1000 * 60, + placeholderData: keepPreviousData, + enabled: isEnabled, + }) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampQuote.ts b/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampQuote.ts new file mode 100644 index 0000000000..c651e5bbf3 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/hooks/useGetRampQuote.ts @@ -0,0 +1,104 @@ +import { useQuery } from "@tanstack/react-query" + +import { useRemoteConfig } from "@ui/state" + +import { RampQuote } from "../types" + +const fetchRampQuote = async ({ + currencyCode, + swapAsset, + tokenAmount, + fiatAmount, + isFiatQuote, + isBuyForm, + rampApiBasePath, + rampApiKey, +}: { + currencyCode: string + swapAsset: string + tokenAmount: string + fiatAmount: number + isFiatQuote: boolean + isBuyForm: boolean + rampApiBasePath: string | undefined + rampApiKey: string | undefined +}): Promise => { + try { + const requestBody: Record = { + fiatCurrency: currencyCode, + cryptoAssetSymbol: swapAsset, + } + + if (isFiatQuote) { + requestBody.fiatValue = fiatAmount + } else { + requestBody.cryptoAmount = tokenAmount + } + + const response = await fetch( + `${rampApiBasePath}/${isBuyForm ? "onramp" : "offramp"}/quote/all/?hostApiKey=${rampApiKey}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }, + ) + if (!response.ok) { + throw new Error(response.statusText) + } + return await response.json() + } catch (cause) { + throw new Error("Failed to fetch Ramp assets", { cause }) + } +} + +export const useGetRampQuote = ({ + currencyCode, + swapAsset, + tokenAmount, + fiatAmount, + isFiatQuote, + isBuyForm, + isEnabled, + retry = true, +}: { + currencyCode: string + swapAsset: string + tokenAmount: string + fiatAmount: number + isFiatQuote: boolean + isBuyForm: boolean + isEnabled: boolean + retry?: boolean +}) => { + const { + rampConfig: { rampApiBasePath, rampApiKey }, + } = useRemoteConfig() + + return useQuery({ + queryKey: [ + "useGetRampQuote", + currencyCode, + swapAsset, + tokenAmount, + fiatAmount, + { isFiatQuote, isBuyForm }, + ], + queryFn: () => + fetchRampQuote({ + currencyCode, + swapAsset, + tokenAmount, + fiatAmount, + isFiatQuote, + isBuyForm, + rampApiBasePath, + rampApiKey, + }), + staleTime: 1000 * 60, + retry, + enabled: isEnabled && !!currencyCode && isFiatQuote ? fiatAmount > 0 : Number(tokenAmount) > 0, + }) +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/hooks/useSupportedTokens.ts b/apps/extension/src/ui/domains/Asset/Buy/hooks/useSupportedTokens.ts new file mode 100644 index 0000000000..e62edbfcae --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/hooks/useSupportedTokens.ts @@ -0,0 +1,76 @@ +import { useMemo } from "react" + +import { DEBUG } from "@extension/shared" +import { useChainsMap, useEvmNetworksMap, useRemoteConfig, useTokensMap } from "@ui/state" + +import { RampAsset, RampAssetWithTokenAndChain } from "../types" + +export function useSupportedTokens({ rampAssets }: { rampAssets: RampAsset[] }) { + const tokensMap = useTokensMap({ activeOnly: false, includeTestnets: false }) + const chainsMap = useChainsMap({ activeOnly: false, includeTestnets: false }) + const evmNetworksMap = useEvmNetworksMap({ activeOnly: false, includeTestnets: false }) + const { rampSupportedTokenIds } = useRemoteConfig() + + const { ethereumTokens, substrateTokens } = useMemo( + () => + rampAssets.reduce<{ + ethereumTokens: RampAssetWithTokenAndChain[] + substrateTokens: RampAssetWithTokenAndChain[] + }>( + (acc, asset) => { + const key = `${asset.chain}_${asset.symbol}` + if (key in rampSupportedTokenIds) { + const supportedTokenId = rampSupportedTokenIds[key] + const token = tokensMap[supportedTokenId] + if (!token) { + if (DEBUG) + // eslint-disable-next-line no-console + console.error(`Token not found for tokenId ${supportedTokenId}`) + return acc + } + + const chain = token?.evmNetwork + ? evmNetworksMap[token.evmNetwork.id] + : chainsMap[token.chain?.id ?? 0] + + if (!chain) { + if (DEBUG) + // eslint-disable-next-line no-console + console.error( + `Chain not found for chainId ${token?.chain?.id} and tokenId ${supportedTokenId}}`, + ) + return acc + } + if (token?.evmNetwork) { + acc.ethereumTokens.push({ + ...asset, + tokenData: { + id: supportedTokenId, + token, + chain, + }, + }) + } else { + acc.substrateTokens.push({ + ...asset, + tokenData: { + id: supportedTokenId, + token, + chain, + }, + }) + } + } + return acc + }, + { ethereumTokens: [], substrateTokens: [] }, + ), + [chainsMap, evmNetworksMap, rampAssets, rampSupportedTokenIds, tokensMap], + ) + + return { + ethereumTokens, + substrateTokens, + allSupportedTokens: [...ethereumTokens, ...substrateTokens], + } +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/types.ts b/apps/extension/src/ui/domains/Asset/Buy/types.ts new file mode 100644 index 0000000000..0c8499d41a --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/types.ts @@ -0,0 +1,91 @@ +import { Token } from "@talismn/chaindata-provider" + +import { AccountJsonAny, EvmNetwork } from "@extension/core" +import { AnyChain } from "@ui/state" + +export type RampCurrency = { + fiatCurrency: string + name: string + onrampAvailable: boolean + offrampAvailable: boolean +} + +type AssetPrice = Record + +export type RampAsset = { + address: string + symbol: string + chain: string + name: string + decimals: number + type: string + enabled: boolean + logoUrl: string + hidden: boolean + networkFee: number + price: AssetPrice + currencyCode: string + minPurchaseAmount: number + maxPurchaseAmount: number + minPurchaseCryptoAmount: string +} + +export type RampCurrencyWithAssets = { + currencyCode: string + minPurchaseAmount: number + maxPurchaseAmount: number + minFeeAmount: number + minFeePercent: number + maxFeePercent: number + assets: RampAsset[] +} + +type PaymentMethod = { + fiatCurrency: string + cryptoAmount: string + fiatValue: number + baseRampFee: number + appliedFee: number +} + +export type RampQuote = { + CARD_PAYMENT?: PaymentMethod + APPLE_PAY?: PaymentMethod + CARD?: PaymentMethod + AMERICAN_BANK_TRANSFER?: PaymentMethod + asset: RampAsset +} + +export type RampAssetWithTokenAndChain = RampAsset & { + tokenData: { + chain: AnyChain | EvmNetwork | undefined + token: Token + id: string + } +} + +export type FormRoute = "mainForm" | "pickFiat" | "pickToken" | "pickWallet" + +export type RampTokenAsset = { + id: string + symbol: string + chain: string + chainPrefix?: number | null | undefined + chainId: string + chainName?: string + logo?: string + decimals: number + isEvm: boolean + minPurchaseAmount: number +} + +export type FormData = { + address: string + fiatAmount: number + fiatCurrency: string + tokenAmount: number + dirtyAmountField: string + rampTokenAsset: RampTokenAsset +} + +export type AccountWithBalance = AccountJsonAny & { total: number; balance?: undefined } diff --git a/apps/extension/src/ui/domains/Asset/Buy/useBuyTokensWizard.tsx b/apps/extension/src/ui/domains/Asset/Buy/useBuyTokensWizard.tsx new file mode 100644 index 0000000000..280fc79ae3 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/useBuyTokensWizard.tsx @@ -0,0 +1,315 @@ +import { yupResolver } from "@hookform/resolvers/yup" +import { isEthereumAddress } from "@polkadot/util-crypto" +import { convertAddress, planckToTokens, tokensToPlanck } from "@talismn/util" +import { useCallback, useEffect, useMemo, useState } from "react" +import { useForm } from "react-hook-form" + +import { activeChainsStore, activeEvmNetworksStore, activeTokensStore } from "@extension/core" +import { provideContext } from "@talisman/util/provideContext" +import { useGetRampOfframpAssetsByCurrency } from "@ui/domains/Asset/Buy/hooks/useGetRampOfframpAssetsByCurrency" +import { useGetRampOnrampAssetsByCurrency } from "@ui/domains/Asset/Buy/hooks/useGetRampOnrampAssetsByCurrency" +import { useDebouncedState } from "@ui/hooks/useDebouncedState" +import { usePortfolioAccounts } from "@ui/hooks/usePortfolioAccounts" +import { useAccounts, useRemoteConfig } from "@ui/state" + +import { useBuyTokensModal } from "./hooks/useBuyTokensModal" +import { useGetRampCurrencies } from "./hooks/useGetRampCurrencies" +import { useGetRampQuote } from "./hooks/useGetRampQuote" +import { useSupportedTokens } from "./hooks/useSupportedTokens" +import { FormData, FormRoute } from "./types" +import { schema } from "./utils/schema" +import { truncateToSignificantDigits } from "./utils/truncateToSignificantDigits" + +const TALISMAN_LOGO_URL = + "https://raw.githubusercontent.com/TalismanSociety/talisman-web/0fa6f5a99b4729f740c1a68bbe3d2ca9c85c9daa/apps/portal/public/talisman.svg" + +export const DEFAULT_RAMP_TOKEN_ASSET = { + id: "", + symbol: "", + chain: "", + chainPrefix: 0, + chainId: "", + chainName: "", + logo: "", + decimals: 0, + isEvm: false, + minPurchaseAmount: 0, +} + +const useBuyTokensWizardProvider = () => { + const [route, setRoute] = useState("mainForm") + const [isBuyForm, setIsBuyForm] = useState(true) + + const { open, close } = useBuyTokensModal() + const [debouncedFiatAmount, setDebouncedFiatAmount] = useDebouncedState("", 300) + const [debouncedTokenAmount, setDebouncedTokenAmount] = useDebouncedState("", 300) + const accounts = useAccounts("portfolio") + const { balanceTotalPerAccount } = usePortfolioAccounts() + + const { + rampConfig: { rampBasePath, rampApiKey }, + } = useRemoteConfig() + + const buySellForm = useForm({ + mode: "all", + defaultValues: { + dirtyAmountField: "fiatAmount", + rampTokenAsset: DEFAULT_RAMP_TOKEN_ASSET, + }, + resolver: yupResolver(schema), + }) + + const { + handleSubmit, + watch, + setValue, + formState: { isValid }, + } = buySellForm + + const [ + fiatCurrency, + { isEvm, symbol, chain, decimals, minPurchaseAmount, id }, + address, + dirtyAmountField, + ] = watch(["fiatCurrency", "rampTokenAsset", "address", "dirtyAmountField"]) + + const submit = handleSubmit((data: FormData) => { + const { fiatCurrency, rampTokenAsset, dirtyAmountField, tokenAmount, fiatAmount, address } = + data + + const formattedAddress = convertAddress(address, rampTokenAsset.chainPrefix ?? 0) || address + + activeTokensStore.setActive(rampTokenAsset.id, true) + if (rampTokenAsset.isEvm) { + activeEvmNetworksStore.setActive(rampTokenAsset.chainId, true) + } else { + activeChainsStore.setActive(rampTokenAsset.chainId, true) + } + + const params = new URLSearchParams({ + hostApiKey: rampApiKey, + hostLogoUrl: TALISMAN_LOGO_URL, + defaultFlow: isBuyForm ? "ONRAMP" : "OFFRAMP", + enabledFlows: "ONRAMP,OFFRAMP", + swapAsset: `${rampTokenAsset.chain}_${rampTokenAsset.symbol}`, + userAddress: formattedAddress, + fiatCurrency: fiatCurrency, + hostAppName: "Talisman", + }) + + // Dynamically add the amount parameter based on the dirtyAmountField + if (dirtyAmountField === "fiatAmount") { + params.append("fiatValue", fiatAmount.toString()) + } else { + params.append( + "swapAmount", + tokensToPlanck(tokenAmount.toString(), rampTokenAsset.decimals).toString(), + ) + } + + const url = `${rampBasePath}/?${params.toString()}` + + window.open(url, "_blank") + }) + + const supportedAccountsWithBalance = useMemo(() => { + const accountsWithBalance = accounts.map((acc) => ({ + ...acc, + total: balanceTotalPerAccount[acc.address], + })) + + if (!symbol) { + return accountsWithBalance + } + const evmByTokenChainType = accountsWithBalance.filter((acc) => + isEvm ? isEthereumAddress(acc.address) : !isEthereumAddress(acc.address), + ) + return evmByTokenChainType + }, [accounts, balanceTotalPerAccount, symbol, isEvm]) + + const { data: rampCurrencies } = useGetRampCurrencies() + + const supportedRampCurrencies = useMemo( + () => + rampCurrencies?.filter((curr) => + isBuyForm ? curr.onrampAvailable : curr.offrampAvailable, + ) ?? [], + [isBuyForm, rampCurrencies], + ) + + const { data: rampCurrencyWithOffRampAssets } = useGetRampOnrampAssetsByCurrency({ + currencyCode: fiatCurrency, + fiatAmount: debouncedFiatAmount, + tokenAmount: debouncedTokenAmount, + tokenId: symbol, + isEnabled: true, + }) + + const { data: rampCurrencyWithOfframpAssets } = useGetRampOfframpAssetsByCurrency({ + currencyCode: fiatCurrency, + fiatAmount: debouncedFiatAmount, + tokenAmount: debouncedTokenAmount, + tokenId: symbol, + isEnabled: true, + }) + + const isFiatAboveMinPurchaseAmount = useMemo(() => { + if (!minPurchaseAmount || !minPurchaseAmount || Number(debouncedFiatAmount) === 0) { + return true + } + + return Number(debouncedFiatAmount) > minPurchaseAmount + }, [debouncedFiatAmount, minPurchaseAmount]) + + const { + data: rampQuote, + isLoading: isRampQuoteLoading, + isError: isRampQuoteError, + } = useGetRampQuote({ + currencyCode: fiatCurrency, + swapAsset: `${chain}_${symbol}`, + tokenAmount: tokensToPlanck(debouncedTokenAmount || "0", decimals)?.toString(), + fiatAmount: Number(debouncedFiatAmount), + isFiatQuote: dirtyAmountField === "fiatAmount", + isBuyForm, + isEnabled: !!symbol && isFiatAboveMinPurchaseAmount, + }) + + // Check if Ramp is supported in the user's region + const { isError: isRampNotSupported } = useGetRampQuote({ + currencyCode: "USD", + swapAsset: "ETH_ETH", + tokenAmount: (1e18).toString(), // 1 ETH + fiatAmount: 0, + isFiatQuote: false, + isBuyForm, + isEnabled: true, + retry: false, + }) + + const quoteUpdateHandler = useCallback(() => { + if (!rampQuote || isRampQuoteLoading) return + + const { CARD_PAYMENT, CARD, asset } = rampQuote ?? {} + + const { fiatValue: onrampFiatValue, cryptoAmount: onrampCryptoAmount } = CARD_PAYMENT ?? {} + const { fiatValue: offrampFiatValue, cryptoAmount: offrampCryptoAmount } = CARD ?? {} + + if (dirtyAmountField === "fiatAmount") { + const tokenQuoteAmount = truncateToSignificantDigits( + Number( + planckToTokens( + (isBuyForm ? onrampCryptoAmount : offrampCryptoAmount) ?? "0", + asset?.decimals ?? 0, + ), + ), + ) + + setValue("tokenAmount", tokenQuoteAmount, { shouldValidate: true }) + } else { + const fiatQuoteAmount = isBuyForm ? onrampFiatValue : offrampFiatValue + + setValue("fiatAmount", fiatQuoteAmount ?? 0, { shouldValidate: true }) + } + }, [dirtyAmountField, isBuyForm, isRampQuoteLoading, rampQuote, setValue]) + + useEffect(() => { + quoteUpdateHandler() + }, [quoteUpdateHandler]) + + const rampAvailableCurrencies = useMemo( + () => (isBuyForm ? rampCurrencyWithOffRampAssets : rampCurrencyWithOfframpAssets), + [isBuyForm, rampCurrencyWithOffRampAssets, rampCurrencyWithOfframpAssets], + ) + + const { allSupportedTokens, ethereumTokens, substrateTokens } = useSupportedTokens({ + rampAssets: rampAvailableCurrencies?.assets ?? [], + }) + + const supportedTokens = useMemo(() => { + if (!address) return allSupportedTokens + return isEthereumAddress(address) ? ethereumTokens : substrateTokens + }, [address, ethereumTokens, substrateTokens, allSupportedTokens]) + + useEffect(() => { + const newMinPurchaseAmount = supportedTokens.find( + (token) => token.tokenData.id === id, + )?.minPurchaseAmount + if (fiatCurrency && minPurchaseAmount && newMinPurchaseAmount !== minPurchaseAmount) { + setValue("rampTokenAsset.minPurchaseAmount", newMinPurchaseAmount ?? 0, { + shouldValidate: true, + }) + } + }, [minPurchaseAmount, setValue, id, supportedTokens, fiatCurrency]) + + const handleToggleFormType = useCallback( + (option: "buy" | "sell") => { + const isBuyForm = option === "buy" + const isSelectedTokenSupported = supportedTokens.some((token) => token.tokenData.id === id) + + if (id && !isSelectedTokenSupported) { + setValue("rampTokenAsset", DEFAULT_RAMP_TOKEN_ASSET, { shouldValidate: true }) + setValue("tokenAmount", 0, { shouldValidate: true }) + } + const isFiatCurrencySupported = supportedRampCurrencies.some( + (curr) => curr.fiatCurrency === fiatCurrency, + ) + if (fiatCurrency && !isFiatCurrencySupported) { + setValue("fiatCurrency", "", { shouldValidate: true }) + } + + // Reset the address field if no token is supported for the selected chain + if (address && supportedTokens.length === 0) { + setValue("address", "", { shouldValidate: true }) + } + + setIsBuyForm(isBuyForm) + }, + [supportedTokens, id, supportedRampCurrencies, fiatCurrency, address, setValue], + ) + + const isFormDisabled = useMemo( + () => + !isValid || + isRampQuoteError || + isRampQuoteLoading || + !isFiatAboveMinPurchaseAmount || + isRampNotSupported, + [ + isFiatAboveMinPurchaseAmount, + isRampNotSupported, + isRampQuoteError, + isRampQuoteLoading, + isValid, + ], + ) + + const ctx = { + route, + buySellForm, + debouncedFiatAmount, + debouncedTokenAmount, + isBuyForm, + supportedAccountsWithBalance, + supportedTokens, + isFormDisabled, + supportedRampCurrencies, + isFiatAboveMinPurchaseAmount, + rampQuote, + isRampNotSupported, + setIsBuyForm, + setDebouncedFiatAmount, + setDebouncedTokenAmount, + setRoute, + open, + close, + submit, + handleToggleFormType, + } + + return ctx +} + +export const [BuyTokensWizardProvider, useBuyTokensWizard] = provideContext( + useBuyTokensWizardProvider, +) diff --git a/apps/extension/src/ui/domains/Asset/Buy/utils/currencyInfo.ts b/apps/extension/src/ui/domains/Asset/Buy/utils/currencyInfo.ts new file mode 100644 index 0000000000..8321cc31c7 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/utils/currencyInfo.ts @@ -0,0 +1,55 @@ +type CurrencyInfo = { + [key: string]: { + countryCode: string + currencyName: string + } +} + +export const currencyInfo: CurrencyInfo = { + ISK: { countryCode: "is", currencyName: "Icelandic Krona" }, + GEL: { countryCode: "ge", currencyName: "Georgian Lari" }, + GBP: { countryCode: "gb", currencyName: "British Pound Sterling" }, + BAM: { countryCode: "ba", currencyName: "Bosnia-Herzegovina Convertible Mark" }, + COP: { countryCode: "co", currencyName: "Colombian Peso" }, + BRL: { countryCode: "br", currencyName: "Brazilian Real" }, + RSD: { countryCode: "rs", currencyName: "Serbian Dinar" }, + HUF: { countryCode: "hu", currencyName: "Hungarian Forint" }, + HNL: { countryCode: "hn", currencyName: "Honduran Lempira" }, + MYR: { countryCode: "my", currencyName: "Malaysian Ringgit" }, + EUR: { countryCode: "eu", currencyName: "Euro" }, + CHF: { countryCode: "ch", currencyName: "Swiss Franc" }, + TJS: { countryCode: "tj", currencyName: "Tajikistani Somoni" }, + BMD: { countryCode: "bm", currencyName: "Bermudian Dollar" }, + KZT: { countryCode: "kz", currencyName: "Kazakhstani Tenge" }, + USD: { countryCode: "us", currencyName: "United States Dollar" }, + DKK: { countryCode: "dk", currencyName: "Danish Krone" }, + PEN: { countryCode: "pe", currencyName: "Peruvian Sol" }, + DOP: { countryCode: "do", currencyName: "Dominican Peso" }, + PYG: { countryCode: "py", currencyName: "Paraguayan Guarani" }, + RON: { countryCode: "ro", currencyName: "Romanian Leu" }, + BWP: { countryCode: "bw", currencyName: "Botswana Pula" }, + UAH: { countryCode: "ua", currencyName: "Ukrainian Hryvnia" }, + MZN: { countryCode: "mz", currencyName: "Mozambican Metical" }, + PLN: { countryCode: "pl", currencyName: "Polish Zloty" }, + ILS: { countryCode: "il", currencyName: "Israeli New Shekel" }, + LKR: { countryCode: "lk", currencyName: "Sri Lankan Rupee" }, + INR: { countryCode: "in", currencyName: "Indian Rupee" }, + KWD: { countryCode: "kw", currencyName: "Kuwaiti Dinar" }, + MXN: { countryCode: "mx", currencyName: "Mexican Peso" }, + THB: { countryCode: "th", currencyName: "Thai Baht" }, + NZD: { countryCode: "nz", currencyName: "New Zealand Dollar" }, + BGN: { countryCode: "bg", currencyName: "Bulgarian Lev" }, + KES: { countryCode: "ke", currencyName: "Kenyan Shilling" }, + UYU: { countryCode: "uy", currencyName: "Uruguayan Peso" }, + NGN: { countryCode: "ng", currencyName: "Nigerian Naira" }, + LAK: { countryCode: "la", currencyName: "Lao Kip" }, + MKD: { countryCode: "mk", currencyName: "Macedonian Denar" }, + SEK: { countryCode: "se", currencyName: "Swedish Krona" }, + HKD: { countryCode: "hk", currencyName: "Hong Kong Dollar" }, + ZAR: { countryCode: "za", currencyName: "South African Rand" }, + GTQ: { countryCode: "gt", currencyName: "Guatemalan Quetzal" }, + MDL: { countryCode: "md", currencyName: "Moldovan Leu" }, + CRC: { countryCode: "cr", currencyName: "Costa Rican Colón" }, + CZK: { countryCode: "cz", currencyName: "Czech Koruna" }, + SGD: { countryCode: "sg", currencyName: "Singapore Dollar" }, +} diff --git a/apps/extension/src/ui/domains/Asset/Buy/utils/schema.ts b/apps/extension/src/ui/domains/Asset/Buy/utils/schema.ts new file mode 100644 index 0000000000..97ef7cc9f7 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/utils/schema.ts @@ -0,0 +1,19 @@ +import * as yup from "yup" + +export const schema = yup.object({ + address: yup.string().required(" "), + fiatAmount: yup.number().required(" ").min(0), + tokenAmount: yup.number().required(" ").min(0), + fiatCurrency: yup.string().required(" "), + dirtyAmountField: yup.string().required(" "), + rampTokenAsset: yup.object().shape({ + id: yup.string().required(), + symbol: yup.string().required(), + chain: yup.string().required(), + chainId: yup.string().required(), + decimals: yup.number().required(), + isEvm: yup.boolean().required(), + chainPrefix: yup.number().nullable().optional(), + minPurchaseAmount: yup.number().required(" "), + }), +}) diff --git a/apps/extension/src/ui/domains/Asset/Buy/utils/truncateToSignificantDigits.ts b/apps/extension/src/ui/domains/Asset/Buy/utils/truncateToSignificantDigits.ts new file mode 100644 index 0000000000..f8a40171ae --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/Buy/utils/truncateToSignificantDigits.ts @@ -0,0 +1,12 @@ +export const truncateToSignificantDigits = (input: number): number => { + const str = input.toString() + if (!str.includes(".")) return input + + const [integerPart, decimalPart] = str.split(".") + + const firstNonZeroIndex = decimalPart.split("").findIndex((digit) => digit !== "0") + + const truncatedDecimal = decimalPart.slice(0, firstNonZeroIndex + 2) + + return parseFloat(`${integerPart}.${truncatedDecimal}`) +} diff --git a/apps/extension/src/ui/domains/Portfolio/AssetDetails/PopupAssetDetails.tsx b/apps/extension/src/ui/domains/Portfolio/AssetDetails/PopupAssetDetails.tsx index ff087cafaa..79b7696bdb 100644 --- a/apps/extension/src/ui/domains/Portfolio/AssetDetails/PopupAssetDetails.tsx +++ b/apps/extension/src/ui/domains/Portfolio/AssetDetails/PopupAssetDetails.tsx @@ -9,7 +9,7 @@ import { PillButton, Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui import { Balance, Balances } from "@extension/core" import { FadeIn } from "@talisman/components/FadeIn" import { SuspenseTracker } from "@talisman/components/SuspenseTracker" -import { api } from "@ui/api" +import { useBuyTokensModal } from "@ui/domains/Asset/Buy/hooks/useBuyTokensModal" import { ChainLogo } from "@ui/domains/Asset/ChainLogo" import { Fiat } from "@ui/domains/Asset/Fiat" import { TokenLogo } from "@ui/domains/Asset/TokenLogo" @@ -326,6 +326,7 @@ const NoTokens = ({ symbol }: { symbol: string }) => { const { selectedAccount, selectedFolder } = usePortfolioNavigation() const { open } = useCopyAddressModal() const { genericEvent } = useAnalytics() + const { open: openBuyTokensModal } = useBuyTokensModal() const handleCopy = useCallback(() => { open({ @@ -337,9 +338,8 @@ const NoTokens = ({ symbol }: { symbol: string }) => { const showBuyCrypto = useFeatureFlag("BUY_CRYPTO") const handleBuyCryptoClick = useCallback(async () => { - await api.modalOpen({ modalType: "buy" }) - window.close() - }, []) + openBuyTokensModal() + }, [openBuyTokensModal]) return ( diff --git a/apps/extension/src/ui/domains/Portfolio/DashboardPortfolioHeader.tsx b/apps/extension/src/ui/domains/Portfolio/DashboardPortfolioHeader.tsx index ec1d77e7f4..6722d6c05d 100644 --- a/apps/extension/src/ui/domains/Portfolio/DashboardPortfolioHeader.tsx +++ b/apps/extension/src/ui/domains/Portfolio/DashboardPortfolioHeader.tsx @@ -37,6 +37,7 @@ import { IS_EMBEDDED_POPUP } from "@ui/util/constants" import { AccountContextMenu } from "../Account/AccountContextMenu" import { AccountTypeIcon } from "../Account/AccountTypeIcon" import { FolderContextMenu } from "../Account/FolderContextMenu" +import { useBuyTokensModal } from "../Asset/Buy/hooks/useBuyTokensModal" import { usePortfolioNavigation } from "./usePortfolioNavigation" const SelectionScope: FC<{ account: AccountJsonAny | null; folder?: TreeFolder | null }> = ({ @@ -233,6 +234,7 @@ const TopActions: FC = () => { const { selectedAccounts, selectedAccount } = usePortfolioNavigation() const { t } = useTranslation() const { open: openCopyAddressModal } = useCopyAddressModal() + const { open: openBuyTokensModal } = useBuyTokensModal() const canBuy = useFeatureFlag("BUY_CRYPTO") const showQuestLink = useFeatureFlag("QUEST_LINK") @@ -288,13 +290,14 @@ const TopActions: FC = () => { disabled: disableActions, disabledReason, }, + canBuy ? { analyticsName: "Goto", analyticsAction: "Buy Crypto button", - label: t("Buy"), + label: t("Buy/Sell"), icon: CreditCardIcon, - onClick: () => api.modalOpen({ modalType: "buy" }), + onClick: () => openBuyTokensModal(), disabled: disableActions, disabledReason, } @@ -310,6 +313,7 @@ const TopActions: FC = () => { selectedAddress, symbol, openCopyAddressModal, + openBuyTokensModal, ], ) diff --git a/apps/extension/src/ui/domains/Portfolio/GetStarted/GetStarted.tsx b/apps/extension/src/ui/domains/Portfolio/GetStarted/GetStarted.tsx index bb2b72c12c..a7a78d7b60 100644 --- a/apps/extension/src/ui/domains/Portfolio/GetStarted/GetStarted.tsx +++ b/apps/extension/src/ui/domains/Portfolio/GetStarted/GetStarted.tsx @@ -8,7 +8,7 @@ import { IconButton } from "talisman-ui" import { api } from "@ui/api" import { AnalyticsPage, sendAnalyticsEvent } from "@ui/api/analytics" -import { useBuyTokensModal } from "@ui/domains/Asset/Buy/useBuyTokensModal" +import { useBuyTokensModal } from "@ui/domains/Asset/Buy/hooks/useBuyTokensModal" import { useCopyAddressModal } from "@ui/domains/CopyAddress" import { useAccounts, useAppState } from "@ui/state" import { closeIfEmbeddedPopup } from "@ui/util/closeIfEmbeddedPopup" @@ -159,10 +159,7 @@ const useGetStarted = () => { const onBuyClick = useCallback(() => { sendAnalyticsEvent({ ...ANALYTICS_PAGE, name: "Goto", action: "add funds" }) - if (IS_POPUP) api.dashboardOpen(`/portfolio?buyTokens`) - else openBuyTokensModal() - - closeIfEmbeddedPopup() + openBuyTokensModal() }, [openBuyTokensModal]) const onLearnMoreClick = useCallback(() => { diff --git a/apps/extension/src/ui/domains/Portfolio/NoTokensMessage.tsx b/apps/extension/src/ui/domains/Portfolio/NoTokensMessage.tsx index 8f72fdc3de..83de3d6f4f 100644 --- a/apps/extension/src/ui/domains/Portfolio/NoTokensMessage.tsx +++ b/apps/extension/src/ui/domains/Portfolio/NoTokensMessage.tsx @@ -6,7 +6,7 @@ import { PillButton } from "talisman-ui" import { useAnalytics } from "@ui/hooks/useAnalytics" import { useFeatureFlag } from "@ui/state" -import { useBuyTokensModal } from "../Asset/Buy/useBuyTokensModal" +import { useBuyTokensModal } from "../Asset/Buy/hooks/useBuyTokensModal" import { useCopyAddressModal } from "../CopyAddress" import { usePortfolioNavigation } from "./usePortfolioNavigation" diff --git a/apps/extension/src/ui/domains/SendFunds/AccountRow.tsx b/apps/extension/src/ui/domains/SendFunds/AccountRow.tsx new file mode 100644 index 0000000000..5bd54dd9ff --- /dev/null +++ b/apps/extension/src/ui/domains/SendFunds/AccountRow.tsx @@ -0,0 +1,156 @@ +import { Balance } from "@talismn/balances" +import { Token } from "@talismn/chaindata-provider" +import { CheckCircleIcon, XIcon } from "@talismn/icons" +import { classNames } from "@talismn/util" +import { useMemo } from "react" + +import { AccountType } from "@extension/core" +import { useFormattedAddress } from "@ui/hooks/useFormattedAddress" +import { useSelectedCurrency } from "@ui/state" + +import { AccountIcon } from "../Account/AccountIcon" +import { AccountTypeIcon } from "../Account/AccountTypeIcon" +import { Address } from "../Account/Address" +import { AccountWithBalance } from "../Asset/Buy/types" +import { Fiat } from "../Asset/Fiat" +import Tokens from "../Asset/Tokens" + +export type SendFundsAccount = { + address: string + origin?: AccountType + name?: string + genesisHash?: string | null + balance?: Balance | undefined + total?: number +} + +type AccountRowProps = { + account: SendFundsAccount | AccountWithBalance + genesisHash?: string | null + selected: boolean + showBalances?: boolean + showTotalBalance?: boolean + token?: Token | null + onClick?: () => void + disabled?: boolean + noFormat?: boolean + className?: string + onClear?: (e: React.MouseEvent) => void +} + +export const AccountRow = ({ + account, + genesisHash, + noFormat, + selected, + onClick, + showBalances, + showTotalBalance, + token, + disabled, + className, + onClear, +}: AccountRowProps) => { + const formattedAddress = useFormattedAddress( + account?.address, + genesisHash ?? account?.genesisHash, + ) + + const displayAddress = useMemo( + () => (noFormat ? account?.address : formattedAddress), + [noFormat, account?.address, formattedAddress], + ) + + return ( + + ) +} + +const AccountTokenBalance = ({ + token, + balance, + total, + showTotalBalance, + showBalances, +}: { + token?: Token | null + balance?: Balance | undefined + total?: number + showTotalBalance?: boolean + showBalances?: boolean +}) => { + const currency = useSelectedCurrency() + + if (showTotalBalance && total) { + return + } + + if (!showBalances || !balance || !token) return null + + return ( +
+
+ +
+
+ +
+
+ ) +} diff --git a/apps/extension/src/ui/domains/SendFunds/SendFundsAccountsList.tsx b/apps/extension/src/ui/domains/SendFunds/SendFundsAccountsList.tsx index 447b3d890a..db2b35bf65 100644 --- a/apps/extension/src/ui/domains/SendFunds/SendFundsAccountsList.tsx +++ b/apps/extension/src/ui/domains/SendFunds/SendFundsAccountsList.tsx @@ -1,19 +1,11 @@ import { Balance } from "@talismn/balances" -import { Token } from "@talismn/chaindata-provider" -import { CheckCircleIcon } from "@talismn/icons" -import { classNames } from "@talismn/util" import { FC, ReactNode, useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" import { AccountType } from "@extension/core" -import { useFormattedAddress } from "@ui/hooks/useFormattedAddress" -import { useBalances, useSelectedCurrency, useToken } from "@ui/state" +import { useBalances, useToken } from "@ui/state" -import { AccountIcon } from "../Account/AccountIcon" -import { AccountTypeIcon } from "../Account/AccountTypeIcon" -import { Address } from "../Account/Address" -import { Fiat } from "../Asset/Fiat" -import Tokens from "../Asset/Tokens" +import { AccountRow } from "./AccountRow" export type SendFundsAccount = { address: string @@ -23,101 +15,6 @@ export type SendFundsAccount = { balance?: Balance } -type AccountRowProps = { - account: SendFundsAccount - genesisHash?: string | null - selected: boolean - showBalances?: boolean - token?: Token | null - onClick?: () => void - disabled?: boolean - noFormat?: boolean -} - -const AccountTokenBalance = ({ token, balance }: { token?: Token | null; balance?: Balance }) => { - const currency = useSelectedCurrency() - - if (!balance || !token) return null - - return ( -
-
- -
-
- -
-
- ) -} - -const AccountRow: FC = ({ - account, - genesisHash, - noFormat, - selected, - onClick, - showBalances, - token, - disabled, -}) => { - const formattedAddress = useFormattedAddress( - account?.address, - genesisHash ?? account?.genesisHash, - ) - - const displayAddress = useMemo( - () => (noFormat ? account?.address : formattedAddress), - [noFormat, account?.address, formattedAddress], - ) - - return ( - - ) -} - type SendFundsAccountsListProps = { accounts: SendFundsAccount[] genesisHash?: string | null diff --git a/apps/extension/src/ui/hooks/useModalSubscription.tsx b/apps/extension/src/ui/hooks/useModalSubscription.tsx deleted file mode 100644 index b442fb282b..0000000000 --- a/apps/extension/src/ui/hooks/useModalSubscription.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect } from "react" - -import { api } from "@ui/api" -import { useBuyTokensModal } from "@ui/domains/Asset/Buy/useBuyTokensModal" - -const focusCurrentTab = async () => { - // ensure tab is active - const tab = await chrome.tabs.getCurrent() - - if (tab?.id && !tab.active) chrome.tabs.update(tab.id, { active: true }) - - // ensure window is focused - const win = await chrome.windows.getCurrent() - if (!win.focused && typeof win.id === "number") - await chrome.windows.update(win.id, { focused: true }) -} - -/** - * Use the useModalSubscription hook to subscribe to modal messages sent across extension environments (popup -> dashboard) - */ -export const useModalSubscription = () => { - const { open: openBuyTokensModal } = useBuyTokensModal() - - useEffect(() => { - const unsubscribe = api.modalOpenSubscribe(async (request) => { - switch (request.modalType) { - case "buy": - await focusCurrentTab() - openBuyTokensModal() - break - default: - break - } - }) - - return () => unsubscribe() - }, [openBuyTokensModal]) -} diff --git a/apps/extension/src/ui/state/chaindata.ts b/apps/extension/src/ui/state/chaindata.ts index cd187b2d4d..94978486a3 100644 --- a/apps/extension/src/ui/state/chaindata.ts +++ b/apps/extension/src/ui/state/chaindata.ts @@ -25,7 +25,7 @@ import { api } from "@ui/api" import { debugObservable } from "./util/debugObservable" -type AnyChain = Chain | CustomChain +export type AnyChain = Chain | CustomChain export type ChaindataQueryOptions = { activeOnly: boolean diff --git a/packages/extension-core/src/domains/app/handler.ts b/packages/extension-core/src/domains/app/handler.ts index b254943355..d6d9985326 100644 --- a/packages/extension-core/src/domains/app/handler.ts +++ b/packages/extension-core/src/domains/app/handler.ts @@ -2,7 +2,7 @@ import keyring from "@polkadot/ui-keyring" import { assert } from "@polkadot/util" import { sleep } from "@talismn/util" import { DEBUG, TALISMAN_WEB_APP_DOMAIN, TEST } from "extension-shared" -import { BehaviorSubject, Subject } from "rxjs" +import { BehaviorSubject } from "rxjs" import type { MessageTypes, RequestTypes, ResponseType } from "../../types" import type { @@ -10,7 +10,6 @@ import type { ChangePasswordStatusUpdate, ChangePasswordStatusUpdateType, LoggedinType, - ModalOpenRequest, RequestLogin, RequestOnboardCreatePassword, RequestRoute, @@ -29,8 +28,6 @@ import { PasswordStoreData } from "./store.password" import { ChangePasswordStatusUpdateStatus } from "./types" export default class AppHandler extends ExtensionHandler { - #modalOpenRequest = new Subject() - private async createPassword({ pass, passConfirm, @@ -221,17 +218,6 @@ export default class AppHandler extends ExtensionHandler { return true } - private async openModal(request: ModalOpenRequest): Promise { - const queryUrl = chrome.runtime.getURL("dashboard.html") - const [tab] = await chrome.tabs.query({ url: queryUrl }) - if (!tab) { - await windowManager.openDashboard({ route: "/portfolio" }) - // wait for newly created page to load and subscribe to backend (max 5 seconds) - for (let i = 0; i < 50 && !this.#modalOpenRequest.observed; i++) await sleep(100) - } - this.#modalOpenRequest.next(request) - } - private onboardOpen(): boolean { windowManager.openOnboarding() return true @@ -298,15 +284,9 @@ export default class AppHandler extends ExtensionHandler { case "pri(app.promptLogin)": return this.promptLogin() - case "pri(app.modalOpen.request)": - return this.openModal(request as ModalOpenRequest) - case "pri(app.sendFunds.open)": return this.openSendFunds(request as RequestTypes["pri(app.sendFunds.open)"]) - case "pri(app.modalOpen.subscribe)": - return genericSubscription<"pri(app.modalOpen.subscribe)">(id, port, this.#modalOpenRequest) - case "pri(app.analyticsCapture)": { const { eventName, options } = request as AnalyticsCaptureRequest talismanAnalytics.capture(eventName, options) diff --git a/packages/extension-core/src/domains/app/store.remoteConfig.ts b/packages/extension-core/src/domains/app/store.remoteConfig.ts index acfc9c173b..fa82cfe106 100644 --- a/packages/extension-core/src/domains/app/store.remoteConfig.ts +++ b/packages/extension-core/src/domains/app/store.remoteConfig.ts @@ -7,6 +7,12 @@ import { RemoteConfigStoreData } from "./types" export const DEFAULT_REMOTE_CONFIG: RemoteConfigStoreData = { featureFlags: {}, + rampConfig: { + rampBasePath: "", + rampApiBasePath: "", + rampApiKey: "", + }, + rampSupportedTokenIds: {}, buyTokens: { tokenIds: [], }, diff --git a/packages/extension-core/src/domains/app/types.ts b/packages/extension-core/src/domains/app/types.ts index 9dd41fde87..6cdf60d2a3 100644 --- a/packages/extension-core/src/domains/app/types.ts +++ b/packages/extension-core/src/domains/app/types.ts @@ -6,6 +6,8 @@ import { PostHogCaptureProperties } from "../analytics/types" export type RemoteConfigStoreData = { featureFlags: FeatureFlags + rampConfig: RampConfig + rampSupportedTokenIds: Record buyTokens: { tokenIds: TokenId[] } @@ -32,10 +34,6 @@ export interface RequestRoute { route: string } -export type ModalOpenRequestBuy = { - modalType: "buy" -} -export type ModalOpenRequest = ModalOpenRequestBuy export type SendFundsOpenRequest = { from?: Address tokenId?: TokenId @@ -111,12 +109,16 @@ export interface AppMessages { "pri(app.dashboardOpen)": [RequestRoute, boolean] "pri(app.onboardOpen)": [null, boolean] "pri(app.popupOpen)": [string | undefined, boolean] - "pri(app.modalOpen.request)": [ModalOpenRequest, boolean] "pri(app.sendFunds.open)": [SendFundsOpenRequest, boolean] - "pri(app.modalOpen.subscribe)": [null, boolean, ModalOpenRequest] "pri(app.promptLogin)": [null, boolean] "pri(app.analyticsCapture)": [AnalyticsCaptureRequest, boolean] "pri(app.phishing.addException)": [RequestAllowPhishingSite, boolean] "pri(app.resetWallet)": [null, boolean] "pri(app.requests)": [null, boolean, ValidRequests[]] } + +type RampConfig = { + rampBasePath: string + rampApiBasePath: string + rampApiKey: string +}