diff --git a/components/01-atoms/OpenSeaExternalLinkButton.tsx b/components/01-atoms/OpenSeaExternalLinkButton.tsx new file mode 100644 index 00000000..7a6b56aa --- /dev/null +++ b/components/01-atoms/OpenSeaExternalLinkButton.tsx @@ -0,0 +1,36 @@ +import { ExternalLinkIcon } from "@/components/01-atoms"; +import { EthereumAddress } from "@/lib/shared/types"; +import { useNetwork } from "wagmi"; + +export const OpenSeaExternalLinkButton = ({ + contractAddress, + tokenId, + label, +}: { + contractAddress: EthereumAddress; + tokenId: number; + label?: string; +}) => { + const { chain } = useNetwork(); + + if (!contractAddress) return null; + + const displayEllipsedAddress = tokenId.toString(); + + const openSeaExplorer = `https://testnets.opensea.io/assets/${chain?.name.toLowerCase()}/${contractAddress.toString()}/${tokenId}`; + + return ( +
+ +

{label || `#${displayEllipsedAddress}`}

+
+ +
+
+
+ ); +}; diff --git a/components/01-atoms/Token3DModal.tsx b/components/01-atoms/Token3DModal.tsx new file mode 100644 index 00000000..d9827867 --- /dev/null +++ b/components/01-atoms/Token3DModal.tsx @@ -0,0 +1,85 @@ +import { TokenCard, TokenCardActionType } from "@/components/02-molecules"; +import { + BlockExplorerExternalLinkButton, + OpenSeaExternalLinkButton, +} from "@/components/01-atoms"; +import { ERC721, EthereumAddress, Token, TokenType } from "@/lib/shared/types"; +import cc from "classcat"; +import { Atropos } from "atropos/react"; + +interface Token3DModalProps { + isOpen: boolean; + onClose: () => void; + token: Token; + ownerAddress: EthereumAddress | null; +} + +export const Token3DModal = ({ + isOpen, + onClose, + token, + ownerAddress, +}: Token3DModalProps) => { + return ( + <> + {isOpen && ( +
+ )} +
+
+
+ + + +
+
+

+ {token.name} ( + {token.tokenType === TokenType.ERC721 && + (token as ERC721).contractMetadata?.name && + (token as ERC721).contractMetadata?.symbol} + ) +

+

+ {token.id && token.contract && ( + <> + + + )} +

+

+ {token.contract && ( + + )} +

+
+
+
+ + ); +}; diff --git a/components/01-atoms/index.ts b/components/01-atoms/index.ts index ac9566a0..31459535 100644 --- a/components/01-atoms/index.ts +++ b/components/01-atoms/index.ts @@ -10,6 +10,7 @@ export * from "./NetworkDropdown"; export * from "./MobileNotSupported"; export * from "./OfferExpiryConfirmSwap"; export * from "./OfferTag"; +export * from "./OpenSeaExternalLinkButton"; export * from "./ProgressBar"; export * from "./SearchBar"; export * from "./SearchItemsShelf"; @@ -21,6 +22,7 @@ export * from "./SwapModalLayout"; export * from "./SwappingIcons"; export * from "./SwappingSearchTab"; export * from "./ThreeDotsCardOffersOptions"; +export * from "./Token3DModal"; export * from "./TokenCardProperties"; export * from "./TokenCardsPlaceholder"; export * from "./TokenOfferDetails"; diff --git a/components/02-molecules/TokenCard.tsx b/components/02-molecules/TokenCard.tsx index 1ca1b324..16497575 100644 --- a/components/02-molecules/TokenCard.tsx +++ b/components/02-molecules/TokenCard.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { SwaplaceIcon } from "@/components/01-atoms"; +import { SwaplaceIcon, Token3DModal } from "@/components/01-atoms"; import { useAuthenticatedUser } from "@/lib/client/hooks/useAuthenticatedUser"; import { ERC20, @@ -10,9 +10,11 @@ import { } from "@/lib/shared/types"; import { getTokenName } from "@/lib/client/ui-utils"; import { SwapContext } from "@/lib/client/contexts"; +import useLongPress from "@/lib/client/hooks/useLongPress"; import React, { useContext, useEffect, useState } from "react"; import cc from "classcat"; import toast from "react-hot-toast"; +import { Atropos } from "atropos/react"; interface TokenCardProps { tokenData: Token; @@ -31,6 +33,7 @@ interface TokenCardProps { displayERC20TokensAmount?: boolean; withSelectionValidation?: boolean; styleType?: StyleVariant; + isToken3D?: boolean; // If true, the token card will be displayed in 3D using the AtroposLibrary } export enum TokenCardActionType { @@ -45,6 +48,7 @@ export enum TokenCardStyleType { NORMAL = "normal", MEDIUM = "medium", LARGE = "large", + FIT = "fit", } type StyleVariant = @@ -52,13 +56,15 @@ type StyleVariant = | "small" | "normal" | "medium" - | "large"; + | "large" + | "fit"; export const TokenSizeClassNames = { [TokenCardStyleType.SMALL]: "card-token-small", [TokenCardStyleType.NORMAL]: "card-token-normal", [TokenCardStyleType.MEDIUM]: "card-token-medium", [TokenCardStyleType.LARGE]: "card-token-large", + [TokenCardStyleType.FIT]: "w-full h-full", }; /** @@ -85,6 +91,7 @@ export const TokenCard = ({ displayERC20TokensAmount = false, styleType = TokenCardStyleType.NORMAL, onClickAction = TokenCardActionType.SELECT_TOKEN_FOR_SWAP, + isToken3D = false, }: TokenCardProps) => { const { authenticatedUserAddress } = useAuthenticatedUser(); const { @@ -95,11 +102,42 @@ export const TokenCard = ({ } = useContext(SwapContext); const [currentNftIsSelected, setCurrentNftIsSelected] = useState(false); const [couldntLoadNftImage, setCouldntLoadNftImage] = useState(false); - const [tokenDisplayableData, setDisplayableData] = useState({ id: "", symbol: "", }); + const [isPressed, setIsPressed] = useState(false); + + interface TokenToClipboard { + tokenType: TokenType; + id?: string; + name?: string; + image: string; + contract: string; + } + + /** + * Parses the token data into a format suitable for copying to the clipboard. + * @param tokenData The token data to be parsed. + * @returns The parsed token data in the form of TokenToClipboard object. + */ + const parseTokenDataToClipboard = (tokenData: Token): TokenToClipboard => { + const tokenCopyToClipboard: TokenToClipboard = { + tokenType: tokenData.tokenType, + id: tokenData.id, + name: tokenData.name, + image: + tokenData.tokenType === TokenType.ERC721 + ? ((tokenData as ERC721).metadata?.image as string) + : "", + contract: + tokenData.tokenType === TokenType.ERC721 + ? ((tokenData as ERC721).contract as string) + : "", + }; + + return tokenCopyToClipboard; + }; useEffect(() => { const displayableData = { ...tokenDisplayableData }; @@ -207,7 +245,8 @@ export const TokenCard = ({ } } } else if (onClickAction === TokenCardActionType.SHOW_NFT_DETAILS) { - navigator.clipboard.writeText(JSON.stringify(tokenData)); + const tokenDataToClipboard = parseTokenDataToClipboard(tokenData); + navigator.clipboard.writeText(JSON.stringify(tokenDataToClipboard)); toast.success("NFT data copied to your clipboard!"); } }; @@ -216,8 +255,47 @@ export const TokenCard = ({ setCouldntLoadNftImage(true); }; + const handleClick = () => { + setIsPressed(false); + }; + + const handleClickLong = () => { + setIsPressed(true); + }; + + const { onMouseDown, onMouseUp, onMouseLeave } = useLongPress( + handleClickLong, + onCardClick, // when the user clicks in Button card + ); + const ButtonLayout = (children: React.ReactNode) => { - return ( + return isToken3D ? ( + + + + ) : (
, )} + {isPressed && ( + { + setIsPressed(false); + }} + /> + )} ); }; diff --git a/components/02-molecules/TokensList.tsx b/components/02-molecules/TokensList.tsx index 5ee92bbe..e17c3e37 100644 --- a/components/02-molecules/TokensList.tsx +++ b/components/02-molecules/TokensList.tsx @@ -34,6 +34,7 @@ export interface TokensListProps { tokenCardClickAction?: TokenCardActionType; variant: ForWhom; gridClassNames?: string; + isToken3D?: boolean; // If true, the token card will be displayed in 3D using the AtroposLibrary } /** @@ -62,6 +63,7 @@ export const TokensList = ({ tokenCardStyleType = TokenCardStyleType.NORMAL, tokenCardClickAction = TokenCardActionType.SELECT_TOKEN_FOR_SWAP, gridClassNames = "w-full h-full grid grid-cols-3 md:grid-cols-6 lg:grid-cols-6 gap-3", + isToken3D = false, }: TokensListProps) => { const [selectTokenAmountOf, setSelectTokenAmountOf] = useState(null); @@ -113,6 +115,7 @@ export const TokensList = ({ withSelectionValidation={withSelectionValidation} ownerAddress={ownerAddress} tokenData={token} + isToken3D={isToken3D} /> )); diff --git a/components/03-organisms/TokensShelf.tsx b/components/03-organisms/TokensShelf.tsx index b9ec54ec..bf8203de 100644 --- a/components/03-organisms/TokensShelf.tsx +++ b/components/03-organisms/TokensShelf.tsx @@ -191,6 +191,7 @@ export const TokensShelf = ({ variant }: { variant: ForWhom }) => { ownerAddress={address} tokensList={allTokensList} variant={variant} + isToken3D={true} /> ) : tokensQueryStatus == TokensQueryStatus.EMPTY_QUERY || !address ? ( diff --git a/lib/client/hooks/useLongPress.tsx b/lib/client/hooks/useLongPress.tsx new file mode 100644 index 00000000..a0443d0d --- /dev/null +++ b/lib/client/hooks/useLongPress.tsx @@ -0,0 +1,66 @@ +import { useCallback, useRef, useState, SyntheticEvent } from "react"; + +interface LongPressOptions { + shouldPreventDefault?: boolean; + delay?: number; +} + +const useLongPress = ( + onLongPress: (event: SyntheticEvent) => void, + onClick: () => void, + { shouldPreventDefault = true, delay = 300 }: LongPressOptions = {}, +) => { + const [longPressTriggered, setLongPressTriggered] = useState(false); + const timeout = useRef(null); + const target = useRef(null); + + const start = useCallback( + (event: SyntheticEvent) => { + if (shouldPreventDefault && event.target) { + event.target.addEventListener("touchend", preventDefault, { + passive: false, + }); + target.current = event.target; + } + timeout.current = setTimeout(() => { + onLongPress(event); + setLongPressTriggered(true); + }, delay); + }, + [onLongPress, delay, shouldPreventDefault], + ); + + const clear = useCallback( + (event: SyntheticEvent, shouldTriggerClick = true) => { + timeout.current && clearTimeout(timeout.current); + shouldTriggerClick && !longPressTriggered && onClick(); + setLongPressTriggered(false); + if (shouldPreventDefault && target.current) { + target.current.removeEventListener("touchend", preventDefault); + } + }, + [shouldPreventDefault, onClick, longPressTriggered], + ); + + return { + onMouseDown: (e: SyntheticEvent) => start(e), + onTouchStart: (e: SyntheticEvent) => start(e), + onMouseUp: (e: SyntheticEvent) => clear(e), + onMouseLeave: (e: SyntheticEvent) => clear(e, false), + onTouchEnd: (e: SyntheticEvent) => clear(e), + }; +}; + +const isTouchEvent = (event: Event): event is TouchEvent => { + return "touches" in event; +}; + +const preventDefault = (event: Event) => { + if (!isTouchEvent(event)) return; + + if ((event as TouchEvent).touches.length < 2 && event.preventDefault) { + event.preventDefault(); + } +}; + +export default useLongPress; diff --git a/package-lock.json b/package-lock.json index 5e9abe2c..5015e7d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@typescript-eslint/eslint-plugin": "5.54.1", "@wagmi/core": "^2.6.9", "alchemy-sdk": "^3.1.2", + "atropos": "^2.0.2", "axios": "^1.6.7", "boring-avatars": "1.7.0", "classcat": "^5.0.4", @@ -4202,6 +4203,14 @@ "node": ">=8.0.0" } }, + "node_modules/atropos": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/atropos/-/atropos-2.0.2.tgz", + "integrity": "sha512-8f0u0hEOlBTWTSvzY17TcHuQjxUIpkTBq70/I4+UF5B43ORtOoRjm8TPBYEgLM8Ba9AWf6PDtkagbYoybdjaKg==", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.17", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", diff --git a/package.json b/package.json index 51dfced7..468c0cac 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@typescript-eslint/eslint-plugin": "5.54.1", "@wagmi/core": "^2.6.9", "alchemy-sdk": "^3.1.2", + "atropos": "^2.0.2", "axios": "^1.6.7", "boring-avatars": "1.7.0", "classcat": "^5.0.4", diff --git a/pages/_app.tsx b/pages/_app.tsx index 2cfdf0f3..23999ff6 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -27,6 +27,7 @@ import localFont from "next/font/local"; import cc from "classcat"; import { ThemeProvider } from "next-themes"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "atropos/css"; const onest = localFont({ src: "../public/fonts/Onest-VariableFont_wght.woff2", diff --git a/styles/global.css b/styles/global.css index 9bca87e3..d951d876 100644 --- a/styles/global.css +++ b/styles/global.css @@ -214,3 +214,8 @@ input[type="number"] { @apply w-10 h-10 rounded-[10px] overflow-hidden; } } + + +.atropos-scale { + transition: transform 0.5s cubic-bezier(0.19, 1, 0.22, 1); +} \ No newline at end of file