diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c61d05..1541ba8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [#161](https://github.com/animareflection/ui/pull/161) [`f750579`](https://github.com/animareflection/ui/commit/f75057983f63a85daa7c74fe521298e3b2582e7e) Thanks [@Twonarly1](https://github.com/Twonarly1)! - Update `Accordion` component - [#171](https://github.com/animareflection/ui/pull/171) [`072e6d0`](https://github.com/animareflection/ui/commit/072e6d0904cdbb6eef0740fbaca43d3fdb0a9738) Thanks [@coopbri](https://github.com/coopbri)! - Change recipes to use Ark v1 slots + - Change `Carousel` `slides` prop to `items` - [#158](https://github.com/animareflection/ui/pull/158) [`165d9c5`](https://github.com/animareflection/ui/commit/165d9c5fd5c938176c01a427f9493e82970a7fa8) Thanks [@hobbescodes](https://github.com/hobbescodes)! - Add additional variants for components that leverage `defaultVariants` diff --git a/bun.lockb b/bun.lockb index df0fedaa..c34a37a6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 262d4d9b..88b11d79 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@storybook/testing-library": "^0.2.2", "@storybook/theming": "^7.5.3", "@storybook/types": "^7.5.3", + "@tanstack/react-query": "^5.8.1", "@testing-library/dom": "^9.3.3", "@testing-library/react": "^14.1.0", "@testing-library/user-event": "^14.5.1", @@ -116,6 +117,8 @@ "tsup": "^7.2.0", "typescript": "^5.2.2", "usehooks-ts": "^2.9.1", + "viem": "2.0.0-beta.1", + "wagmi": "beta", "wait-on": "^7.1.0", "webpack": "^5.89.0" } diff --git a/public/svg/connectors/brave.svg b/public/svg/connectors/brave.svg new file mode 100644 index 00000000..f80dbc3e --- /dev/null +++ b/public/svg/connectors/brave.svg @@ -0,0 +1,33 @@ + + + + build-icons/Stable Copy 3 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/svg/connectors/ethereum.svg b/public/svg/connectors/ethereum.svg new file mode 100644 index 00000000..668f2cc0 --- /dev/null +++ b/public/svg/connectors/ethereum.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/public/svg/connectors/metamask.svg b/public/svg/connectors/metamask.svg new file mode 100644 index 00000000..faee2007 --- /dev/null +++ b/public/svg/connectors/metamask.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svg/connectors/phantom.svg b/public/svg/connectors/phantom.svg new file mode 100644 index 00000000..d763c498 --- /dev/null +++ b/public/svg/connectors/phantom.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/components/core/Button/Button.recipe.ts b/src/components/core/Button/Button.recipe.ts index cbec2e7f..8a1ab7dd 100644 --- a/src/components/core/Button/Button.recipe.ts +++ b/src/components/core/Button/Button.recipe.ts @@ -4,6 +4,7 @@ export const buttonRecipe = defineRecipe({ className: "button", description: "The styles for the Button component", base: { + borderWidth: "1px", cursor: "pointer", fontWeight: "bold", borderRadius: "md", @@ -27,6 +28,7 @@ export const buttonRecipe = defineRecipe({ variants: { variant: { primary: { + borderColor: "accent.default", color: "accent.fg", bgColor: "accent.default", _hover: { @@ -41,7 +43,6 @@ export const buttonRecipe = defineRecipe({ }, }, secondary: { - borderWidth: "1px", borderColor: "border.emphasized", bgColor: "bg.default", color: "fg.emphasized", @@ -60,6 +61,7 @@ export const buttonRecipe = defineRecipe({ }, }, ghost: { + borderColor: "transparent", bgColor: "transparent", color: "fg.emphasized", _hover: { @@ -76,6 +78,7 @@ export const buttonRecipe = defineRecipe({ }, }, round: { + borderColor: "accent.default", borderRadius: "full !important", color: "accent.fg", bgColor: "accent.default", diff --git a/src/components/core/Menu/Menu.stories.tsx b/src/components/core/Menu/Menu.stories.tsx index 2fe922b3..71524436 100644 --- a/src/components/core/Menu/Menu.stories.tsx +++ b/src/components/core/Menu/Menu.stories.tsx @@ -80,30 +80,18 @@ const WITH_CONTEXT_GROUPS: MenuItemGroupRecord[] = [ ]; export const Default: Story = { - render: () => ( - - ), + render: () => Open Menu} groups={GROUPS} />, }; export const Small: Story = { render: () => ( - + Open Menu} groups={GROUPS} size="sm" /> ), }; export const Large: Story = { render: () => ( - + Open Menu} groups={GROUPS} size="lg" /> ), }; @@ -111,8 +99,7 @@ export const WithContext: Story = { render: () => ( Open Menu} groups={WITH_CONTEXT_GROUPS} > {({ close: onClose }) => ( diff --git a/src/components/core/Menu/Menu.tsx b/src/components/core/Menu/Menu.tsx index 7a83b57b..0fe0e54a 100644 --- a/src/components/core/Menu/Menu.tsx +++ b/src/components/core/Menu/Menu.tsx @@ -9,15 +9,11 @@ import { PrimitiveMenuItemGroupLabel, PrimitiveMenuTriggerItem, } from "components/primitives"; -import { cx } from "generated/panda/css"; -import { button, menu } from "generated/panda/recipes"; +import { menu } from "generated/panda/recipes"; import { useIsClient } from "lib/hooks"; import type { PrimitiveMenuProps } from "components/primitives"; -import type { - ButtonVariantProps, - MenuVariantProps, -} from "generated/panda/recipes"; +import type { MenuVariantProps } from "generated/panda/recipes"; import type { ReactElement, ReactNode } from "react"; export interface MenuItemRecord { @@ -36,7 +32,6 @@ export interface MenuItemGroupRecord { export interface Props extends PrimitiveMenuProps, MenuVariantProps { trigger?: ReactNode; triggerItem?: ReactNode; - triggerVariant?: ButtonVariantProps["variant"]; groups?: MenuItemGroupRecord[]; } @@ -47,7 +42,6 @@ const Menu = ({ children, trigger, triggerItem, - triggerVariant, groups, size, ...rest @@ -63,14 +57,7 @@ const Menu = ({ {(ctx) => ( <> {trigger && ( - - {trigger} - + {trigger} )} {triggerItem && ( diff --git a/src/components/core/Modal/Modal.stories.tsx b/src/components/core/Modal/Modal.stories.tsx index 3eb75d7f..1357739e 100644 --- a/src/components/core/Modal/Modal.stories.tsx +++ b/src/components/core/Modal/Modal.stories.tsx @@ -1,5 +1,5 @@ import { modalState } from "./Modal.spec"; -import { Modal, Text } from "components/core"; +import { Button, Modal, Text } from "components/core"; import type { Meta, StoryObj } from "@storybook/react"; @@ -8,7 +8,7 @@ type Story = StoryObj; export const Default: Story = { render: () => ( Open Modal} title="Modal Title" description="Modal Description" > @@ -24,7 +24,7 @@ export const Default: Story = { export const WithContext: Story = { render: () => ( Open Modal} title="Modal Title" description="Modal Description" > diff --git a/src/components/core/Modal/Modal.tsx b/src/components/core/Modal/Modal.tsx index ccfd9c5e..12b7f4ba 100644 --- a/src/components/core/Modal/Modal.tsx +++ b/src/components/core/Modal/Modal.tsx @@ -47,7 +47,7 @@ const Modal = ({ {(ctx) => ( <> {trigger && ( - + {trigger} )} diff --git a/src/components/core/Spinner/Spinner.recipe.ts b/src/components/core/Spinner/Spinner.recipe.ts index ee932e85..e5eb3b91 100644 --- a/src/components/core/Spinner/Spinner.recipe.ts +++ b/src/components/core/Spinner/Spinner.recipe.ts @@ -12,6 +12,10 @@ export const spinnerRecipe = defineRecipe({ }, variants: { size: { + xs: { + h: 3, + w: 3, + }, sm: { h: 6, w: 6, diff --git a/src/components/providers/BlockchainProvider/BlockchainProvider.tsx b/src/components/providers/BlockchainProvider/BlockchainProvider.tsx new file mode 100644 index 00000000..96cc8441 --- /dev/null +++ b/src/components/providers/BlockchainProvider/BlockchainProvider.tsx @@ -0,0 +1,22 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WagmiProvider } from "wagmi"; + +import { WagmiConfig } from "lib/web3"; + +import type { ReactNode } from "react"; + +const queryClient = new QueryClient(); + +interface Props { + children?: ReactNode; +} + +const BlockchainProvider = ({ children }: Props) => { + return ( + + {children} + + ); +}; + +export default BlockchainProvider; diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts new file mode 100644 index 00000000..7af8eb0e --- /dev/null +++ b/src/components/providers/index.ts @@ -0,0 +1 @@ +export { default as BlockchainProvider } from "./BlockchainProvider/BlockchainProvider"; diff --git a/src/components/web3/SwitchNetwork/SwitchNetwork.tsx b/src/components/web3/SwitchNetwork/SwitchNetwork.tsx new file mode 100644 index 00000000..26c08537 --- /dev/null +++ b/src/components/web3/SwitchNetwork/SwitchNetwork.tsx @@ -0,0 +1,103 @@ +import { default as toast } from "react-hot-toast"; +import { FiChevronDown } from "react-icons/fi"; +import { useAccount, useChainId, useSwitchChain } from "wagmi"; + +import Button from "components/core/Button/Button"; +import Icon from "components/core/Icon/Icon"; +import Image from "components/core/Image/Image"; +import Menu from "components/core/Menu/Menu"; +import Toast from "components/core/Toast/Toast"; +import { Flex } from "generated/panda/jsx"; +import { NETWORKS } from "lib/web3"; + +import type { Props as MenuProps } from "components/core/Menu/Menu"; + +export interface Props extends MenuProps { + iconOnly?: boolean; +} + +/** + * Switch network menu. + */ +const SwitchNetwork = ({ iconOnly = false, ...rest }: Props) => { + const { isConnected } = useAccount(); + const chainId = useChainId(); + const { chains, switchChain } = useSwitchChain({ + mutation: { + onError: (error) => { + toast.error( + , + ); + }, + onSuccess: () => { + toast.success( + , + ); + }, + }, + }); + + const currentNetworkIcon = NETWORKS.find((network) => network.id === chainId) + ?.icon, + currentNetworkName = NETWORKS.find((network) => network.id === chainId) + ?.name; + + if (!isConnected) return null; + + return ( + + {currentNetworkName} + {!iconOnly && currentNetworkName} + + + + + } + groups={[ + { + id: "networks", + items: chains.map(({ id, name }) => ({ + id: id.toString(), + child: ( + switchChain({ chainId: id })} + aria-label={`Switch to ${name}`} + > + network.id === id)?.icon} + alt={`${name} icon`} + w={5} + h={5} + style={{ objectFit: "contain" }} + /> + {NETWORKS.find((network) => network.id === id)?.name} + + ), + })), + }, + ]} + {...rest} + /> + ); +}; + +export default SwitchNetwork; diff --git a/src/components/web3/WalletConnection/ConnectWallet/ConnectWallet.tsx b/src/components/web3/WalletConnection/ConnectWallet/ConnectWallet.tsx new file mode 100644 index 00000000..da30a911 --- /dev/null +++ b/src/components/web3/WalletConnection/ConnectWallet/ConnectWallet.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { default as toast } from "react-hot-toast"; +import { useConnect } from "wagmi"; + +import Button from "components/core/Button/Button"; +import Image from "components/core/Image/Image"; +import Modal from "components/core/Modal/Modal"; +import Spinner from "components/core/Spinner/Spinner"; +import Text from "components/core/Text/Text"; +import Toast from "components/core/Toast/Toast"; +import { Flex, panda } from "generated/panda/jsx"; +import { useDisclosure } from "lib/hooks"; +import { getConnectorImage } from "lib/utils/web3"; + +import type { Props as ModalProps } from "components/core/Modal/Modal"; +import type { Connector } from "wagmi"; + +export interface Props extends ModalProps {} + +/** + * Connect wallet modal. + */ +const ConnectWallet = ({ ...props }: Props) => { + const [currentConnector, setCurrentConnector] = useState( + null, + ); + + const { isOpen, onClose, onToggle } = useDisclosure(); + + const { connectors, connect, status } = useConnect({ + mutation: { + onError: (error) => { + setCurrentConnector(null); + onClose(); + toast.error( + , + ); + }, + onMutate: ({ connector }) => setCurrentConnector(connector as Connector), + onSuccess: () => { + setCurrentConnector(null); + onClose(); + toast.success( + , + ); + }, + }, + }); + + return ( + Connect} + title="Connect" + description="Select option to connect your wallet." + open={isOpen} + onOpenChange={onToggle} + {...props} + > + + {connectors.map((connector) => ( + + ))} + {/* TODO: use custom Link component when available */} + + Learn more about Ethereum wallets + + + + ); +}; + +export default ConnectWallet; diff --git a/src/components/web3/WalletConnection/DisconnectWallet/DisconnectWallet.tsx b/src/components/web3/WalletConnection/DisconnectWallet/DisconnectWallet.tsx new file mode 100644 index 00000000..626e6093 --- /dev/null +++ b/src/components/web3/WalletConnection/DisconnectWallet/DisconnectWallet.tsx @@ -0,0 +1,185 @@ +import { useEffect } from "react"; +import { default as toast } from "react-hot-toast"; +import { FiClipboard, FiLogOut } from "react-icons/fi"; +import { normalize } from "viem/ens"; +import { + useAccount, + useChainId, + useDisconnect, + useEnsAvatar, + useEnsName, +} from "wagmi"; + +import Badge from "components/core/Badge/Badge"; +import Button from "components/core/Button/Button"; +import Icon from "components/core/Icon/Icon"; +import Image from "components/core/Image/Image"; +import Modal from "components/core/Modal/Modal"; +import Text from "components/core/Text/Text"; +import Toast from "components/core/Toast/Toast"; +import { Circle, Flex, panda } from "generated/panda/jsx"; +import { useCopyToClipboard, useDisclosure } from "lib/hooks"; +// TODO: add `useBalance` hook to `lib/hooks` when it's ready for bundle +import { useBalance } from "lib/hooks/web3"; +import { truncateString } from "lib/utils"; +import { NETWORKS } from "lib/web3"; + +import type { Props as ModalProps } from "components/core/Modal/Modal"; + +export interface Props extends ModalProps {} + +/** + * Disconnect wallet modal. + */ +const DisconnectWallet = ({ ...props }: Props) => { + const [value, copy] = useCopyToClipboard(); + + const { isOpen, onClose, onToggle } = useDisclosure(); + + const { address, chain, connector } = useAccount(), + { data: ensName } = useEnsName({ + address, + }), + { data: ensAvatar } = useEnsAvatar({ + name: ensName ? normalize(ensName) : undefined, + // TODO: add gateway URLs to resolve ipfs and/or arweave assets + }), + { data: balance } = useBalance({ + address, + precision: 3, + }), + chainId = useChainId(); + + const currentNetworkIcon = NETWORKS.find((network) => network.id === chainId) + ?.icon; + + const { disconnect } = useDisconnect({ + mutation: { + onError: (error) => { + onClose(); + toast.error( + , + ); + }, + onSuccess: () => { + onClose(); + toast.success( + , + ); + }, + }, + }); + + const MODAL_BUTTONS = [ + { + label: "Copy Address", + icon: , + onClick: () => copy(address!), + }, + { + label: "Disconnect", + icon: , + onClick: () => disconnect(), + ariaLabel: "Disconnect Wallet", + }, + ]; + + useEffect(() => { + if (!value) return; + + toast.success( + , + ); + }, [value]); + + return ( + + {ensAvatar + {ensName ?? truncateString(address!)} + + } + open={isOpen} + onOpenChange={onToggle} + {...props} + > + + {ensAvatar + + {ensName ?? truncateString(address!)} + + {balance && ( + + + {balance.formatted} + {balance.symbol} + + + )} + + + + {connector?.name} connected + + + + + {MODAL_BUTTONS.map(({ label, icon, onClick, ariaLabel }) => ( + + ))} + + + + ); +}; + +export default DisconnectWallet; diff --git a/src/components/web3/WalletConnection/WalletConnection.spec.tsx b/src/components/web3/WalletConnection/WalletConnection.spec.tsx new file mode 100644 index 00000000..91c1c333 --- /dev/null +++ b/src/components/web3/WalletConnection/WalletConnection.spec.tsx @@ -0,0 +1,103 @@ +import { expect } from "@storybook/jest"; +import { screen, within, userEvent } from "@storybook/testing-library"; + +import { sleep } from "lib/utils"; + +import type { ReactRenderer } from "@storybook/react"; +import type { PlayFunctionContext, Renderer } from "@storybook/types"; + +/** + * Wallet connection testing suite. + */ +export const walletConnectionState = async < + R extends Renderer = ReactRenderer, +>({ + canvasElement, + step, +}: PlayFunctionContext) => { + const canvas = within(canvasElement as HTMLElement); + + await step("It should open connect wallet modal", async () => { + const connectButton = await canvas.findByRole("button", { + name: "Connect", + }); + + await userEvent.click(connectButton); + + await sleep(1000); + + const mockConnector = screen.getByLabelText("Connect with Mock Connector"); + + await expect(mockConnector).toBeInTheDocument(); + }); + + await step("It should connect wallet", async () => { + const mockConnector = screen.getByLabelText("Connect with Mock Connector"); + + await userEvent.click(mockConnector); + + await sleep(1000); + + const disconnectButton = canvas.getByLabelText("Open Disconnect Modal"); + const networkMenu = canvas.getByLabelText("Open Network Menu"); + + await expect(disconnectButton).toBeInTheDocument(); + await expect(networkMenu).toBeInTheDocument(); + }); + + await step("It should open switch network menu", async () => { + const networkMenu = canvas.getByLabelText("Open Network Menu"); + + await userEvent.click(networkMenu); + + await sleep(1000); + + const arbitrumNetworkItem = screen.getByText("Arbitrum"); + + await expect(arbitrumNetworkItem).toBeInTheDocument(); + }); + + await step("It should switch network", async () => { + const arbitrumNetworkItem = screen.getByText("Arbitrum"); + + await userEvent.click(arbitrumNetworkItem); + + await sleep(1000); + + const currentChainImage = canvas.getByLabelText("Arbitrum One icon"); + + await expect(currentChainImage).toBeInTheDocument(); + }); + + await step("It should open disconnect wallet modal", async () => { + const disconnectModalTrigger = canvas.getByLabelText( + "Open Disconnect Modal", + ); + + await userEvent.click(disconnectModalTrigger); + + await sleep(1000); + + const disconnectButton = screen.getByLabelText("Disconnect Wallet"); + + await expect(disconnectButton).toBeInTheDocument(); + }); + + await step("It should disconnect wallet", async () => { + const disconnectButton = screen.getByLabelText("Disconnect Wallet"); + + await userEvent.click(disconnectButton); + + await sleep(2000); + + await expect(disconnectButton).not.toBeVisible(); + + const connectButton = await canvas.findByRole("button", { + name: "Connect", + }); + + await expect(connectButton).toBeVisible(); + }); +}; + +export default walletConnectionState; diff --git a/src/components/web3/WalletConnection/WalletConnection.stories.tsx b/src/components/web3/WalletConnection/WalletConnection.stories.tsx new file mode 100644 index 00000000..6a76dd8b --- /dev/null +++ b/src/components/web3/WalletConnection/WalletConnection.stories.tsx @@ -0,0 +1,69 @@ +import { useAccount } from "wagmi"; + +import { walletConnectionState } from "./WalletConnection.spec"; +import { Toaster } from "components/core"; +import { BlockchainProvider } from "components/providers"; +import { + ConnectWallet, + DisconnectWallet, + SwitchNetwork, +} from "components/web3"; +import { Flex } from "generated/panda/jsx"; + +import type { Meta, StoryObj } from "@storybook/react"; + +type Story = StoryObj; + +const Connection = ({ + showNetworkMenu = false, +}: { + showNetworkMenu?: boolean; +}) => { + const { isConnected } = useAccount(); + + if (isConnected) + return ( + + + {showNetworkMenu && } + + ); + + return ; +}; + +export const Default: Story = { + render: () => , +}; + +export const WithNetworkMenu: Story = { + render: () => , +}; + +// !NB: Note that the test flow for this story will only work if you start in the disconnected state. +export const WalletConnectionState: Story = { + ...WithNetworkMenu, + play: walletConnectionState, + name: "[TEST] Wallet Connection State", + tags: ["test"], +}; + +const meta = { + title: "Components/Web3/WalletConnection", + component: Connection, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + + ), + ], + // TODO: remove when portal issue / ref bug is fixed + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; diff --git a/src/components/web3/index.ts b/src/components/web3/index.ts new file mode 100644 index 00000000..13a546a9 --- /dev/null +++ b/src/components/web3/index.ts @@ -0,0 +1,7 @@ +export { default as SwitchNetwork } from "./SwitchNetwork/SwitchNetwork"; +export { default as ConnectWallet } from "./WalletConnection/ConnectWallet/ConnectWallet"; +export { default as DisconnectWallet } from "./WalletConnection/DisconnectWallet/DisconnectWallet"; + +export type { Props as SwitchNetworkProps } from "./SwitchNetwork/SwitchNetwork"; +export type { Props as ConnectWalletProps } from "./WalletConnection/ConnectWallet/ConnectWallet"; +export type { Props as DisconnectWalletProps } from "./WalletConnection/DisconnectWallet/DisconnectWallet"; diff --git a/src/lib/hooks/web3/index.ts b/src/lib/hooks/web3/index.ts new file mode 100644 index 00000000..89ddbb5f --- /dev/null +++ b/src/lib/hooks/web3/index.ts @@ -0,0 +1,6 @@ +export { default as useBalance } from "./useBalance/useBalance"; + +export type { + Options as BalanceOptions, + BalanceData, +} from "./useBalance/useBalance"; diff --git a/src/lib/hooks/web3/useBalance/useBalance.stories.tsx b/src/lib/hooks/web3/useBalance/useBalance.stories.tsx new file mode 100644 index 00000000..f749c003 --- /dev/null +++ b/src/lib/hooks/web3/useBalance/useBalance.stories.tsx @@ -0,0 +1,65 @@ +import { Text } from "components/core"; +import { BlockchainProvider } from "components/providers"; +import { Flex } from "generated/panda/jsx"; +import { useBalance } from "lib/hooks/web3"; + +import type { Meta, StoryObj } from "@storybook/react"; +import type { ComponentType } from "react"; + +type Story = StoryObj; + +const NativeCurrencyExample = () => { + const { data: balance } = useBalance({ + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + precision: 4, + }); + + if (!balance) return null; + + return ( + {`Vitalik's ${balance.symbol} balance: ${balance.formatted} ${balance.symbol}`} + ); +}; + +const ERC20Example = () => { + const { data: balance } = useBalance({ + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + token: "0x514910771AF9Ca656af840dff83E8264EcF986CA", // LINK token + precision: 4, + }); + + if (!balance) return null; + + return ( + {`Vitalik's ${balance.symbol} balance: ${balance.formatted} ${balance.symbol}`} + ); +}; + +export const Balances: Story = { + render: () => ( + + + + + ), +}; + +const meta = { + title: "Hooks/Web3/useBalance", + tags: ["autodocs"], + // NB: type coercion here to allow `useBalance` Storybook metadata to render (e.g. JSDoc, hook parameters) + component: useBalance as unknown as ComponentType, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; diff --git a/src/lib/hooks/web3/useBalance/useBalance.tsx b/src/lib/hooks/web3/useBalance/useBalance.tsx new file mode 100644 index 00000000..6253d61e --- /dev/null +++ b/src/lib/hooks/web3/useBalance/useBalance.tsx @@ -0,0 +1,85 @@ +import { erc20Abi } from "viem"; +import { useBalance as useWagmiBalance, useReadContracts } from "wagmi"; + +import { formatUnits } from "lib/utils/web3"; + +import type { UseBalanceParameters } from "wagmi"; +import type { GetBalanceData } from "wagmi/query"; + +export interface Options extends UseBalanceParameters { + token?: `0x${string}`; + precision?: number; +} + +export interface BalanceData extends GetBalanceData { + // NB: `formatted` is deprecated in `GetBalanceData` and will be removed in a future release of wagmi, this overrides that action + formatted: string; +} + +/** + * Hook used to determine a given address' ERC20 or Native Currency balance. + */ +const useBalance = ({ address, token, precision, ...rest }: Options) => { + const erc20Contract = { + address: token, + abi: erc20Abi, + ...rest, + } as const; + + const nativeCurrencyBalance = useWagmiBalance({ + address, + ...rest, + query: { + select: (data): BalanceData => { + return { + ...data, + formatted: formatUnits({ + value: data.value, + decimals: data.decimals, + precision, + }), + }; + }, + }, + }), + erc20TokenBalance = useReadContracts({ + contracts: [ + { + ...erc20Contract, + functionName: "balanceOf", + args: [address!], + }, + { + ...erc20Contract, + functionName: "decimals", + }, + { + ...erc20Contract, + functionName: "symbol", + }, + ], + query: { + select: (data): BalanceData => { + const [balance, decimals, symbol] = data; + + return { + decimals: decimals.result ?? 18, + formatted: formatUnits({ + value: balance.result ?? 0n, + decimals: decimals.result, + precision, + }), + // TODO: determine appropriate fallback for `symbol` + symbol: symbol.result ?? "N/A", + value: balance.result ?? 0n, + }; + }, + }, + }); + + const balance = token ? erc20TokenBalance : nativeCurrencyBalance; + + return { ...balance }; +}; + +export default useBalance; diff --git a/src/lib/panda/animations.ts b/src/lib/panda/animations.ts index 9a8549f8..103a3457 100644 --- a/src/lib/panda/animations.ts +++ b/src/lib/panda/animations.ts @@ -46,6 +46,7 @@ const animations = defineTokens.animations({ "skeleton-light": { value: "skeleton-loading-light 1s infinite linear alternate", }, + pulse: { value: "pulse 2s infinite" }, }); export default animations; diff --git a/src/lib/panda/keyframes.ts b/src/lib/panda/keyframes.ts index ca40e84b..5e173da4 100644 --- a/src/lib/panda/keyframes.ts +++ b/src/lib/panda/keyframes.ts @@ -53,6 +53,20 @@ const keyframes = defineKeyframes({ "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" }, }, + pulse: { + "0%": { + transform: "scale(0.95)", + boxShadow: "0 0 0 0 rgba(0, 0, 0, 0.7)", + }, + "70%": { + transform: "scale(1)", + boxShadow: "0 0 0 0 rgba(0, 0, 0, 0)", + }, + "100%": { + transform: "scale(0.95)", + boxShadow: "0 0 0 0 rgba(0, 0, 0, 0.7)", + }, + }, "skeleton-loading-dark": { "0%": { background: "#555555" }, "100%": { backgroundColor: "#333333" }, diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index c2706167..89fabae5 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,2 +1,3 @@ export { default as emToPx } from "./emToPx"; export { default as sleep } from "./sleep"; +export { default as truncateString } from "./truncateString"; diff --git a/src/lib/utils/truncateString.ts b/src/lib/utils/truncateString.ts new file mode 100644 index 00000000..81e9e373 --- /dev/null +++ b/src/lib/utils/truncateString.ts @@ -0,0 +1,23 @@ +/** + * Truncate string to first and last four digits (with dots inbetween). + * + * @param {string} str string to truncate + * @param {number} terminalCharacters number of terminal (left/right) characters to maintain; homogeneous between left and right + * + * @example + * // returns "abcd...wxyz" + * truncateString("abcdefghijklmnopqrstuvwxyz") + * + * @example + * // returns "abcdefgh...stuvwxyz" + * truncateString("abcdefghijklmnopqrstuvwxyz", 8) + * + * @returns truncated string + */ +const truncateString = (str: string, terminalCharacters = 4) => + (str = + str.substring(0, terminalCharacters) + + "..." + + str.substring(str.length - terminalCharacters)); + +export default truncateString; diff --git a/src/lib/utils/web3/formatUnits.ts b/src/lib/utils/web3/formatUnits.ts new file mode 100644 index 00000000..8c48ddbb --- /dev/null +++ b/src/lib/utils/web3/formatUnits.ts @@ -0,0 +1,14 @@ +import { formatUnits as viemFormatUnits } from "viem"; + +interface Options { + value: bigint; + decimals?: number; + precision?: number; +} + +const formatUnits = ({ value, decimals = 18, precision }: Options) => + precision + ? Number(viemFormatUnits(value, decimals)).toFixed(precision) + : viemFormatUnits(value, decimals); + +export default formatUnits; diff --git a/src/lib/utils/web3/getConnectorImage.ts b/src/lib/utils/web3/getConnectorImage.ts new file mode 100644 index 00000000..d3716ea1 --- /dev/null +++ b/src/lib/utils/web3/getConnectorImage.ts @@ -0,0 +1,15 @@ +// TODO: update switch statement or refactor as needed +const getConnectorImage = (connectorName: string) => { + switch (connectorName) { + case "Brave Wallet": + return "/svg/connectors/brave.svg"; + case "MetaMask": + return "/svg/connectors/metamask.svg"; + case "Phantom": + return "/svg/connectors/phantom.svg"; + default: + return "/svg/connectors/ethereum.svg"; + } +}; + +export default getConnectorImage; diff --git a/src/lib/utils/web3/index.ts b/src/lib/utils/web3/index.ts new file mode 100644 index 00000000..a09d625e --- /dev/null +++ b/src/lib/utils/web3/index.ts @@ -0,0 +1,2 @@ +export { default as formatUnits } from "./formatUnits"; +export { default as getConnectorImage } from "./getConnectorImage"; diff --git a/src/lib/web3/config.ts b/src/lib/web3/config.ts new file mode 100644 index 00000000..8178fd95 --- /dev/null +++ b/src/lib/web3/config.ts @@ -0,0 +1,35 @@ +import { createConfig, http } from "wagmi"; +import { arbitrum, sepolia, mainnet, optimism, polygon } from "wagmi/chains"; +import { injected, mock } from "wagmi/connectors"; + +// !NB: using public RPCs (transports) is not recommended for production use. Depending on public storybook traffic, we may want to consider using our own RPC endpoints. +const config = createConfig({ + chains: [mainnet, arbitrum, optimism, polygon, sepolia], + connectors: [ + injected({ target: "metaMask" }), + mock({ + // !NB: These accounts are for testing purposes only. Derived from spinning up a local anvil and/or hardhat node. + accounts: [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65", + "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + "0x976EA74026E726554dB657fA54763abd0C3a0aa9", + "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955", + "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", + "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", + ], + }), + ], + transports: { + [mainnet.id]: http(), + [arbitrum.id]: http(), + [optimism.id]: http(), + [polygon.id]: http(), + [sepolia.id]: http(), + }, +}); + +export default config; diff --git a/src/lib/web3/index.ts b/src/lib/web3/index.ts new file mode 100644 index 00000000..9f87151b --- /dev/null +++ b/src/lib/web3/index.ts @@ -0,0 +1,2 @@ +export { default as WagmiConfig } from "./config"; +export { NETWORKS } from "./networks"; diff --git a/src/lib/web3/networks.ts b/src/lib/web3/networks.ts new file mode 100644 index 00000000..cad35160 --- /dev/null +++ b/src/lib/web3/networks.ts @@ -0,0 +1,33 @@ +import { arbitrum, sepolia, mainnet, optimism, polygon } from "wagmi/chains"; + +import type { Chain } from "wagmi/chains"; + +interface Network extends Chain { + /** Network icon. */ + icon: string; +} + +export const NETWORKS: Network[] = [ + { + ...mainnet, + icon: "https://cryptologos.cc/logos/ethereum-eth-logo.svg", + }, + { + ...polygon, + icon: "https://cryptologos.cc/logos/polygon-matic-logo.svg", + }, + { + ...arbitrum, + name: "Arbitrum", + icon: "https://cryptologos.cc/logos/arbitrum-arb-logo.svg", + }, + { + ...optimism, + name: "Optimism", + icon: "https://cryptologos.cc/logos/optimism-ethereum-op-logo.svg", + }, + { + ...sepolia, + icon: "https://cryptologos.cc/logos/ethereum-eth-logo.svg", + }, +];