Skip to content

Commit 2114626

Browse files
authored
NFT component improvements (#62)
* feat: add ENS support for NFTGallery * refactor(NFT): accept contractAddress and tokenId to <NFT /> * Update <NFTGallery/> stories * feat: add support for video NFTs * feat: add support for audio NFTs and some progress on tests * Fix tests and improve data fetching logic * Refactor stories
1 parent 3b28ae1 commit 2114626

File tree

10 files changed

+2832
-1620
lines changed

10 files changed

+2832
-1620
lines changed

packages/components/package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
"@emotion/core": "^11.0.0",
3232
"@emotion/react": "^11",
3333
"@emotion/styled": "^11",
34-
"classnames": "^2.2.6",
3534
"cross-fetch": "^3.1.4",
35+
"ethers": "^5.5.2",
3636
"framer-motion": "^4"
3737
},
3838
"peerDependencies": {
@@ -42,15 +42,12 @@
4242
"devDependencies": {
4343
"@babel/core": "^7.12.7",
4444
"@storybook/react": "^6.3.12",
45-
"@types/classnames": "^2.2.11",
4645
"@types/jest": "^26.0.15",
4746
"@types/node": "^16.11.9",
4847
"@types/react": "^17.0.36",
4948
"@types/react-dom": "^16.9.10",
5049
"@web3-ui/hooks": "^0.1.0",
5150
"babel-loader": "^8.2.1",
52-
"classnames": "^2.2.6",
53-
"ethers": "^5.5.1",
5451
"husky": "^7.0.0",
5552
"identity-obj-proxy": "^3.0.0",
5653
"lint-staged": "^12.1.2",

packages/components/src/components/NFT/NFT.stories.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,23 @@ export default {
66
component: NFT,
77
};
88

9-
export const Default = () => (
9+
export const image = () => (
10+
<NFT tokenId='1' contractAddress='0x25ed58c027921e14d86380ea2646e3a1b5c55a8b' />
11+
);
12+
13+
export const GIF = () => (
1014
<NFT
11-
tokenId='1'
12-
name='Dev #1'
13-
imageUrl='https://storage.opensea.io/files/acef01b1f111088c40a0d86a4cd8a2bd.svg'
14-
assetContractName='Devs for Revolution'
15-
assetContractSymbol='DEVS'
15+
contractAddress='0x495f947276749ce646f68ac8c248420045cb7b5e'
16+
tokenId='107788331033457039753851660026809005506934842002275129649229957686061111967745'
1617
/>
1718
);
19+
20+
export const Video = () => (
21+
<NFT contractAddress='0xb932a70a57673d89f4acffbe830e8ed7f75fb9e0' tokenId='29192' />
22+
);
23+
24+
export const Audio = () => (
25+
<NFT contractAddress='0x0eede4764cfdfcd5dac0e00b3b7f4778c0cc994e' tokenId='1' />
26+
);
27+
28+
export const Error = () => <NFT contractAddress='abcd' tokenId='1' />;
Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
import React from 'react';
2-
import { render } from '@testing-library/react';
2+
import { render, screen } from '@testing-library/react';
33

44
import { NFT } from './NFT';
5+
import { act } from 'react-dom/test-utils';
56

67
describe('NFT', () => {
7-
it('displays the NFT name', () => {
8-
const { container } = render(
9-
<NFT
10-
tokenId='1'
11-
name='Dev #1'
12-
imageUrl='https://storage.opensea.io/files/acef01b1f111088c40a0d86a4cd8a2bd.svg'
13-
assetContractName='Devs for Revolution'
14-
assetContractSymbol='DEVS'
15-
/>
16-
);
17-
18-
expect(container.textContent).toContain('Dev #1');
8+
it('displays an image NFT properly', async () => {
9+
act(() => {
10+
render(<NFT tokenId='1' contractAddress='0x25ed58c027921e14d86380ea2646e3a1b5c55a8b' />);
11+
});
12+
const name = await screen.findByText('Dev #1');
13+
const image = await screen.findByAltText('Dev #1');
14+
expect(name).toBeInTheDocument();
15+
expect(image).toBeInTheDocument();
1916
});
17+
18+
//TODO: test for video NFT
2019
});
Lines changed: 115 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,129 @@
1-
import React from 'react';
2-
import { Box, Heading, Image, Flex, Tag, Text } from '@chakra-ui/react';
1+
import React, { useCallback, useEffect, useRef } from 'react';
2+
import {
3+
Box,
4+
Heading,
5+
Image,
6+
Flex,
7+
Tag,
8+
Text,
9+
VStack,
10+
Skeleton,
11+
Alert,
12+
AlertIcon,
13+
} from '@chakra-ui/react';
14+
import fetch from 'cross-fetch';
315

416
export interface NFTProps {
5-
/**
6-
* The id for the NFT, unique within the contract
7-
*/
17+
contractAddress: string;
818
tokenId: string;
9-
/**
10-
* The name of the NFT, potentially null
11-
*/
19+
}
20+
21+
export interface NFTData {
22+
tokenId: string;
23+
imageUrl?: string;
1224
name: string | null;
13-
/**
14-
* The image of the NFT, cached from OpenSea
15-
*/
16-
imageUrl: string;
17-
/**
18-
* The name of the NFT collection
19-
*/
20-
assetContractName: string;
21-
/**
22-
* The symbol for the NFT collection
23-
*/
2425
assetContractSymbol: string;
26+
assetContractName: string;
27+
animationUrl?: string;
2528
}
2629

2730
/**
28-
* Component to display an NFT given render params
31+
* Component to fetch and display NFT data
2932
*/
30-
export const NFT = ({
31-
tokenId,
32-
name,
33-
imageUrl,
34-
assetContractName,
35-
assetContractSymbol,
36-
}: NFTProps) => {
37-
const displayName = name || tokenId;
33+
export const NFT = ({ contractAddress, tokenId }: NFTProps) => {
34+
const _isMounted = useRef(true);
35+
const [nftData, setNftData] = React.useState<NFTData>();
36+
const [errorMessage, setErrorMessage] = React.useState<string>();
37+
38+
const fetchNFTData = useCallback(async () => {
39+
try {
40+
const res = await fetch(`https://api.opensea.io/api/v1/asset/${contractAddress}/${tokenId}/`);
41+
if (!res.ok) {
42+
throw Error(
43+
`OpenSea request failed with status: ${res.status}. Make sure you are on mainnet.`
44+
);
45+
}
46+
const data = await res.json();
47+
if (_isMounted.current) {
48+
setNftData({
49+
tokenId: data.token_id,
50+
imageUrl: data.image_url,
51+
name: data.name,
52+
assetContractName: data.asset_contract.name,
53+
assetContractSymbol: data.asset_contract.symbol,
54+
animationUrl: data.animation_url,
55+
});
56+
}
57+
} catch (error: any) {
58+
setErrorMessage(error.message);
59+
}
60+
}, [contractAddress, tokenId]);
61+
62+
useEffect(() => {
63+
_isMounted.current = true;
64+
fetchNFTData();
65+
return () => {
66+
_isMounted.current = false;
67+
};
68+
}, [contractAddress, tokenId]);
69+
70+
return <NFTCard data={nftData} errorMessage={errorMessage} />;
71+
};
72+
73+
/**
74+
* Private component to display an NFT given the data
75+
*/
76+
export const NFTCard = ({
77+
data,
78+
errorMessage = '',
79+
}: {
80+
data: NFTData | undefined | null;
81+
errorMessage?: string | undefined;
82+
}) => {
83+
const name = data?.name;
84+
const imageUrl = data?.imageUrl;
85+
const assetContractName = data?.assetContractName;
86+
const assetContractSymbol = data?.assetContractSymbol;
87+
const animationUrl = data?.animationUrl;
88+
const tokenId = data?.tokenId;
89+
const displayName = name || `${assetContractSymbol} #${tokenId}`;
90+
91+
if (errorMessage) {
92+
return (
93+
<Alert status='error'>
94+
<AlertIcon />
95+
{errorMessage}
96+
</Alert>
97+
);
98+
}
3899

39100
return (
40-
<Box maxW='xs' borderRadius='lg' borderWidth='1px' overflow='hidden'>
41-
<Image src={imageUrl} alt={displayName} borderRadius='lg' />
42-
<Box p='6'>
43-
<Flex alignItems='center' justifyContent='space-between' pb='2'>
44-
<Heading as='h3' size='sm'>
45-
{displayName}
46-
</Heading>
47-
<Tag size='sm'>{assetContractSymbol}</Tag>
48-
</Flex>
49-
<Text fontSize='xs'>
50-
{assetContractName} #{tokenId}
51-
</Text>
101+
<Skeleton isLoaded={!!data} maxW='xs' h='md'>
102+
<Box maxW='xs' borderRadius='lg' borderWidth='1px' overflow='hidden'>
103+
{animationUrl ? (
104+
animationUrl.endsWith('.mp3') ? (
105+
<VStack>
106+
<Image src={imageUrl} alt={displayName} borderRadius='lg' />
107+
<audio src={animationUrl} controls autoPlay muted style={{ borderRadius: '7px' }} />
108+
</VStack>
109+
) : (
110+
<video src={animationUrl} controls autoPlay muted />
111+
)
112+
) : (
113+
<Image src={imageUrl} alt={displayName} borderRadius='lg' />
114+
)}
115+
<Box p='6'>
116+
<Flex alignItems='center' justifyContent='space-between' pb='2'>
117+
<Heading as='h3' size='sm'>
118+
{displayName}
119+
</Heading>
120+
{assetContractSymbol && <Tag size='sm'>{assetContractSymbol}</Tag>}
121+
</Flex>
122+
<Text fontSize='xs'>
123+
{assetContractName} #{tokenId}
124+
</Text>
125+
</Box>
52126
</Box>
53-
</Box>
127+
</Skeleton>
54128
);
55129
};
Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,33 @@
1-
import React from 'react';
1+
import { ethers } from 'ethers';
2+
import React, { useEffect, useState } from 'react';
23
import { NFTGallery } from '.';
34

45
export default {
56
title: 'Components/NFTGallery',
67
component: NFTGallery,
8+
parameters: {
9+
// TODO: Fix window.ethereum is undefined breaking chromatic
10+
chromatic: { disableSnapshot: true },
11+
},
712
};
813

9-
export const Default = () => <NFTGallery address='0x1A16c87927570239FECD343ad2654fD81682725e' />;
14+
export const nftsOwnedByAnAccount = () => (
15+
<NFTGallery address='0x1A16c87927570239FECD343ad2654fD81682725e' />
16+
);
17+
18+
export const nftsOwnedByAnENS = () => {
19+
const [provider, setProvider] = useState<ethers.providers.Web3Provider>();
20+
21+
useEffect(() => {
22+
const provider = new ethers.providers.Web3Provider(window.ethereum);
23+
setProvider(provider);
24+
}, []);
25+
26+
if (!provider) {
27+
return <>Loading...</>;
28+
}
29+
30+
return <NFTGallery address='dhaiwat.eth' web3Provider={provider} />;
31+
};
1032

1133
export const WithAnError = () => <NFTGallery address='bad_address' />;

packages/components/src/components/NFTGallery/NFTGallery.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ describe('NFTGallery', () => {
2424
expect(container.textContent).toContain('OpenSea request failed');
2525
});
2626
});
27+
28+
//TODO: test for ENS
2729
});

packages/components/src/components/NFTGallery/NFTGallery.tsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React, { useEffect } from 'react';
22
import fetch from 'cross-fetch';
3-
3+
import { ethers } from 'ethers';
44
import { VStack, Heading, Grid, Alert, AlertIcon } from '@chakra-ui/react';
5-
import { NFT } from '../NFT';
5+
import { NFTCard } from '../NFT';
66

77
export interface NFTGalleryProps {
88
/**
@@ -13,6 +13,7 @@ export interface NFTGalleryProps {
1313
* The number of columns in the grid
1414
*/
1515
gridWidth?: number;
16+
web3Provider?: ethers.providers.Web3Provider;
1617
}
1718

1819
export interface OpenSeaAsset {
@@ -21,6 +22,7 @@ export interface OpenSeaAsset {
2122
name: string | null;
2223
asset_contract: {
2324
name: string;
25+
address: string;
2426
symbol: string;
2527
};
2628
}
@@ -29,21 +31,33 @@ export interface OpenSeaAsset {
2931
* Component to display a grid of NFTs owned by an address. It uses the OpenSea API to fetch
3032
* the NFTs.
3133
*/
32-
export const NFTGallery = ({ address, gridWidth = 4 }: NFTGalleryProps) => {
34+
export const NFTGallery = ({ address, gridWidth = 4, web3Provider }: NFTGalleryProps) => {
3335
const [nfts, setNfts] = React.useState<OpenSeaAsset[]>([]);
3436
const [errorMessage, setErrorMessage] = React.useState();
3537

3638
useEffect(() => {
37-
fetch(`https://api.opensea.io/api/v1/assets?owner=${address}`)
38-
.then((res) => {
39-
if (!res.ok) {
40-
throw Error(`OpenSea request failed with status: ${res.status}.`);
39+
async function exec() {
40+
let resolvedAddress: string | null = address;
41+
if (address.endsWith('.eth')) {
42+
if (!web3Provider) {
43+
return console.error('Please provide a web3 provider');
4144
}
42-
return res.json();
43-
})
44-
.then((data) => setNfts(data.assets))
45-
.catch((err) => setErrorMessage(err.message));
46-
}, [address]);
45+
resolvedAddress = await web3Provider.resolveName(address);
46+
}
47+
fetch(`https://api.opensea.io/api/v1/assets?owner=${resolvedAddress}`)
48+
.then((res) => {
49+
if (!res.ok) {
50+
throw Error(
51+
`OpenSea request failed with status: ${res.status}. Make sure you are on mainnet.`
52+
);
53+
}
54+
return res.json();
55+
})
56+
.then((data) => setNfts(data.assets))
57+
.catch((err) => setErrorMessage(err.message));
58+
}
59+
exec();
60+
}, [address, web3Provider]);
4761

4862
return (
4963
<VStack>
@@ -56,13 +70,15 @@ export const NFTGallery = ({ address, gridWidth = 4 }: NFTGalleryProps) => {
5670
)}
5771
<Grid templateColumns={`repeat(${gridWidth}, 1fr)`} gap={6}>
5872
{nfts.map((nft) => (
59-
<NFT
73+
<NFTCard
6074
key={`${nft.asset_contract.symbol}-${nft.token_id}`}
61-
tokenId={nft.token_id}
62-
name={nft.name}
63-
imageUrl={nft.image_url}
64-
assetContractName={nft.asset_contract.name}
65-
assetContractSymbol={nft.asset_contract.symbol}
75+
data={{
76+
name: nft.name!,
77+
imageUrl: nft.image_url,
78+
tokenId: nft.token_id,
79+
assetContractName: nft.asset_contract.name,
80+
assetContractSymbol: nft.asset_contract.symbol,
81+
}}
6682
/>
6783
))}
6884
</Grid>

0 commit comments

Comments
 (0)