diff --git a/src/components/LabeledBadge/LabeledBadge.tsx b/src/components/LabeledBadge/LabeledBadge.tsx new file mode 100644 index 000000000..5efc4d18e --- /dev/null +++ b/src/components/LabeledBadge/LabeledBadge.tsx @@ -0,0 +1,37 @@ +import { Badge, BadgeProps, Flex, Icon, Text } from "@chakra-ui/react" +import { FC } from "react" +import { IconType } from "react-icons" + +interface LabeledBadgeProps extends BadgeProps { + label: string + icon?: IconType +} + +const LabeledBadge: FC = ({ + label, + icon, + children, + ...restProps +}) => { + return ( + + + {label}  + + {children} + + + ) +} + +export default LabeledBadge diff --git a/src/components/LabeledBadge/index.ts b/src/components/LabeledBadge/index.ts new file mode 100644 index 000000000..33c2615b9 --- /dev/null +++ b/src/components/LabeledBadge/index.ts @@ -0,0 +1 @@ +export { default as LabeledBadge } from "./LabeledBadge" diff --git a/src/components/Modal/TbtcMintingConfirmationModal/index.tsx b/src/components/Modal/TbtcMintingConfirmationModal/index.tsx deleted file mode 100644 index 5fb8e150a..000000000 --- a/src/components/Modal/TbtcMintingConfirmationModal/index.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Skeleton } from "@chakra-ui/react" -import { BitcoinUtxo } from "@keep-network/tbtc-v2.ts" -import { - BodyLg, - BodySm, - Button, - H5, - ModalBody, - ModalCloseButton, - ModalFooter, - ModalHeader, -} from "@threshold-network/components" -import { BigNumber } from "ethers" -import { FC, useEffect } from "react" -import { useThreshold } from "../../../contexts/ThresholdContext" -import { useRevealDepositTransaction } from "../../../hooks/tbtc" -import { useTbtcState } from "../../../hooks/useTbtcState" -import MintingTransactionDetails from "../../../pages/tBTC/Bridge/components/MintingTransactionDetails" -import { BaseModalProps } from "../../../types" -import { MintingStep } from "../../../types/tbtc" -import InfoBox from "../../InfoBox" -import { BridgeContractLink } from "../../tBTC" -import { InlineTokenBalance } from "../../TokenBalance" -import withBaseModal from "../withBaseModal" - -export interface TbtcMintingConfirmationModalProps extends BaseModalProps { - utxo: BitcoinUtxo -} - -const TbtcMintingConfirmationModal: FC = ({ - utxo, - closeModal, -}) => { - const { updateState, tBTCMintAmount, mintingFee, thresholdNetworkFee } = - useTbtcState() - const threshold = useThreshold() - - const onSuccessfulDepositReveal = () => { - updateState("mintingStep", MintingStep.MintingSuccess) - closeModal() - } - - const { sendTransaction: revealDeposit } = useRevealDepositTransaction( - onSuccessfulDepositReveal - ) - - const initiateMintTransaction = async () => { - await revealDeposit(utxo) - } - - const amount = BigNumber.from(utxo.value).toString() - - useEffect(() => { - const getEstimatedDepositFees = async () => { - const { treasuryFee, optimisticMintFee, amountToMint } = - await threshold.tbtc.getEstimatedDepositFees(amount) - - updateState("mintingFee", optimisticMintFee) - updateState("thresholdNetworkFee", treasuryFee) - updateState("tBTCMintAmount", amountToMint) - } - - getEstimatedDepositFees() - }, [amount, updateState, threshold]) - - return ( - <> - Initiate minting tBTC - - - -
You will initiate the minting of
- {/* - We can't use `InlineTokenBalance` inside the above `H5` because - the tooltip won't work correctly- will display at the top and - center of the whole `H5` component not only above token amount. - */} -
- - - -
- - Minting tBTC requires a single transaction on Ethereum network and - takes approximately 3 hours. - -
- - - View bridge contract on  - . - -
- - - - - - ) -} - -export default withBaseModal(TbtcMintingConfirmationModal) diff --git a/src/components/Modal/TransactionModal/TransactionFailed.tsx b/src/components/Modal/TransactionModal/TransactionFailed.tsx index 8f9ae64b4..3cc7c57cd 100644 --- a/src/components/Modal/TransactionModal/TransactionFailed.tsx +++ b/src/components/Modal/TransactionModal/TransactionFailed.tsx @@ -1,25 +1,25 @@ -import { FC } from "react" import { Alert, AlertIcon, AlertTitle, Box, + Button, + Icon, ModalBody, ModalFooter, useDisclosure, VStack, - Button, - Icon, } from "@chakra-ui/react" import { BodySm } from "@threshold-network/components" -import { ExternalHref } from "../../../enums" -import { BaseModalProps } from "../../../types" -import withBaseModal from "../withBaseModal" -import Link from "../../Link" +import { FC } from "react" import { - HiOutlinePlus as PlusIcon, HiOutlineMinus as MinusIcon, + HiOutlinePlus as PlusIcon, } from "react-icons/hi" +import { ExternalHref } from "../../../enums" +import { BaseModalProps } from "../../../types" +import Link from "../../Link" +import withBaseModal from "../withBaseModal" interface TransactionFailedProps extends BaseModalProps { transactionHash?: string diff --git a/src/components/Modal/TransactionModal/TransactionIsPending.tsx b/src/components/Modal/TransactionModal/TransactionIsPending.tsx index 975980a86..5250fa7bb 100644 --- a/src/components/Modal/TransactionModal/TransactionIsPending.tsx +++ b/src/components/Modal/TransactionModal/TransactionIsPending.tsx @@ -30,8 +30,8 @@ const TransactionIsPending: FC = ({ text="View" id={transactionHash} type={ExplorerDataType.TRANSACTION} - />{" "} - transaction on Etherscan + /> +  transaction on Etherscan diff --git a/src/components/Modal/TransactionModal/TransactionIsWaitingForConfirmation.tsx b/src/components/Modal/TransactionModal/TransactionIsWaitingForConfirmation.tsx index 9cbc30ab5..b8c283d03 100644 --- a/src/components/Modal/TransactionModal/TransactionIsWaitingForConfirmation.tsx +++ b/src/components/Modal/TransactionModal/TransactionIsWaitingForConfirmation.tsx @@ -5,6 +5,7 @@ import withBaseModal from "../withBaseModal" import { BaseModalProps } from "../../../types" import Spinner from "../../Spinner" +const MODAL_BODY_PB = "3.75rem" interface Props extends BaseModalProps { pendingText?: string } diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx new file mode 100644 index 000000000..767d9937c --- /dev/null +++ b/src/components/Toast/Toast.tsx @@ -0,0 +1,129 @@ +import { + AlertStatus, + Alert, + AlertIcon, + AlertTitle, + AlertDescription, + AlertProps as AlertPropsBase, + CloseButton, + VStack, + Stack, + Flex, + Icon, +} from "@chakra-ui/react" +import { FC, useEffect, useState } from "react" +import { setTimeout, clearTimeout } from "../../utils/setTimeout" +import { ToastCollapsibleDetails } from "./ToastCollapsibleDetails" +import { spacing } from "@chakra-ui/theme/foundations/spacing" +import { customBreakpoints } from "../../theme" +import { IconType } from "react-icons" + +export interface ToastInternalProps { + id: number + onUnmount?: () => void +} + +type PositionType = "left" | "center" | "right" +export interface ToastProps { + title: string + status: AlertStatus + description?: string + duration?: number + isDismissable?: boolean + orientation?: "horizontal" | "vertical" + position?: PositionType + icon?: IconType +} +type AlertProps = ToastProps & + Omit & + Omit + +const getPositioningProps = (position: PositionType) => { + if (position !== "center") { + return { + [position]: `max(0vw, calc((100vw - ${customBreakpoints["3xl"]}) / 2))`, + top: { base: "4.5rem", lg: 28 }, + mx: { base: 2, lg: 10 }, + } + } + return { + top: { base: "4.5rem", lg: 20 }, + left: "50%", + transform: "translateX(-50%)", + mx: { base: 0, lg: undefined }, + } +} + +const Toast: FC = ({ + title, + description, + duration = Infinity, + isDismissable = true, + children, + orientation = "horizontal", + position = "center", + icon, + ...restProps +}) => { + const [isMounted, setIsMounted] = useState(true) + + useEffect(() => { + const timeout = setTimeout(() => { + setIsMounted(false) + }, duration) + + return () => clearTimeout(timeout) + }, []) + + return isMounted ? ( + + + + + + {title ? {title} : null} + {description ? ( + + {description ?? title} + + ) : null} + + {isDismissable ? ( + setIsMounted(false)} + w={5} + h={5} + /> + ) : null} + + {children} + + + ) : ( + <> + ) +} + +const ToastNamespace = Object.assign(Toast, { + CollapsibleDetails: ToastCollapsibleDetails, +}) + +export default ToastNamespace diff --git a/src/components/Toast/ToastCollapsibleDetails.tsx b/src/components/Toast/ToastCollapsibleDetails.tsx new file mode 100644 index 000000000..89a866aa4 --- /dev/null +++ b/src/components/Toast/ToastCollapsibleDetails.tsx @@ -0,0 +1,76 @@ +import { + HStack, + FlexProps, + Box, + Button, + Icon, + useDisclosure, + Text, + Flex, +} from "@chakra-ui/react" +import { + HiOutlinePlus as PlusIcon, + HiOutlineMinus as MinusIcon, +} from "react-icons/hi" +import { ExternalHref } from "../../enums" +import Link from "../Link" + +type ToastCollapsibleDetailsProps = Omit & { + children: string +} + +export const ToastCollapsibleDetails = ({ + children, + ...restProps +}: ToastCollapsibleDetailsProps) => { + const { isOpen, onToggle } = useDisclosure() + return ( + + + {isOpen ? ( + <> + + {children} + + + + Get help on  + + Discord + + + + + ) : null} + + ) +} diff --git a/src/components/Toast/index.ts b/src/components/Toast/index.ts new file mode 100644 index 000000000..cc7296b44 --- /dev/null +++ b/src/components/Toast/index.ts @@ -0,0 +1 @@ +export { default as Toast } from "./Toast" diff --git a/src/components/TransactionDetails/index.tsx b/src/components/TransactionDetails/index.tsx index bdb9ee7ed..43dee8080 100644 --- a/src/components/TransactionDetails/index.tsx +++ b/src/components/TransactionDetails/index.tsx @@ -1,28 +1,29 @@ import { ComponentProps, FC } from "react" -import { - BodySm, - ListItem, - Skeleton, - useColorModeValue, -} from "@threshold-network/components" +import { Text, ListItem, Skeleton, useMultiStyleConfig } from "@chakra-ui/react" import { InlineTokenBalance } from "../TokenBalance" type TransactionDetailsItemProps = { label: string value?: string + variant?: "simple" | "bold" | "highlight" } export const TransactionDetailsItem: FC = ({ label, value, children, + ...restProps }) => { - const valueTextColor = useColorModeValue("gray.700", "gray.300") + const styles = useMultiStyleConfig("TransactionDetailsItem", restProps) return ( - - {label} - {value ? {value} : children} + + {label} + { + + {value ?? children} + + } ) } @@ -31,23 +32,21 @@ type TransactionDetailsAmountItemProps = Omit< ComponentProps, "tokenAmount" > & - Pick & { tokenAmount?: string } + Omit & { + tokenAmount?: string + } export const TransactionDetailsAmountItem: FC< TransactionDetailsAmountItemProps -> = ({ label, tokenAmount, ...restProps }) => { - const tokenBalanceTextColor = useColorModeValue("gray.700", "gray.300") - +> = ({ label, tokenAmount, variant, ...restProps }) => { return ( - + - - - + ) diff --git a/src/enums/modal.ts b/src/enums/modal.ts index ad535c3f4..589ad478b 100644 --- a/src/enums/modal.ts +++ b/src/enums/modal.ts @@ -3,7 +3,6 @@ export enum ModalType { TransactionIsPending = "TRANSACTION_IS_PENDING", TransactionIsWaitingForConfirmation = "TRANSACTION_IS_WAITING_FOR_CONFIRMATION", TransactionFailed = "TRANSACTION_FAILED", - TbtcMintingConfirmation = "TBTC_MINTING_CONFIRMATION", NewTBTCApp = "NEW_TBTC_APP", GenerateNewDepositAddress = "TBTC_GENERATE_NEW_DEPOSIT_ADDRESS", InitiateUnminting = "INITIATE_UNMINTING", diff --git a/src/pages/tBTC/Bridge/Minting/InitiateMinting.tsx b/src/pages/tBTC/Bridge/Minting/InitiateMinting.tsx index 5d8df2eb4..f5d1bea1d 100644 --- a/src/pages/tBTC/Bridge/Minting/InitiateMinting.tsx +++ b/src/pages/tBTC/Bridge/Minting/InitiateMinting.tsx @@ -1,43 +1,169 @@ import { BitcoinUtxo } from "@keep-network/tbtc-v2.ts" -import { BodyMd, Box, Button } from "@threshold-network/components" -import { FC } from "react" +import { + Badge, + BodyMd, + Box, + Button, + H5, + HStack, + VStack, +} from "@threshold-network/components" +import { BigNumber } from "ethers" +import { FC, useEffect, useState } from "react" +import { FaClock as ClockIcon } from "react-icons/fa" +import { LabeledBadge } from "../../../../components/LabeledBadge" +import { Toast } from "../../../../components/Toast" +import { InlineTokenBalance } from "../../../../components/TokenBalance" import withOnlyConnectedWallet from "../../../../components/withOnlyConnectedWallet" -import { ModalType } from "../../../../enums" +import { useThreshold } from "../../../../contexts/ThresholdContext" +import { useRevealDepositTransaction } from "../../../../hooks/tbtc" import { useModal } from "../../../../hooks/useModal" +import { useTbtcState } from "../../../../hooks/useTbtcState" import { MintingStep } from "../../../../types/tbtc" +import { getDurationByNumberOfConfirmations } from "../../../../utils/tBTC" import { BridgeProcessCardTitle } from "../components/BridgeProcessCardTitle" +import MintingTransactionDetails from "../components/MintingTransactionDetails" + +type RevealDepositErrorType = { + code: number + message: string +} const InitiateMintingComponent: FC<{ utxo: BitcoinUtxo onPreviousStepClick: (previousStep?: MintingStep) => void }> = ({ utxo, onPreviousStepClick }) => { - const { openModal } = useModal() + const { updateState } = useTbtcState() + const threshold = useThreshold() + const { closeModal } = useModal() + const [depositRevealErrorData, setDepositRevealErrorData] = + useState() + + const onSuccessfulDepositReveal = () => { + updateState("mintingStep", MintingStep.MintingSuccess) + // We don't have success modal for deposit reveal so we just closing the + // current TransactionIsPending modal. + closeModal() + } - const confirmDespotAndMint = async () => { - openModal(ModalType.TbtcMintingConfirmation, { utxo: utxo }) + const onFailedDepositReveal = (error: RevealDepositErrorType) => { + setDepositRevealErrorData(error) + closeModal() + } + + const { sendTransaction: revealDeposit } = useRevealDepositTransaction( + onSuccessfulDepositReveal, + onFailedDepositReveal + ) + + const depositedAmount = BigNumber.from(utxo.value).toString() + const confirmations = threshold.tbtc.minimumNumberOfConfirmationsNeeded( + utxo.value + ) + const durationInMinutes = getDurationByNumberOfConfirmations(confirmations) + // Round up the minutes to the nearest half-hour + const hours = (Math.round(durationInMinutes / 30) * 30) / 60 + + const hoursSuffix = hours === 1 ? "hour" : "hours" + const confirmationsSuffix = + confirmations === 1 ? "confirmation" : "confirmations" + + useEffect(() => { + const getEstimatedDepositFees = async () => { + const { treasuryFee, optimisticMintFee, amountToMint } = + await threshold.tbtc.getEstimatedDepositFees(depositedAmount) + updateState("mintingFee", optimisticMintFee) + updateState("thresholdNetworkFee", treasuryFee) + updateState("tBTCMintAmount", amountToMint) + } + + getEstimatedDepositFees() + }, [depositedAmount, updateState, threshold]) + + const initiateMintTransaction = async () => { + if (depositRevealErrorData) { + setDepositRevealErrorData(undefined) + console.log("Revealing deposit failed, trying again...") + } + await revealDeposit(utxo) } return ( + {depositRevealErrorData ? ( + + + {depositRevealErrorData?.message} + + + ) : null} Action on Ethereum} + onPreviousStepClick={onPreviousStepClick} /> - - This step requires you to sign a transaction in your Ethereum wallet. - - - Your tBTC will arrive in your wallet in around ~3 hours. - + + +
+ Deposit received +
+ + +  BTC + +
+ + + + +{confirmations} + +  {confirmationsSuffix} + + + {hours} {hoursSuffix} + + +
+
) diff --git a/src/pages/tBTC/Bridge/Minting/MintingFlowRouter.tsx b/src/pages/tBTC/Bridge/Minting/MintingFlowRouter.tsx index f84692503..a77a4be7b 100644 --- a/src/pages/tBTC/Bridge/Minting/MintingFlowRouter.tsx +++ b/src/pages/tBTC/Bridge/Minting/MintingFlowRouter.tsx @@ -77,7 +77,9 @@ const MintingFlowRouterBase = () => { return ( + onPreviousStepClick(MintingStep.ProvideData) + } /> ) } diff --git a/src/pages/tBTC/Bridge/components/MintingTransactionDetails.tsx b/src/pages/tBTC/Bridge/components/MintingTransactionDetails.tsx index 607db7b50..166b1815f 100644 --- a/src/pages/tBTC/Bridge/components/MintingTransactionDetails.tsx +++ b/src/pages/tBTC/Bridge/components/MintingTransactionDetails.tsx @@ -1,39 +1,62 @@ -import { List } from "@threshold-network/components" import { - TransactionDetailsAmountItem, - TransactionDetailsItem, -} from "../../../../components/TransactionDetails" + List, + ListProps, + ListItem, + StackDivider, +} from "@threshold-network/components" +import { BigNumber } from "ethers" +import { FC } from "react" +import { TransactionDetailsAmountItem } from "../../../../components/TransactionDetails" import { useTbtcState } from "../../../../hooks/useTbtcState" -import shortenAddress from "../../../../utils/shortenAddress" -const MintingTransactionDetails = () => { - const { tBTCMintAmount, mintingFee, thresholdNetworkFee, ethAddress } = - useTbtcState() +const MintingTransactionDetails: FC = (props) => { + const { tBTCMintAmount, mintingFee, thresholdNetworkFee } = useTbtcState() + + if (!tBTCMintAmount || !mintingFee || !thresholdNetworkFee) { + // It's already hidden behind a Skelton component + return null + } + + const totalFees = BigNumber.from(mintingFee).add( + BigNumber.from(thresholdNetworkFee) + ) + + const amountToReceive = BigNumber.from(tBTCMintAmount).sub(totalFees) return ( - + + + + - ) diff --git a/src/theme/Alert.ts b/src/theme/Alert.ts index c03273aba..bd716274e 100644 --- a/src/theme/Alert.ts +++ b/src/theme/Alert.ts @@ -45,7 +45,16 @@ const statusError = { }, } -const statusStyles = (props: AlertProps) => { +const solidStyles = { + container: { + bg: "linear-gradient(315deg, #0A1616 0%, #090909 100%)", + border: "1px solid", + borderColor: "whiteAlpha.250", + rounded: "lg", + }, +} + +const subtleStatusStyles = (props: AlertProps) => { const { status } = props const styleMap = new Map([ @@ -58,6 +67,27 @@ const statusStyles = (props: AlertProps) => { return styleMap.get(status!) || {} } +const solidStatusStyles = (props: AlertProps) => { + const { status } = props + + const styles = new Map([ + ["info", statusInfo], + ["warning", statusWarning], + ["success", statusSuccess], + ["error", statusError], + ]).get(status!) + + if (styles) { + styles.container = Object.assign(styles.container, solidStyles.container) + } + + if (styles && status === "info") { + styles.container.color = "brand.100" + } + + return styles || {} +} + export const Alert = { defaultProps, baseStyle: { @@ -80,6 +110,7 @@ export const Alert = { }, }, variants: { - subtle: statusStyles, + subtle: subtleStatusStyles, + solid: solidStatusStyles, }, } diff --git a/src/theme/TransactionDetailsItem.ts b/src/theme/TransactionDetailsItem.ts new file mode 100644 index 000000000..1f9c4da4c --- /dev/null +++ b/src/theme/TransactionDetailsItem.ts @@ -0,0 +1,41 @@ +import { PartsStyleObject, anatomy } from "@chakra-ui/theme-tools" + +const parts = anatomy("TransactionDetailsItem").parts( + "container", + "label", + "value" +) + +const baseStyle: PartsStyleObject = { + container: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + fontSize: "md", + lineHeight: "base", + }, +} + +const variants: PartsStyleObject = { + simple: { + label: { color: "hsl(0, 0%, 50%)" }, + value: { color: "white" }, + }, + bold: { + label: { color: "white" }, + value: { fontWeight: "bold" }, + }, + highlight: { + label: { color: "brand.100" }, + value: { fontWeight: "bold", color: "brand.100" }, + }, +} + +export const TransactionDetailsItem = { + defaultProps: { + variant: "simple", + }, + parts: parts.keys, + baseStyle, + variants, +} diff --git a/src/theme/index.ts b/src/theme/index.ts index e67c18fc5..4b619f8db 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -12,9 +12,15 @@ import { Tooltip } from "./Tooltip" import { fonts } from "./fonts" import { Button } from "./Button" import { Alert } from "./Alert" +import { TransactionDetailsItem } from "./TransactionDetailsItem" + +export const customBreakpoints = { + "3xl": "120rem", // 1920px +} export const customSizes = { "content-max-width": "89.25rem", // 1428px + "toast-width": "34.375rem", // 550px } const index = extendTheme({ @@ -41,6 +47,7 @@ const index = extendTheme({ }, }, sizes: customSizes, + breakpoints: customBreakpoints, fontSizes: { "4.5xl": "2.5rem", }, @@ -60,6 +67,7 @@ const index = extendTheme({ Tooltip, Button, Alert, + TransactionDetailsItem, }, config: { initialColorMode: "dark", diff --git a/src/types/modal.ts b/src/types/modal.ts index 6932ba1dc..82280f857 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -6,7 +6,6 @@ import { TransactionIsPending, TransactionIsWaitingForConfirmation, } from "../components/Modal/TransactionModal" -import TbtcMintingConfirmationModal from "../components/Modal/TbtcMintingConfirmationModal" import { GenerateNewDepositAddress, InitiateUnminting, @@ -19,7 +18,6 @@ export const MODAL_TYPES: Record = { [ModalType.TransactionIsWaitingForConfirmation]: TransactionIsWaitingForConfirmation, [ModalType.TransactionFailed]: TransactionFailed, - [ModalType.TbtcMintingConfirmation]: TbtcMintingConfirmationModal, [ModalType.NewTBTCApp]: NewTBTCApp, [ModalType.GenerateNewDepositAddress]: GenerateNewDepositAddress, [ModalType.InitiateUnminting]: InitiateUnminting, diff --git a/src/utils/setTimeout.ts b/src/utils/setTimeout.ts new file mode 100644 index 000000000..9160741d3 --- /dev/null +++ b/src/utils/setTimeout.ts @@ -0,0 +1,23 @@ +export function setTimeout(callback: VoidFunction, duration: number) { + let requestId: number + let start: number | null = null + + const loop = (timestamp: number) => { + if (!start) { + start = timestamp + } + + if (timestamp - start < duration) { + requestId = requestAnimationFrame(loop) + } else { + callback() + } + } + + requestId = requestAnimationFrame(loop) + return requestId +} + +export function clearTimeout(requestId: number) { + cancelAnimationFrame(requestId) +}