Skip to main content

Step 3: Create a React frontend with Material UI V5

Set up the project

This step uses Material UI V5 for visual components. Follow this example to jumpstart a React app with Typescript.

Run the following commands in the terminal:

curl https://codeload.github.com/mui-org/material-ui/tar.gz/master | tar -xz --strip=2 material-ui-master/examples/create-react-app-with-typescript

mv create-react-app-with-typescript your-project-name

cd your-project-name
caution

You might need to revert react-script to version 4.0.3 to avoid dependency issues caused by Webpack 5:

"react-scripts": "^4.0.3"

If you've already installed the package, delete node_modules and yarn.lock under the root folder and yarn install again.

Install the packages:

yarn install

You must install other dependencies for the app, including:

  • react-router-dom - A fully-featured client and server-side routing library for React.
  • react-query - For fetching and updating server state from React app.
  • ethers - A complete Ethereum wallet implementation and utilities in JavaScript and TypeScript.
  • @ethersproject/providers - A submodule of ethers that contains common Provider classes and utility functions for dealing with providers.
  • axios - A Promise-based HTTP client for the browser and node.js.
  • lodash - A modern utility library for JavaScript.
  • yup - A JavaScript schema builder for value parsing and validation.
  • formik - An easy tool to build forms in React.
  • @mui/icons-material - A library of pre-built svg icons from MUI.
  • typechain - For generating TypeScript typings from Ethereum smart contracts.
yarn add react-router-dom@6
yarn add react-query
yarn add ethers
yarn add @ethersproject/providers
yarn add axios
yarn add lodash
yarn add yup
yarn add formik
yarn add @mui/icons-material
yarn add typechain
yarn add @typechain/ethers-v5

Create a .env.local file under the root folder to store all the environment variables for the app. Export the variables in a configuration file to keep them handy for retrieval later.

tip

You can find the Collection ID in the URL parameter of the Collections page.

PUBLIC_URL=/
REACT_APP_WEBSITE_TITLE=THE EGG Drop
REACT_APP_API_BASE_URL=https://platform.consensys-nft.com/api
REACT_APP_NETWORK_ID=4
REACT_APP_NETWORK_NAME=Rinkeby
REACT_APP_ORGANIZATION_ID=//replace with your organization ID
REACT_APP_COLLECTION_ID=//replace with your collection ID

Create HomePage

Restructure the project folder to add the separate folders /pages, /components, /services, /img, and /utils under /src.

/src
|---- /components: for all React components
|---- /img: store brand assets
|---- /pages: pages for navigation
|---- /services: for api services
|---- /utils: utility functions and helper methods

Create an AppProviders component to set up all the global providers for the app.

/src/AppProviders.tsx
import { QueryClientProvider, QueryClient } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";

const queryClient = new QueryClient();

const AppProviders = ({ children }: any) => (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen />
</QueryClientProvider>
);

export default AppProviders;
tip

If you experience a TypeScript error when writing jsx without import React from 'react', add the following to your tsconfig.json file:

{
"compilerOptions": {
...
"jsx": "react-jsx"
...
},
}
/src/components/Page.tsx
import { Box, Container } from "@mui/material";
import { useEffect } from "react";

interface PageProps {
title?: string;
children: any;
}

const Page = ({ title, children }: PageProps) => {
useEffect(() => {
window.scrollTo(0, 0);
}, []);

return <Container sx={{ py: 8 }}>{children}</Container>;
};

export default Page;

Under /pages, create a HomePage component.

/src/pages/HomePage.tsx
import { Typography, Box } from "@mui/material";
import Page from "../components/Page";

const HomePage = () => (
<Page>
<Box sx={{ my: 4 }}>
<Typography variant="h2" align="center" gutterBottom>
THE EGG drop
</Typography>
</Box>
</Page>
);

export default HomePage;

Add both AppProviders and a route to the HomePage in App.

/src/App.tsx
import * as React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import AppProviders from "./AppProviders";
import HomePage from "./pages/HomePage";

export default function App() {
return (
<AppProviders>
<Router basename={process.env.PUBLIC_URL}>
<Routes>
<Route key="home" path="/" element={<HomePage />} />
</Routes>
</Router>
</AppProviders>
);
}

At this stage, the app should be up and running with the title THE EGG drop.

Display items

Define some types and constants to pass around in the app, and utility functions to initiate new API instances.

/src/utils/types.ts
import { BigNumberish, BytesLike } from "ethers";

export interface Item {
id: string;
attributes: any;
token_contract: TokenContract;
token_id: string;
collection_id: string;
listing: Listing;
}

export type Token = {
kind: BigNumberish;
token: string;
id: BigNumberish;
amount: BigNumberish;
};

export interface TokenContract {
address: string;
network_id: number;
symbol: string;
token_type: "ERC721" | "ERC1155";
}

export interface Listing {
data: SetPriceListingData;
type: "SET_PRICE";
}

export interface SetPriceListingData {
order: Order;
signature: BytesLike;
quantity_listed: number;
quantity_remaining: number;
}

export interface Order {
expiry: BigNumberish;
nonce: BigNumberish;
makerAddress: string;
takerAddress: string;
makerToken: Token;
takerToken: Token;
payoutTo: string[];
payoutAmount: BigNumberish[];
}

export interface ERC20Token {
address: string;
decimals: number;
symbol: string;
}
/src/utils/constants.ts
export const TRANSACTION_SUCCESS = {
type: "success",
message:
"Your transaction has succeeded. The UI may take a few moments to reflect these changes.",
autoDismiss: 10000,
};
/src/utils/api.ts
import axios from "axios";

const createApiInstance = (options = {}) => {
return axios.create({
baseURL: process.env.REACT_APP_API_BASE_URL,
headers: {
"Content-Type": "application/json",
},
});
};

const apiInstance = createApiInstance();

export default apiInstance;

Create a file called itemService to retrieve all NFTs minted from the ConsenSys NFT public endpoints.

/src/services/itemService.ts
import { useQuery } from "react-query";
import { collectionId, organizationId } from "../config";
import apiInstance from "../utils/api";

const fetchItems = async (organizationId: string, collectionId: string) => {
let searchParams = new URLSearchParams();
searchParams.set("collection_id", collectionId);

const { data } = await apiInstance.get(
`/v2/public/${organizationId}/items?${searchParams}`,
);
return data;
};

export const useItems = () => {
const { isError, isSuccess, data } = useQuery(
["items", organizationId, collectionId],
() => {
if (!organizationId || !collectionId) {
return Error(
"Please set organizationId and collectionId in the .env.local file",
);
}

return fetchItems(organizationId, collectionId);
},
);

return {
items: Boolean(data) ? data.items : [],
isError,
isSuccess,
};
};

Call this itemService in a new component named ItemsList.

/src/components/ItemsList.tsx
import { Alert, Grid } from "@mui/material";
import { Link } from "react-router-dom";
import { useItems } from "../services/itemService";
import { Item } from "../utils/types";

const ItemsList = () => {
const { items, isError, isSuccess } = useItems();

return (
<>
{isError && <Alert severity="error">Error loading items.</Alert>}
{isSuccess && (
<Grid
container
spacing={8}
justifyContent="space-between"
alignItems="center">
{(items || []).length === 0 && (
<Alert severity="info">No items to display.</Alert>
)}
{(items || []).map((item: Item) => (
<Grid key={item.id} item sm={4} xs={12}>
Item
</Grid>
))}
</Grid>
)}
</>
);
};

export default ItemsList;

Add ItemsList to the HomePage, and if the query is successful, three Items display in the center of the page.

/src/pages/HomePage.tsx

import ItemsList from "../components/ItemsList";
...
...
<Box sx={{ my: 4 }}>
<Typography variant="h2" align="center" gutterBottom>
THE EGG drop
</Typography>
<ItemsList />
</Box>
...

tutorial-1-homepage

To replace Items with the real thing, you need an ItemPreview component. For now, the ItemPreview can simply be a card displaying the Image, Title, and Price of the NFT.

You must format the token price in the correct decimals. In Ethereum smart contracts, the value of ether is always represented internally in Wei, which is the smallest denomination of ether.

1 ETH = 1000000000000000000 wei = 10^18 Wei

As such,

1250000000000000000 Wei = 1.25 ETH

8000000000000000 Wei = 0.008 ETH

Create a utility method to correctly display the ETH value that is given in Wei.

src/utils/market.ts
import { BigNumber, BigNumberish, logger } from "ethers";
import { PaymentToken } from "../services/tokenService";
import { ERC20Token, Order } from "./types";

let zeros = "0";
while (zeros.length < 256) {
zeros += zeros;
}

export const formatPrice = (
order: Order,
token?: PaymentToken | ERC20Token,
quantity: number = 1,
) => {
if (order) {
return `${formatUnits(
BigNumber.from(order.takerToken.amount).mul(quantity),
token?.decimals,
)} ${token?.symbol}`;
}
};

export function formatUnits(
value: BigNumberish,
decimals: BigNumberish = 18,
): string {
const multiplier = getMultiplier(decimals);

// Make sure wei is a big number (convert as necessary)
value = BigNumber.from(value);

let fraction = value.mod(multiplier).toString();

while (fraction.length < multiplier.length - 1) {
fraction = "0" + fraction;
}

// Strip trailing 0
fraction = fraction.match(/^([0-9]*[1-9]|0)(0*)/)![1];

const whole = value.div(multiplier).toString();
if (multiplier.length === 1) {
value = whole;
} else {
value = whole + "." + fraction;
}

return value;
}

function getMultiplier(decimals: BigNumberish): string {
if (
typeof decimals === "number" &&
decimals >= 0 &&
decimals <= 256 &&
!(decimals % 1)
) {
return "1" + zeros.substring(0, decimals);
}

return logger.throwArgumentError(
"invalid decimal size",
"decimals",
decimals,
);
}

With this utility function, you can now display price in ItemPreview. To know which payment token the Item is listed in (ETH, WETH, or MATIC), you must get the token address from listing info, search from the database among the payment tokens supported from the network, and return the token. You can do this in the tokenService file.

/src/services/tokenService.ts
import { useQuery } from "react-query";
import apiInstance from "../utils/api";
import { ERC20Token } from "../utils/types";

export interface PaymentToken extends ERC20Token {
network_id: number;
}

export const getPaymentTokenForNetwork = async (
networkId: number,
): Promise<PaymentToken[]> => {
const { data } = await apiInstance.get(
`/v2/public/tokens/payment-tokens?network_id=${networkId}`,
);
return data;
};

export const usePaymentToken = (networkId: number) => {
const { status, data } = useQuery(["payment-tokens", networkId], () => {
return getPaymentTokenForNetwork(networkId);
});

return {
tokens: data || [],
status,
};
};

Now with both price and currency known, display both in ItemPreview:

/src/components/ItemPreview.tsx
import {
Card,
CardActionArea,
CardContent,
CardMedia,
Typography,
} from "@mui/material";
import { Item } from "../utils/types";
import placeholderImg from "../img/placeholderImg.png";
import { usePaymentToken } from "../services/tokenService";
import { formatPrice } from "../utils/market";

interface ItemPreviewProps {
item: Item;
}

const ItemPreview = ({ item }: ItemPreviewProps) => {
const { attributes, token_contract, listing } = item;

const { tokens: paymentTokens } = usePaymentToken(token_contract.network_id);

let price = "SOLD";

if (listing) {
const paymentToken = paymentTokens.find(
(t) => t.address === listing.data.order.takerToken.token,
);

price = formatPrice(listing.data.order, paymentToken)!;
}

return (
<Card sx={{ my: 8, height: 600 }}>
<CardActionArea sx={{ height: 1 }}>
<CardMedia
component="img"
image={attributes.image_url || placeholderImg}
alt={attributes.title}
sx={{ height: 300 }}
/>

<CardContent>
<Typography
gutterBottom
variant="caption"
component="div"
align="center">
{attributes.title}
</Typography>
<Typography
gutterBottom
variant="subtitle2"
component="div"
align="center">
{price}
</Typography>
</CardContent>
</CardActionArea>
</Card>
);
};

export default ItemPreview;

Add the ItemPreview component to ItemsList.

/src/components/ItemsList.tsx
    ...
import ItemPreview from "./ItemPreview";
...
...

<Grid key={item.id} item sm={4} xs={12}>
<ItemPreview item={item} />
</Grid>

...

tutorial-1-homepage-display-item

Create ItemPage

You want to be able to select any of the cards and see more detailed information of the NFTs, such as their Attributes, Token ID, and Token Address.

To do so, first create an ItemPage and a route in the App so that React Router knows where to turn to when the path matches.

/src/pages/ItemPage.tsx
import { Typography, Box } from "@mui/material";
import Page from "../components/Page";

const ItemPage = () => (
<Page>
<Box sx={{ my: 4 }}>
<Typography variant="h2" align="center" gutterBottom>
EGG Details
</Typography>
</Box>
</Page>
);

export default ItemPage;
    ...
<Route key="home" path="/" element={<HomePage />} />
<Route key="item" path={`/items/:itemId`} element={<ItemPage />} />
...

The ItemPage must dynamically know which exact Item to render and then pass it down to its children. It must understand the itemId as the path parameter that was passed in, and use it to query the item from the backend.

Create two additional utility methods for the app to extract the item from the ConsenSys NFT endpoints using itemId.

/src.services.itemService.ts
    ...
const fetchItem = async (itemId: string) => {
const { data } = await apiInstance.get(`/v2/public/items/${itemId}`);
return data;
};

export const useItem = (itemId: string) => {
const { isError, isSuccess, data } = useQuery(["item", itemId], () => fetchItem(itemId));

return {
item: Boolean(data) ? data : null,
isError,
isSuccess,
};
...

With those, modify the ItemPage to:

/src/pages/ItemPage.tsx
import { Typography, Box, Alert } from "@mui/material";
import { useParams } from "react-router-dom";
import { useItem } from "../services/itemService";
import Page from "../components/Page";

const ItemPage = () => {
const { itemId } = useParams();
const { item, isError, isSuccess } = useItem(itemId!);

return (
<>
{isError && <Alert severity="error">Error loading items.</Alert>}
{isSuccess && (
<Container sx={{ py: 24 }}>
<Box sx={{ my: 4 }}>
<Typography variant="h2" align="center" gutterBottom>
{item.attributes.title}
</Typography>
</Box>
</Container>
)}
</>
);
};

export default ItemPage;

Don't forget to wrap ItemPreview with the link to its destination.

/src/components/ItemsList.tsx
import { Alert, Grid, Link } from "@mui/material";
...
...
<Link href={`/items/${item.id}`} underline="none">
<ItemPreview item={item} />
</Link>
...

Selecting the cards should take you to the correct corresponding page.

Display attributes

Add the attributes and token information to display in the ItemDetails component.

/src/components/ItemDetails.tsx
import { Card, CardContent, Grid, Typography, Box } from "@mui/material";
import { Item } from "../utils/types";
import isNil from "lodash/isNil";
import pickBy from "lodash/pickBy";
import EthAddress from "./EthAddress";
import { isEqual } from "lodash";
import ItemPreview from "./ItemPreview";

interface ItemDetailsProps {
item: Item;
}

const INCLUDED_KEYS = [
"Color",
"Pattern",
"Rarity",
"Transparency",
"address",
"network_id",
"symbol",
"token_type",
];

const ItemDetails = ({ item }: ItemDetailsProps) => {
const { attributes, token_contract } = item;

const customAttributes = pickBy(
attributes,
(val, key) => INCLUDED_KEYS.includes(key) && !isNil(val) && val !== "",
);

const customTokenInfo: any = pickBy(
token_contract,
(val, key) => INCLUDED_KEYS.includes(key) && !isNil(val) && val !== "",
);

return (
<Grid container spacing={4} justifyContent="space-between">
<Grid key="image" item sm={4} xs={12}>
<ItemPreview item={item} />
</Grid>
<Grid key="attributes" item sm={4} xs={12}>
<Card sx={{ my: 8, height: 600 }}>
<CardContent sx={{ p: 4, height: 1 }}>
{Object.keys(customAttributes).map((k) => (
<Box key={k}>
<Box sx={{ width: 1 }}>
<Typography variant="overline">{k}</Typography>
</Box>
<Box sx={{ width: 1 }}>
<Typography variant="h4">{customAttributes[k]}</Typography>
</Box>
</Box>
))}
</CardContent>
</Card>
</Grid>
<Grid key="contract-information" item sm={4} xs={12}>
<Card sx={{ my: 8, height: 600 }}>
<CardContent sx={{ p: 4, height: 1 }}>
{Object.keys(customTokenInfo).map((k) => (
<Box key={k}>
<Box sx={{ width: 1 }}>
<Typography variant="overline">{k}</Typography>
</Box>
<Box sx={{ width: 1 }}>
{isEqual(k, "address") ? (
<EthAddress
address={customTokenInfo.address}
networkId={token_contract.network_id}
/>
) : (
<Typography variant="h4">{customTokenInfo[k]}</Typography>
)}
</Box>
</Box>
))}
</CardContent>
</Card>
</Grid>
</Grid>
);
};

export default ItemDetails;

Note that you've used an EthAddress component to crop the long hexadecimal Ethereum address into a desired shape. It also links the address to a blockchain explorer depending on the network ID of the contract. In this case, selecting the EthAddress component takes you to Etherscan for Rinkeby.

/src/components/EthAddress
import { Box } from "@mui/material";
import { useMemo } from "react";
import theme from "../theme";

interface EthAddressProps {
address: string;
networkId: number;
full?: boolean;
}

const EthAddress = ({
address = "",
networkId = 1,
full = false,
}: EthAddressProps) => {
const displayedAddress = useMemo(() => {
if (full || !address) return address;
return `${address.slice(0, 2 + 4)}...${address.slice(0 - 4)}`;
}, [address, full]);

const openExplorer = () => {
let url = "";
switch (networkId) {
default:
url = "https://etherscan.io/address/" + address;
break;
case 4:
url = "https://rinkeby.etherscan.io/address/" + address;
break;
case 137:
url = "https://polygonscan.com/address/" + address;
break;
}
if (url) {
window.open(url);
}
};

return (
<Box
sx={{
fontSize: "2rem",
fontWeight: "400",
overflow: "hidden",
cursor: "pointer",
"&:hover": {
color: theme.palette.secondary.main,
},
}}
onClick={openExplorer}>
{displayedAddress}
</Box>
);
};

export default EthAddress;

Add the ItemDetails component to ItemPage.

/src/pages/ItemPage.tsx
import ItemDetails from "../components/ItemDetails";
...
...
{isSuccess && (
<Page>
<Box sx={{ my: 4 }}>
<Typography variant="h2" align="center" gutterBottom>
<ItemDetails item={item} />
</Typography>
</Box>
</Page>
)}

tutorial-1-itempage

Until now, you've created a normal functioning web2 app.