From 3d1b2534e7b72b324cfa9f43607488d10dc22577 Mon Sep 17 00:00:00 2001 From: Raymond Huynh Date: Thu, 16 Nov 2023 16:51:59 -0800 Subject: [PATCH] Ray/v3 update (#27) * add hook to fetch v2 and v3 * fix bug * add notes & clean up code * update UI to change accounts * fix bug with nft render for acc * add v3 v2 badge & disclaimer on v2 --- .../[tokenId]/[chainId]/Panel.tsx | 109 +++++++++++------- .../[tokenId]/[chainId]/TokenDetail.tsx | 6 + .../[tokenId]/[chainId]/page.tsx | 49 +++----- components/ui/Disclaimer.tsx | 24 ++++ components/ui/DropDownMenu.tsx | 100 ++++++++++++++++ components/ui/index.ts | 2 + lib/hooks/index.ts | 1 + lib/hooks/useTBADetails.ts | 90 +++++++++++++++ lib/utils/shortenAddress.ts | 3 +- package.json | 2 +- pnpm-lock.yaml | 43 +++---- tailwind.config.js | 2 + 12 files changed, 326 insertions(+), 105 deletions(-) create mode 100644 components/ui/Disclaimer.tsx create mode 100644 components/ui/DropDownMenu.tsx create mode 100644 lib/hooks/useTBADetails.ts diff --git a/app/[contractAddress]/[tokenId]/[chainId]/Panel.tsx b/app/[contractAddress]/[tokenId]/[chainId]/Panel.tsx index 4f01079..c1a231e 100644 --- a/app/[contractAddress]/[tokenId]/[chainId]/Panel.tsx +++ b/app/[contractAddress]/[tokenId]/[chainId]/Panel.tsx @@ -1,8 +1,15 @@ /* eslint-disable @next/next/no-img-element */ import clsx from "clsx"; import { useState } from "react"; -import { Check, Exclamation } from "@/components/icon"; -import { Tabs, TabPanel, MediaViewer, ExternalLink } from "@/components/ui"; +import { Check } from "@/components/icon"; +import { + Tabs, + TabPanel, + MediaViewer, + ExternalLink, + DropdownMenu, + Disclaimer, +} from "@/components/ui"; import { TbaOwnedNft } from "@/lib/types"; import useSWR from "swr"; import { getAlchemy } from "@/lib/clients"; @@ -16,10 +23,55 @@ export const TABS = { ASSETS: "Assets", }; +interface CopyAddressProps { + account: string; + displayedAddress: string; +} + +const CopyAddress = ({ account, displayedAddress }: CopyAddressProps) => { + const [copied, setCopied] = useState(false); + + return ( +
{ + const textarea = document.createElement("textarea"); + textarea.textContent = account; + textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in MS Edge. + document.body.appendChild(textarea); + textarea.select(); + + try { + document.execCommand("copy"); // Security exception may be thrown by some browsers. + setCopied(true); + setTimeout(() => setCopied(false), 1000); + + return; + } catch (ex) { + console.warn("Copy to clipboard failed.", ex); + return false; + } finally { + document.body.removeChild(textarea); + } + }} + > + {copied ? ( + + + + ) : ( + shortenAddress(displayedAddress) + )} +
+ ); +}; + interface Props { className?: string; approvalTokensCount?: number; account?: string; + accounts?: string[]; + handleAccountChange?: (account: string) => void; tokens: TbaOwnedNft[]; title: string; chainId: number; @@ -29,6 +81,8 @@ export const Panel = ({ className, approvalTokensCount, account, + accounts, + handleAccountChange, tokens, title, chainId, @@ -58,53 +112,26 @@ export const Panel = ({

{title}

- {account && displayedAddress && (
- { - const textarea = document.createElement("textarea"); - textarea.textContent = account; - textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in MS Edge. - document.body.appendChild(textarea); - textarea.select(); - - try { - document.execCommand("copy"); // Security exception may be thrown by some browsers. - setCopied(true); - setTimeout(() => setCopied(false), 1000); - - return; - } catch (ex) { - console.warn("Copy to clipboard failed.", ex); - return false; - } finally { - document.body.removeChild(textarea); - } - }} + - {copied ? ( - - - - ) : ( - shortenAddress(displayedAddress) - )} - + +
)} {approvalTokensCount ? ( -
-
- -
-

- {`There are existing approvals on (${approvalTokensCount}) tokens owned by this account. Check approval status on tokenbound.org before purchasing.`} -

-
+ ) : null} + {typeof account === "string" && accounts && account === accounts[1] && ( + + )} void; approvalTokensCount?: number; account?: string; + accounts?: string[]; + handleAccountChange: (arg0: string) => void; tokens: TbaOwnedNft[]; title: string; chainId: number; @@ -48,6 +50,8 @@ export const TokenDetail = ({ handleOpenClose, approvalTokensCount, account, + accounts, + handleAccountChange, tokens, title, chainId, @@ -80,6 +84,8 @@ export const TokenDetail = ({ tokens={tokens} title={title} chainId={chainId} + accounts={accounts} + handleAccountChange={handleAccountChange} /> )} diff --git a/app/[contractAddress]/[tokenId]/[chainId]/page.tsx b/app/[contractAddress]/[tokenId]/[chainId]/page.tsx index 36cff6d..82f6b0e 100644 --- a/app/[contractAddress]/[tokenId]/[chainId]/page.tsx +++ b/app/[contractAddress]/[tokenId]/[chainId]/page.tsx @@ -4,13 +4,13 @@ import { useEffect, useState } from "react"; import useSWR from "swr"; import { isNil } from "lodash"; import { TokenboundClient } from "@tokenbound/sdk"; -import { getAccount, getAccountStatus, getLensNfts, getNfts } from "@/lib/utils"; +import { getAccountStatus } from "@/lib/utils"; import { TbLogo } from "@/components/icon"; -import { useGetApprovals, useNft } from "@/lib/hooks"; +import { useGetApprovals, useNft, useTBADetails } from "@/lib/hooks"; import { TbaOwnedNft } from "@/lib/types"; import { getAddress } from "viem"; import { TokenDetail } from "./TokenDetail"; -import { HAS_CUSTOM_IMPLEMENTATION, alchemyApiKey } from "@/lib/constants"; +import { HAS_CUSTOM_IMPLEMENTATION } from "@/lib/constants"; interface TokenParams { params: { @@ -26,12 +26,11 @@ interface TokenParams { export default function Token({ params, searchParams }: TokenParams) { const [imagesLoaded, setImagesLoaded] = useState(false); - const [nfts, setNfts] = useState([]); - const [lensNfts, setLensNfts] = useState([]); const { tokenId, contractAddress, chainId } = params; const { disableloading, logo } = searchParams; const [showTokenDetail, setShowTokenDetail] = useState(false); const chainIdNumber = parseInt(chainId); + const tokenboundClient = new TokenboundClient({ chainId: chainIdNumber }); const { @@ -67,9 +66,11 @@ export default function Token({ params, searchParams }: TokenParams) { }, [nftImages, nftMetadataLoading]); // Fetch nft's TBA - const { data: account } = useSWR(tokenId ? `/account/${tokenId}` : null, async () => { - const result = await getAccount(Number(tokenId), contractAddress, chainIdNumber); - return result.data; + const { account, nfts, handleAccountChange, tba, tbaV2 } = useTBADetails({ + tokenboundClient, + tokenId, + tokenContract: contractAddress as `0x${string}`, + chainId: chainIdNumber, }); // Get nft's TBA account bytecode to check if account is deployed or not @@ -88,33 +89,12 @@ export default function Token({ params, searchParams }: TokenParams) { return data ?? false; }); - // fetch nfts inside TBA - useEffect(() => { - async function fetchNfts(account: string) { - const [data, lensData] = await Promise.all([ - getNfts(chainIdNumber, account), - getLensNfts(account), - ]); - if (data) { - setNfts(data); - } - if (lensData) { - setLensNfts(lensData); - } - } - - if (account) { - fetchNfts(account); - } - }, [account, accountIsDeployed, chainIdNumber]); - const [tokens, setTokens] = useState([]); - const allNfts = [...nfts, ...lensNfts]; - const { data: approvalData } = useGetApprovals(allNfts, account, chainIdNumber); + const { data: approvalData } = useGetApprovals(nfts, account, chainIdNumber); useEffect(() => { - if (nfts !== undefined && nfts.length) { + if (nfts !== undefined) { nfts.map((token) => { const foundApproval = approvalData?.find((item) => { const contract = item.contract.address; @@ -129,11 +109,8 @@ export default function Token({ params, searchParams }: TokenParams) { token.hasApprovals = foundApproval?.hasApprovals || false; }); setTokens(nfts); - if (lensNfts) { - setTokens([...nfts, ...lensNfts]); - } } - }, [nfts, approvalData, lensNfts]); + }, [nfts, approvalData, account]); const showLoading = disableloading !== "true" && nftMetadataLoading; @@ -151,6 +128,8 @@ export default function Token({ params, searchParams }: TokenParams) { title={nftMetadata.title} chainId={chainIdNumber} logo={logo} + accounts={[tba, tbaV2 as string]} + handleAccountChange={handleAccountChange} /> )}
diff --git a/components/ui/Disclaimer.tsx b/components/ui/Disclaimer.tsx new file mode 100644 index 0000000..9b21c46 --- /dev/null +++ b/components/ui/Disclaimer.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx"; +import { Exclamation } from "../icon"; + +interface Props + extends React.DetailedHTMLProps, HTMLDivElement> { + message: string; +} + +export const Disclaimer = ({ message, className, ...rest }: Props) => { + return ( +
+
+ +
+

{message}

+
+ ); +}; diff --git a/components/ui/DropDownMenu.tsx b/components/ui/DropDownMenu.tsx new file mode 100644 index 0000000..b7e56c7 --- /dev/null +++ b/components/ui/DropDownMenu.tsx @@ -0,0 +1,100 @@ +import { shortenAddress } from "@/lib/utils"; +import clsx from "clsx"; +import { useState } from "react"; + +interface Props + extends React.DetailedHTMLProps, HTMLDivElement> { + options?: string[]; + currentOption?: string; + setCurrentOption?: (arg0: string) => void; +} + +export const DropdownMenu = ({ + options, + currentOption, + setCurrentOption, + className, + children, + ...rest +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + + const handleOptionClick = (option: string) => { + if (setCurrentOption) setCurrentOption(option); + setIsOpen(false); + }; + + if (!options || options.length <= 1) { + return null; + } + + return ( +
+
+ {children ||
{currentOption}
} + {options?.length > 1 && ( +
+ +
+ )} +
+ {isOpen && ( +
+
+ {options?.map((option, index) => ( + + ))} +
+
+ )} +
+ ); +}; diff --git a/components/ui/index.ts b/components/ui/index.ts index 9acd71c..08c416c 100644 --- a/components/ui/index.ts +++ b/components/ui/index.ts @@ -3,3 +3,5 @@ export * from "./Tabs"; export * from "./TabPanel"; export * from "./MediaViewer"; export * from "./ExternalLink"; +export * from "./DropDownMenu"; +export * from "./Disclaimer"; diff --git a/lib/hooks/index.ts b/lib/hooks/index.ts index 6bfa118..c103bf3 100644 --- a/lib/hooks/index.ts +++ b/lib/hooks/index.ts @@ -1,3 +1,4 @@ export * from "./useNft"; export * from "./useGetApprovals"; export * from "./useGetTokenBalances"; +export * from "./useTBADetails"; diff --git a/lib/hooks/useTBADetails.ts b/lib/hooks/useTBADetails.ts new file mode 100644 index 0000000..d131e34 --- /dev/null +++ b/lib/hooks/useTBADetails.ts @@ -0,0 +1,90 @@ +import { getAccount, getLensNfts, getNfts } from "@/lib/utils"; +import { TokenboundClient } from "@tokenbound/sdk"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { TbaOwnedNft } from "../types"; + +type TBADetailsParams = { + tokenboundClient: TokenboundClient; + tokenId: string; + tokenContract: `0x${string}`; + chainId: number; +}; + +export const useTBADetails = ({ + tokenboundClient, + tokenId, + tokenContract, + chainId, +}: TBADetailsParams) => { + const [account, setAccount] = useState(""); + const [nfts, setNfts] = useState([]); + + const handleAccountChange = (account: string) => { + setAccount(account); + + if (account === tba) { + setNfts(tbaNFTs || []); + } else { + setNfts(tbaV2NFTs || []); + } + }; + + const { data: tbaV2 } = useSWR(`tbaV2-${tokenId}-${tokenContract}`, () => + getAccount(Number(tokenId), tokenContract, chainId) + ); + + const tba = tokenboundClient.getAccount({ tokenId, tokenContract }); + + const { data: isTbaDeployed } = useSWR(`tba-${tokenId}-${tokenContract}`, () => + tokenboundClient.checkAccountDeployment({ accountAddress: tba }) + ); + + const { + data: tbaV2NFTs, + isLoading, + error, + } = useSWR(`tbaV2NFTs-${tbaV2?.data}}`, async () => { + if (tbaV2) { + console.log("Inside fetching the nfts for v2: ", tbaV2); + const [nfts, lensNFT] = await Promise.all([ + getNfts(chainId, tbaV2.data as `0x${string}`), + getLensNfts(tbaV2.data as `0x${string}`), + ]); + + return [...nfts, ...lensNFT]; + } + + return []; + }); + + const { data: tbaNFTs } = useSWR(`tbaNFTs-${tba}`, async () => { + const [nfts, lensNFT] = await Promise.all([getNfts(chainId, tba), getLensNfts(tba)]); + + return [...nfts, ...lensNFT]; + }); + + useEffect(() => { + // If there are nfts in v3 by default show those + if (tbaNFTs?.length) { + setAccount(tba); + setNfts(tbaNFTs); + // If there are no nfts in v3 but there are in v2 show those + } else if (tbaV2NFTs?.length && !tbaNFTs?.length) { + setAccount(tbaV2?.data as `0x${string}`); + setNfts(tbaV2NFTs); + // Default to v3 + } else { + setAccount(tba); + setNfts(tbaNFTs || []); + } + }, [isTbaDeployed, tbaV2NFTs, tbaNFTs, tba, tbaV2?.data]); + + return { + tba, + tbaV2: tbaV2?.data, + account, + nfts, + handleAccountChange, + }; +}; diff --git a/lib/utils/shortenAddress.ts b/lib/utils/shortenAddress.ts index 2508b5b..b8c396f 100644 --- a/lib/utils/shortenAddress.ts +++ b/lib/utils/shortenAddress.ts @@ -1,4 +1,5 @@ -export function shortenAddress(address: string): string { +export function shortenAddress(address?: string): string { + if (!address) return ""; const start = address.slice(0, 4); const end = address.slice(-3); return `${start}...${end}`; diff --git a/package.json b/package.json index c2d4a9e..48c6dbf 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@sentry/nextjs": "^7.55.2", - "@tokenbound/sdk": "^0.3.5", + "@tokenbound/sdk": "^0.4.4", "@types/node": "20.3.1", "@types/react": "18.2.12", "@types/react-dom": "18.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db1d205..2abbc0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: ^7.55.2 version: 7.55.2(encoding@0.1.13)(next@13.4.5)(react@18.2.0) '@tokenbound/sdk': - specifier: ^0.3.5 - version: 0.3.5(typescript@5.1.3) + specifier: ^0.4.4 + version: 0.4.4(typescript@5.1.3) '@types/node': specifier: 20.3.1 version: 20.3.1 @@ -97,12 +97,12 @@ devDependencies: packages: - /@adraffy/ens-normalize@1.9.0: - resolution: {integrity: sha512-iowxq3U30sghZotgl4s/oJRci6WPBfNO5YYgk2cIOMCHr3LeGPcsZjCEr+33Q4N+oV3OABDAtA+pyvWjbvBifQ==} + /@adraffy/ens-normalize@1.10.0: + resolution: {integrity: sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q==} dev: false - /@adraffy/ens-normalize@1.9.4: - resolution: {integrity: sha512-UK0bHA7hh9cR39V+4gl2/NnBBjoXIxkuWAPCaY4X7fbH4L/azIi7ilWOCjMUYfpJgraLUAqkRi2BqrjME8Rynw==} + /@adraffy/ens-normalize@1.9.0: + resolution: {integrity: sha512-iowxq3U30sghZotgl4s/oJRci6WPBfNO5YYgk2cIOMCHr3LeGPcsZjCEr+33Q4N+oV3OABDAtA+pyvWjbvBifQ==} dev: false /@alloc/quick-lru@5.2.0: @@ -766,10 +766,6 @@ packages: resolution: {integrity: sha512-RkmuBcqiNioeeBKbgzMlOdreUkJfYaSjwgx9XDgGGpjvWgyaxWvDmZVSN9CS6LjEASadhgPv2BcFp+SeouWXXA==} dev: false - /@scure/base@1.1.1: - resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} - dev: false - /@scure/base@1.1.3: resolution: {integrity: sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==} dev: false @@ -801,7 +797,7 @@ packages: resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} dependencies: '@noble/hashes': 1.3.2 - '@scure/base': 1.1.1 + '@scure/base': 1.1.3 dev: false /@sentry-internal/tracing@7.55.2: @@ -961,10 +957,10 @@ packages: tslib: 2.5.3 dev: false - /@tokenbound/sdk@0.3.5(typescript@5.1.3): - resolution: {integrity: sha512-THgeAkY+l/OpUdWsAh/HyP5x9X0pLIyLCSuNPMmmDRUwuSoBC3J65/bZWffX4CXXUXr92IzHrw0PM+Jy7nUBKQ==} + /@tokenbound/sdk@0.4.4(typescript@5.1.3): + resolution: {integrity: sha512-BUjEEfzpq/6j2EP/xuwOVbFUyisHUf15PoJj/0y4+QQKYci2c6uoY7Im0CnfIakh0iiGTtYgrnwWTnDMefuPyg==} dependencies: - viem: 1.10.7(typescript@5.1.3) + viem: 1.19.3(typescript@5.1.3) transitivePeerDependencies: - bufferutil - typescript @@ -1010,12 +1006,6 @@ packages: resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} dev: false - /@types/ws@8.5.5: - resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} - dependencies: - '@types/node': 20.3.1 - dev: false - /@typescript-eslint/parser@5.59.11(eslint@8.42.0)(typescript@5.1.3): resolution: {integrity: sha512-s9ZF3M+Nym6CAZEkJJeO2TFHHDsKAM3ecNkLuH4i4s8/RCPnF5JRip2GyviYkeEAcwGMJxkqG9h2dAsnA1nZpA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2811,8 +2801,8 @@ packages: ws: 8.12.0 dev: false - /isomorphic-ws@5.0.0(ws@8.13.0): - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + /isows@1.0.3(ws@8.13.0): + resolution: {integrity: sha512-2cKei4vlmg2cxEjm3wVSqn8pcoRF/LX/wpifuuNquFO4SQmPwarClT+SUCA2lt+l581tTeZIPIZuIDo2jWN1fg==} peerDependencies: ws: '*' dependencies: @@ -4141,22 +4131,21 @@ packages: - zod dev: false - /viem@1.10.7(typescript@5.1.3): - resolution: {integrity: sha512-yuaYSHgV1g794nfxhn+V89qgK5ziFTLBSNqSDt4KW8YpjLu0Ah6LLZTtpOj3+MRWKKDwJ1YL2rENb8cuXstUzg==} + /viem@1.19.3(typescript@5.1.3): + resolution: {integrity: sha512-SymIbCO0nIq2ucna8R3XV4f/lEshKGLuhYU2f6O+OAbmE2ugBxBKx2axMKQrQML87nE0jb2qqqtRJka5SO/7MA==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: typescript: optional: true dependencies: - '@adraffy/ens-normalize': 1.9.4 + '@adraffy/ens-normalize': 1.10.0 '@noble/curves': 1.2.0 '@noble/hashes': 1.3.2 '@scure/bip32': 1.3.2 '@scure/bip39': 1.2.1 - '@types/ws': 8.5.5 abitype: 0.9.8(typescript@5.1.3) - isomorphic-ws: 5.0.0(ws@8.13.0) + isows: 1.0.3(ws@8.13.0) typescript: 5.1.3 ws: 8.13.0 transitivePeerDependencies: diff --git a/tailwind.config.js b/tailwind.config.js index 425a21e..b4dcfc2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -17,6 +17,8 @@ module.exports = { "tb-warning-primary": "#FF8A00", "tb-warning-secondary": "rgba(255, 138, 0, 0.1)", "tb-text-gray": "#666D74", + purple: "#644CF7", + "purple-fade": "rgba(100, 76, 247, 0.10)", }, transitionProperty: { width: "width",