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 (
+
+ );
+};
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);
+ }}
+ />
+ )}
>
) : (
<>
@@ -261,6 +349,16 @@ export const TokenCard = ({
})}
,
)}
+ {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