Create an NFT Minting dApp with Cudos NFT Native Module

Create an NFT Minting dApp with Cudos NFT Native Module

NFT Minting is one of the most common applications of blockchain technology and the application of NFTs are almost limitless, so it is a good idea to look at how we can create NFTs on the Cudos blockchain. To do this, we will build a webapp that interacts with the Cudos-node’s NFT module using the Cudosjs.

In this tutorial, you will learn how to use the CudosJs library to create a web app that integrates with the Cudos network and the knowledge you gain here can be applied to build different types of decentralised applications on Cudos.

What you will learn from this tutorial are:

  • Features of the Cudos-Node’s NFT module

  • How to set up a React TS app with Keplr and Cudos-js

  • how to setup and interact with keplr extension to sign transactions

  • Querying and executing transactions on Cudos testnet using cudosjs and Keplr

  • Possible ways to extend the project

This GitHub repository contains the finished project…

The tooling used are:

Cudos-node we will be interacting with the Cudos testnet in this tutorial, you can find the current rpc URLs here. You may also choose to run your own local Cudos-Node.

React is the front-end framework we will use.

Cudosjs this is the library with which we interact with the Cudos network.

00A What is Cudos Network?

Cudos is a network that seeks to unite cloud and blockchain by enabling the use of spare computing. Cudo Compute platform is powered by smartcontracts that allow you to easily deploy virtual machines on a decentralised cloud platform at a fraction of the cost of traditional cloud platforms.

Transactions on Cudos network are low-cost which makes it a great network to build your dApps on.

00B Features of the Cudos-Node’s NFT module

First let us take a look at the Cudos-node’s NFT module, here is the link to the repo on Github.

There are 2 concepts/terms that are important to understand in the context of the NFT module :

  1. Denom: a denom is a collection of NFTs, you can create a denom by executing an issue transaction. All NFTs belong to a denom.

  2. NFTs: this is the NFT itself and as I said earlier an NFT belongs to a denom, hence only the denom creator can mint a new NFT that is associated with a particular denom. When minting an NFT the owner can be set to an address other than the denom creator’s address.

The NFT module has the following features:

  • Executable functions that allow us to:

    • create NFT collections known as denom,

    • mint NFTs in a denom,

    • set and transfer denom and NFT ownership,

    • grant or revoke addresses permissions to transfer an NFT,

    • edit NFT data, and lastly burn NFT.

  • Query functions that allow us to query NFT and Denom data such as:

    • querying denom by id, name, or symbol,

    • query all the denoms/collections in a network,

    • query specific or all the NFTs in a collection,

    • query the total supply of (i.e. number of tokens in) a collection,

    • query the denoms and NFTs owned by an address

You can learn more about the NFT module by looking at the code on Github.

Step 01 Set up the react project and install Cudosjs

We use Vite to scaffold our React Typescript project, Vite requires Node.js 14.18 or higher.

Go to the directory you want your project located in and run the command npm create vite@latest.

Initialising your react project with Vite

You will be prompted to provide: Project name, Package name, framework, and variant. Enter the names of your choice and use your arrow keys to select the framework and variant. The framework and variant in this tutorial are React and Typescript respectively.

File Structure of our newly initialised project

You will then need to run the command npm install to install all the libraries that are part of the project scaffolding process.

Opening the project root in VS code, or another editor of choice, we will need to install more dependencies we need in our project.

Using npm (or other package manager of choice) you can install all the dependencies at once with the following command :

npm install cudosjs react-router-dom react-hook-form zod notistack @mui/material @mui/lab @emotion/react @emotion/styled @fontsource/poppins @hookform/resolvers @keplr-wallet/types @mui/icons-material @esbuild-plugins/node-globals-polyfill

Or you can install them individually as listed below

commanddependencyfunction
npm i cudosjshttps://www.npmjs.com/package/cudosjsA JavasSript Open Source Library for https://cudos.org/
network
npm i react-router-domhttps://www.npmjs.com/package/react-router-domIt contains bindings for the routing library for React
npm i react-hook-formhttps://www.npmjs.com/package/react-hook-formPerformant, flexible and extensible forms with easy to use validation.
npm i zodhttps://www.npmjs.com/package/zodA type declaration and validation library. We use it to declare and validate the data type of the forms used in NFT minting.
npm i @hookform/resolvershttps://github.com/react-hook-form/resolversallows you to use an external validation library with React hook form
npm i @mui/material @mui/lab @emotion/react @emotion/styledhttps://mui.com/material-ui/getting-started/overview/ for component stylingMaterial UI is an open-source React component library that implements Google's https://m2.material.io/
.
npm i notistackhttps://www.npmjs.com/package/notistackFor snack bar notification
npm i @fontsource/poppinsPoppins fontPoppins font via Fontsource
npm i @keplr-wallet/types
npm install @esbuild-plugins/node-globals-polyfillTo make the Node module Buffer available in our project

After installing all the dependencies, start the development server to verify everything works. Do this by running the command npm run dev which usually makes the project live on http://localhost:5173/ . When you open this in your browser, you'll see it displayed like this:

boilerplate react-vite project browser output

Upon completing this step, your project should be similar to what can be found here Step 1 branch.

Step 02 Adapting and removing boilerplate code

In this step, we will remove some boilerplate code, update the vite.config.ts file and adapt the project to use Material UI components.

We will make a change and an addition to the index.html file.

Please note that the use of ellipsis (...) in code snippets in this tutorial signifies that some parts of the code in your code editor that is omitted for brevity.

// index.html    
...
    <title>Reified Token</title> //change 
    ...
    <script type="module" src="/src/main.tsx"></script>
    <script> var global = global || window; </script> //addition  
  </body>
</html>

First, change the title of the app to Reified Tokens. Then add

<script> var global = global || window; </script> below the first script tag, this creates a global variable and assigns it to window, if it exists. The aim of this is to ensure that the code works well in the browser, especially when we need to access the browser extensions.

You will then delete the content of the App.css, and paste this

/* src/App.css */
#root {
  width: -webkit-fill-available;
}

The code above sets the width of the root element of our React app to the maximum available width.

Delete the CSS rules in index.css, except that of the body, such that the index.css file is as below.

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

Material UI modifications

Remember that we have installed Material UI and Icons during project setup. We want to customise the theme for the application. To do this, you will create a theme.ts file in the src folder adding the code below into it.

// src/theme.ts

import { createTheme } from "@mui/material/styles";

// A custom theme for this app
const theme = createTheme({
  palette: {
    primary: {
      main: "#52A6F8",
      light: "#8bd6ff",
      dark: "#0077c4",
    },
    secondary: {
      main: "#2A4064",
      light: "#576b92",
      dark: "#001a39",
    },
    background: {
      default: "#1C2030",
      paper: "#576b92",
    },
    text: {
      primary: "#000",
      secondary: "#fff",
    },
  },
  typography: {
    fontFamily: ["Poppins", "sans-serif"].join(","),
    fontSize: 14,
  },
});

export default theme;

With the createTheme function of the Material UI library, we have created a custom theme system of color palette and typography for our app.

Secondly, remove the boilerplate code in the App.tsx file and update it to reflect the code below.

import React from "react";
import "./App.css";

import { ThemeProvider } from "@mui/material/styles";
import { Box, CssBaseline, Typography } from "@mui/material";
import "@fontsource/poppins";
import theme from "./theme";

function App() {
  return (
    <Box sx={{ maxWidth: "100%" }}>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Box>
          <Typography color="text.secondary">Hello...</Typography>
        </Box>
      </ThemeProvider>
    </Box>
  );
}

export default App;

This file is the entry point for our app. We import the ThemeProvider passing in the theme we previously defined in the theme.ts file. The CssBaseline component applies a baseline CSS to the application, ensuring consistent styling across different browsers. We also import the font family and display a simple Hello… world using some material UI components.

Adding Router and Notistack

Modify the main.tsx file to:

// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { SnackbarProvider } from "notistack";
import { BrowserRouter as Router } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <Router>
    <SnackbarProvider maxSnack={2} preventDuplicate={true}>
      <App />
    </SnackbarProvider>
  </Router>
);

Note that we wrap the App in the SnackbarProvider, this provides a global context for displaying snackbars used for notifications in the app, and we set the maximum number of snackbars displayed simultaneously as 2 with the maxSnack prop.

To enable routing in our web app, we import BrowserRouter from the react-router-dom library and make it accessible as Router. The App and its container SnackbarProvider are both wrapped with Router component, which provides client-side routing functionality to our application.

Now we need to add some configurations to the vite.config.ts file

  1. Add import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill' to the imports in the vite.config.ts file

  2. Add the following config to the config definition

  ...
    optimizeDeps: {
          esbuildOptions: {
              define: {
                  global: 'globalThis'
              },
              plugins: [
                  NodeGlobalsPolyfillPlugin({
                      buffer: true
                  })
              ]
          }
      }
  })

The file will look like this

After following the above instructions, running npm run dev should display as below in the browser.

Image description

Upon completing this step, your project should be similar to what can be found here Step 02 branch.

We will first need to create a type declaration file named global.d.ts. Inside the src folder, add a new folder named types, creating the global.d.ts inside the newly created types folder.

  import { Window as KeplrWindow } from "@keplr-wallet/types";

  export {};

  declare global {
    // type Override<T1, T2> = Omit<T1, keyof T2> & T2
    interface Window extends KeplrWindow {
    }

  }

We are extending the global browser window interface to include the KeplrWindow interface provided by the @keplr-wallet/types interface. This is our way of ensuring we can access the functions provided with the KeplrWallet extension through the global window object. You will find an example of this when we call on window.keplr.enable() later in this section.

Creating .env file to store our variables

Create a .env file in the project root, and paste the following variables in it.

VITE_NETWORK='LOCAL' || 'TESTNET'
VITE_GAS_PRICE ="5000000000000acudos"

# TESTNET
VITE_APP_RPC='https://rpc.testnet.cudos.org:443'
VITE_APP_API='https://sentry1.gcp-uscentral1.cudos.org:31317'
VITE_APP_CHAIN_ID ='cudos-testnet-public-3'
VITE_APP_ADDRESS_PREFIX = 'cudos'

The environmental variables and their usage are explained in the table below. Please note that changing any of the variables in the .env file while the app is running, the app must be stopped by running ctrl + C and then built again with npm run dev before the changes take effect.

The variables are made available to the application code via import.meta.env<variable-name>. For example, the Cudos testnet RPC endpoint is accessed in the code by referencing import.meta.env.VITE_APP_RP

VariableDescrition
VITE_APP_RPCThe Cudos public testnet RPC endpointhttps://docs.cudos.org/docs/build/overview/connect-network#01-connect-to-testnet-rpc
VITE_APP_APIThe cudos public testnet HTTP endpoint
VITE_APP_CHAIN_IDThe chain id
VITE_APP_ADDRESS_PREFIXThe network prefix, used in configuring Keplr wallet

KeplrLedger.ts File

We are now going to create KeplrLedger file which will contain utility functions for signing the blockchain transactions, using Keplr Wallet browser extension. Below is the code.

//src/ledgers/KeplrLedger.ts

import { GasPrice, SigningStargateClient } from "cudosjs";

export type AccountDetails = { address: string; username: string };

export const keplrSigningClient = async (): Promise<SigningStargateClient> => {
  if (!window.keplr || !window.keplr.getOfflineSignerAuto) {
    throw new Error("Keplr extension not installed");
  }
  await window.keplr.enable(import.meta.env.VITE_APP_CHAIN_ID).catch((err) => {
    console.error(err);
    throw new Error("Keplr can't connect to this chainId!");
  });
  const offlineSigner = await window.keplr.getOfflineSignerAuto(
    import.meta.env.VITE_APP_CHAIN_ID
  );
  const client = await SigningStargateClient.connectWithSigner(
    import.meta.env.VITE_APP_RPC,
    offlineSigner,
    {
      prefix: "cudos",
      gasPrice: GasPrice.fromString("5000000000000acudos"),
    }
  );
  return client;
};

export const getConnectedAccount = async (): Promise<AccountDetails> => {
  const key = await window.keplr!.getKey(import.meta.env.VITE_APP_CHAIN_ID);
  return { address: key.bech32Address, username: key.name };
};

The function keplrSigningClient returns a promise of SigningStargateClient, of the CudosJs library, which we use to sign and broadcast messages to the Cudos network.

if (!window.keplr || !window.keplr.getOfflineSignerAuto) {
    throw new Error("Keplr extension not installed");
}

We first confirm that the Keplr extension is installed in the browser, otherwise we throw an error.

await window.keplr.enable(import.meta.env.VITE_APP_CHAIN_ID).catch((err) => {
    console.error(err);
    throw new Error("Keplr can't connect to this chainId!");
  });

Next, we make an async call on keplr’s the enable method to request the user's permission to authorise our dapp to access the cudos chain on Keplr wallet. The enable() method also requests that the user unlocks the wallet if it is locked and it throws an error if the user cancels the unlock or rejects the permission.

const offlineSigner = await window.keplr.getOfflineSignerAuto(
    import.meta.env.VITE_APP_CHAIN_ID
  );

We then pass in the cudos chain id to keplr’s getOfflineSignerAuto() method.

const client = await SigningStargateClient.connectWithSigner(
    import.meta.env.VITE_APP_RPC,
    offlineSigner,
    {
      prefix: "cudos",
      gasPrice: GasPrice.fromString("5000000000000acudos"),
    }
  );
  return client;

With the snippet above, we instantiate a SigningstargateClient object, by calling on the SigningStargate.connectWithSigner() function from the CudosJs library. We pass in parameters such as rpc node endpoint, offlineSigner and an object containing optional parameters prefix and gasPrice, other acceptable options are broadcastTimeoutMs and lots more.

The returned client is an instance that is connected to the Cudos rpc endpoint to broadcast and view transaction history on Cudos. You may check your editor’s intellisense to see the possible methods/functions available to us. Upon completing this step, your project should be similar to what can be found here.

STEP 04 Composing Queries and Transactions

In this step, we are going to create classes/functions related to the nft minting. In the same ledger directory, create a file named NftClient.ts which will contain the code below:

Your code editor may point out an error with some of the imports but we will create the files later.

//ledger/NftClient.ts
import {
    Account,
    assertIsDeliverTxSuccess,
    GasPrice,
    StargateClient,
    generateMsg,
  } from "cudosjs";

  import {
    QueryCollectionResponse,
    QueryDenomByNameResponse,
    QueryDenomBySymbolResponse,
    QueryDenomResponse,
    QueryDenomsResponse,
    QueryNFTResponse,
    QuerySupplyResponse,
  } from "cudosjs/build/stargate/modules/nft/proto-types/query";
  import { IssueMessage, MintMessage } from "../types/nft";
  import { getConnectedAccount, keplrSigningClient } from "./KeplrLedger";

  const queryClient = await StargateClient.connect(import.meta.env.VITE_APP_RPC);
  const cudosSigningStargateClient = await keplrSigningClient();

  export interface INftQueryClient {
    getAllDenoms: () => Promise<QueryDenomsResponse>;
    getDenom: (denomId: string) => Promise<QueryDenomResponse>;
    getToken: (denomId: string, tokenId: string) => Promise<QueryNFTResponse>;
    getAllTokensInCollection: (
      denomId: string
    ) => Promise<QueryCollectionResponse>;
    isValidAddress: (address: string) => Promise<Account | null>;
    getDenomByName: (denomName: string) => Promise<QueryDenomByNameResponse>;
    getDenomBySymbol: (symbol: string) => Promise<QueryDenomBySymbolResponse>;
    getNFTDenomSupply: (denomId: string) => Promise<QuerySupplyResponse>;
  }

  export class NftQueryClient implements INftQueryClient {

    getAllDenoms() {
      return queryClient.nftModule.getNftDenoms();
    }
    getDenom(denomId: string) {
      return queryClient.nftModule.getNftDenom(denomId);
    }
    getToken(denomId: string, tokenId: string) {
      return queryClient.nftModule.getNftToken(denomId, tokenId);
    }
    getAllTokensInCollection(denomId: string) {
      return queryClient.nftModule.getNftCollection(denomId);
    }
    isValidAddress(address: string) {
      return queryClient.getAccount(address);
    }

    getDenomByName(denomName: string) {
      return queryClient.nftModule.getNftDenomByName(denomName);
    }
    getDenomBySymbol(symbol: string) {
      return queryClient.nftModule.getNftDenomBySymbol(symbol);
    }
    getNFTDenomSupply(denomId: string) {
      return queryClient.nftModule.getNftDenomSupply(denomId);
    }
  }

  export interface INftClient {
    issueDenom: (denomMessage: IssueMessage) => Promise<void>;
    mintNFT: (mintMessage: MintMessage) => Promise<void>;
  }

  export class NftClient implements INftClient {
    client = cudosSigningStargateClient;

    async issueDenom(denomMessage: IssueMessage) {
      const account = await getConnectedAccount();
      const denomMsg = generateMsg("msgIssueDenom", denomMessage);

      const result = await this.client.signAndBroadcast(
        account.address,
        [denomMsg],
        "auto"
      );
      assertIsDeliverTxSuccess(await result);
    }

    async mintNFT(mintMessage: MintMessage) {
      const account = await getConnectedAccount();
      const result = await this.client.nftMintToken(
        account.address,
        mintMessage.denomId,
        mintMessage.name,
        mintMessage.uri,
        mintMessage.data,
        mintMessage.recipient,
        GasPrice.fromString(import.meta.env.VITE_GAS_PRICE)
      );

      assertIsDeliverTxSuccess(result);
    }

    disconnect() {
      this.client.disconnect();
    }
  }

For the purpose of this tutorial, we will create two classes, one for query, and the other for executing transactions.

//ledger/NftClient.ts
... 
const queryClient = await StargateClient.connect(import.meta.env.VITE_APP_RPC);
const cudosSigningStargateClient = await keplrSigningClient();
...

Now, let us have a look at the code you pasted earlier. First, we initialise the connection to the cudos network. Then, we declare the interface of the classes, describing the methods that will be implemented for each class: INftQueryClient (for queries) and INftClient (for writing onto the blockchain). NftQueryClient class’ methods include getDenom, getAllDenom, getToken, etc. These methods call their corresponding methods in the queryClient instance of the StargateClient class.

Unlike, the SigningStargate client we talked about in Step 03, the stargateClient is a read-only client. You can check the editor’s intellisense to see the possible methods/functions available to us. Link to the sourcecode of the cudos Stargate class from cudosjs.

The NftClient class implements the INftClient interface and defines methods to issue and mint NFTs. These methods include issueDenom and mintNFT. These methods use the cudosSigningStargateClient instance of the keplrSigningClient function to sign and broadcast transactions to the Cudos network.

We would explore 2 approaches to sending (signed) transactions to the network:

  1. Using the module-specific methods available via the client, as used in the mintNFT(). With this approach, we access the CudosSigningStargateClient ’s methods available in the Cudosjs library, in this case, this.client.nftMintToken().

This is the method signature of nftMintToken()

Let us take a look at the corresponding snippet.

nftMintToken(sender: string,
                         denomId: string, 
                         name: string, 
                         uri: string, 
                         data: string, 
                         recipient: string, 
                         gasPrice: GasPrice, 
                         memo?: string, 
                         gasMultiplier?: number)
: Promise<DeliverTxResponse>;

The async method has parameters such as transactionsender address, token name, uri pointing to a the location of token info, data string, recipient address (i.e. the address of the owner of the minted token), the gasPrice the price we are willing to pay for a single unit of gas, optional memo (string) that can be attached to the transaction, and an optional gasMultiplier.

import {...assertIsDeliverTxSuccess, GasPrice } from "cudosjs";
import { MintMessage... } from "../types/nft";
import { getConnectedAccount, keplrSigningClient } from "./KeplrLedger";
...
export class NftClient implements INftClient {
  client = cudosSigningStargateClient;
...
  async mintNFT(mintMessage: MintMessage) {
    const account = await getConnectedAccount();
    const result = await this.client.nftMintToken(
      account.address,
      mintMessage.denomId,
      mintMessage.name,
      mintMessage.uri,
      mintMessage.data,
      mintMessage.recipient,
      GasPrice.fromString(import.meta.env.VITE_GAS_PRICE)
    );

    assertIsDeliverTxSuccess(result);
}
...
...

mintNFT returns a promise of type DeliverTxResponse with this structure

export interface DeliverTxResponse {
    readonly height: number;
    /** Error code. The transaction suceeded iff code is 0. */
    readonly code: number;
    readonly transactionHash: string;
    readonly rawLog?: string;
    readonly data?: readonly MsgData[];
    readonly gasUsed: number;
    readonly gasWanted: number;
}

We can then call on assertIsDeliverTxSuccess() passing in the response. This asserts that the response is that of a successful transaction, otherwise it throws an error. If no error is thrown, the token must have been minted successfully.

  1. Usually, the CudosjsSigningStargateClient methods are enough. But, sometimes you may need to manually create and send messages in a transaction. This second approach to sending transactions to the blockchain network is by using the signing stargate client’s signAndBroadcast function. Let us have a look at the function signature.
signAndBroadcast(
    signerAddress: string,
    messages: readonly EncodeObject[], 
    fee: StdFee | "auto" | number, 
    memo?: string): Promise<DeliverTxResponse>;

It takes the signerAddress, an array of messages of type EncodeObject, the fee (gas fee and tokens attached to the transaction), and lastly, an optional memo which is a string sent along with the transaction. signAndBroadcast also returns a promise of type DeliverTxResponse.

CudosJS comes with a utility function called generateMsg to make it easier to create custom messages.

function generateMsg(msgName: string, params: object):EncodeObject

generateMsg’s parameters are : a string msgName and params, an object containing the message’s properties. The provided msgName must correspond to the ones exposed by our blockchain application interface, See https://github.com/CudoVentures/cudos-node on Github.

...
import {...generateMsg} from "cudosjs";
import { IssueMessage... } from "../types/nft";
import { getConnectedAccount, keplrSigningClient } from "./KeplrLedger";
...
export class NftClient implements INftClient {
  client = cudosSigningStargateClient;

  async issueDenom(denomMessage: IssueMessage) {
    const account = await getConnectedAccount();
    const denomMsg = generateMsg("msgIssueDenom", denomMessage);

    const result = await this.client.signAndBroadcast(
      account.address,
      [denomMsg],
      "auto"
    );
    assertIsDeliverTxSuccess(await result);
  }
...
...

Our issueDenom method accepts the parameter denomMessage object. The object is then passed into the generateMsg function, as the second parameter. We then await the call to signAndBroadcast(), passing its response to the assertIsDeliverTxSuccess .

Now let us resolve the issue with the type imports.

The types/nft.ts file

Create the nft.ts file in the types directory and paste the following code for interface declaration.

export interface Denom {
  id: string;
  name: string;
  schema: string;
  creator: string;
  symbol: string;
}
export interface Nft {
  id: string;
  name: string;
  uri?: string;
  data?: string;
  owner: string;
  approvedAddresses?: string[];
}

export interface Collection {
  denom: Denom;
  nfts?: Nft[];
}

export interface AllTokenResponse {
  collection: Collection;
  pagination?: Pagination;
}

export interface Pagination {
  total: {
    low: number;
    high: number;
    unsigned: boolean;
  };
  nextkey: object;
}

export interface IssueMessage {
  id: string;
  name: string;
  symbol: string;
  from: string;
  schema: string;
  chainId: string;
  sender: string;
}

export interface MintMessage {
  denomId: string;
  name: string;
  uri: string;
  data: string;
  from: string;
  recipient: string;
  chainId: string;
}

This file contains the definition of the interfaces relating to our project. We imported some of the interfaces in this step, we would use the rest in the coming steps. Upon completing this step, your project should be similar to what can be found here.

Step 05 Creating the components for Minting NFTs

In this and the following steps, we are going to create the components needed for our dApp UI. Now create a folder named components inside the src directory wherein we would put our components.

Remember, we are using the Material UI component library.

Here we are going to create the various components used in the NFT minting process. They are FormInput, DenomForm, NftForm, and Mint that is the parent component comprising the other ones. The minting process may take either of these 2 forms:

  • A user can start by creating a new NFT denom, and minting one or more nfts belonging to the denom.

  • A user can mint more NFTs when viewing a list of NFTs belonging to an already existing denom.

FormInput

Create a MintForm directory in the components directory and add the FormInput.tsx file, with the below content.

import React, { FC } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { TextField, TextFieldProps } from "@mui/material";
import { styled } from "@mui/material/styles";

// Styled Material UI TextField Component
const CssTextField = styled(TextField)({
  "& label.Mui-focused": {
    color: "#5e5b5d",
    fontWeight: 400,
  },
  "& .MuiInputBase-input": {
    borderColor: "#c8d0d4",
  },
  "& .MuiInput-underline:after": {
    border: "none",
  },
  "& .MuiOutlinedInput-root": {
    "&.Mui-error": {
      "& .MuiOutlinedInput-notchedOutline": {
        borderColor: "#d32f2f",
      },
    },
    "& fieldset": {
      borderColor: "#c8d0d4",
      borderRadius: 0,
    },
    "&:hover fieldset": {
      border: "1px solid #c8d0d4",
    },
    "&.Mui-focused fieldset": {
      border: "1px solid #c8d0d4",
    },
  },
});

type FormInputProps = {
  name: string;
} & TextFieldProps;

const FormInput: FC<FormInputProps> = ({ name, ...otherProps }) => {
  const {
    control,
    formState: { errors },
  } = useFormContext();

  return (
    <Controller
      control={control}
      name={name}
      defaultValue=""
      render={({ field }) => (
        <CssTextField
          {...field}
          {...otherProps}
          variant="filled"
          sx={{ mb: "1.5rem" }}
          error={!!errors[name]}
          helperText={
            errors[name] ? (errors[name]?.message as unknown as string) : ""
          }
        />
      )}
    />
  );
};

export default FormInput;

The FormInput component is a reusable form input field that integrates with react-hook-form and provides styled Material-UI components to simplify validated form input creatiion. It handles form validation and error handling. It takes name and other props. The component uses react-hook-form’s useFormContext to access the form context and Controller to manage the field's value and registration. The error prop and helperText display validation errors if present.

For an in-depth explanation of how the components, react-hook-form and zod work please read this and this.

DenomForm

This component is used for the first step in Minting NFT collections. It contains the form for creating a denom. The form we will create will take denom details such as required DenomId, Name, Symbol, and an optional Description. Add a newfile named DenomForm.tsx into the MintForm folder.

import React, { useState } from "react";
import { Container, Box, Typography } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton"
import { useForm, SubmitHandler, FormProvider } from "react-hook-form";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import FormInput from "./FormInput";
import { IssueMessage } from "../../types/nft";
import { NftQueryClient } from "../../ledgers/NftClient";
import { AccountDetails } from "../../ledgers/KeplrLedger";
import { useSnackbar } from "notistack";

const nftQueryClient = new NftQueryClient();
const denomFormSchema = object({
  denomId: string()
    .min(4, "DenomId should be at least 4 alphanumeric character")
    .max(8, "DenomId should not be more than 8 characters").refine(
      async (val) => {
        try {
          const denom = await (await nftQueryClient.getDenom(val)).denom;
          return denom == null;
        } catch (e: any) {
          return e.message.includes("not found denom");
        }
      },
      { message: "DenomId already in use." }
    ),
  name: string()
    .min(4, "Name is required.")
    .max(20, "Name should not be more than 20 characters.")
    .refine(
      async (val) => {
        try {
          const denom = await (await nftQueryClient.getDenomByName(val)).denom;
          return denom == null;
        } catch (e: any) {
          return e.message.includes("not found denom");
        }
      },
      { message: "Name already in use" }
    ),
  symbol: string()
    .min(3, "Symbol must be at least 3 characters.")
    .max(6, "Symbol cannot be more than 6 characters.")
    .refine(
      async (val) => {
        try {
          const denom = await (
            await nftQueryClient.getDenomBySymbol(val)
          ).denom;
          return denom == null;
        } catch (e: any) {
          return e.message.includes("not found denom");
        }
      },
      { message: "Symbol already in use" }
    ),

  description: string().optional(),
});

type IDenom = TypeOf<typeof denomFormSchema>;

export type DenomFormProps = {
  createDenom: (denom: IssueMessage) => Promise<string | undefined>;
  account: AccountDetails | null;
  setDenomId: React.Dispatch<React.SetStateAction<string | undefined>>;
  setIsCreatingCollectionSucceed: React.Dispatch<React.SetStateAction<boolean>>;
  setNextStep: () => void;
};

export const DenomForm = ({
  createDenom,
  account,
  setDenomId: setDenomId,
  setIsCreatingCollectionSucceed,
  setNextStep,
}: DenomFormProps) => {
  const [isLoading, setIsLoading] = useState(false);
  const { enqueueSnackbar } = useSnackbar();

const defaultValues: IDenom = {
    denomId: "",
    name: "",
    symbol: "",
    description: "",
  };

  const denomFormMethods = useForm<IDenom>({
    resolver: zodResolver(denomFormSchema),
    defaultValues,
  });

  const onSubmitHandler: SubmitHandler<IDenom> = async (values: IDenom) => {
    if (account?.address) {
      const denom: IssueMessage = {
        id: values.denomId,
        name: values.name,
        symbol: values.symbol,
        from: account.address,
        schema: values.description ?? "",
        chainId: import.meta.env.VITE_APP_CHAIN_ID,
        sender: account.address,
      };
      try {
        const successReturnsDenom = await createDenom(denom);
        setDenomId(successReturnsDenom);
        setIsCreatingCollectionSucceed(true);
        enqueueSnackbar("Collection Created", { variant: "success" });
        setNextStep(); 
      } catch (error: any) {
        enqueueSnackbar(error.message, {
          variant: "error",
        });
      }
    } else {
      setIsCreatingCollectionSucceed(false);
      throw new Error("Please Connect your Keplr Wallet");
    }
  };

  return (
    <Container
      maxWidth={false}
      sx={{
        height: "100%",
        width: "fit-content",
        backgroundColor: { xs: "#fff", md: "#f4f4f4" },
      }}
    >
      {/* react hook form's FormProvider */}
      <FormProvider {...denomFormMethods}>
        <Box
          display="flex"
          flexDirection="column"
          component="form"
          noValidate
          autoComplete="off"
          sx={{ py: "6rem", px: "1rem", width: "50vw" }}
          onSubmit={(e) => {
            setIsLoading(true);
            denomFormMethods
              .handleSubmit(onSubmitHandler)(e)
              .catch((error: any) => {
                enqueueSnackbar(error.message, {
                  variant: "error",
                });
              })
              .finally(() => setIsLoading(false));
          }}
        >
          <Typography
            variant="h6"
            component="h1"
            sx={{ textAlign: "center", mb: "1.5rem" }}
          >
            Create Asset Collection
          </Typography>

          <FormInput
            label="DenomId"
            type="text"
            name="denomId"
            focused
            required
          />
          <FormInput label="Name" type="text" name="name" focused required />
          <FormInput
            type="text"
            label="Symbol"
            name="symbol"
            required
            focused
          />

          <FormInput
            type="text"
            label="Description"
            name="description"
            focused
          />

          <LoadingButton
            loading={isLoading}
            type="submit"
            variant="contained"
            sx={{
              py: "0.8rem",
              mt: 2,
              width: "80%",
              marginInline: "auto",
            }}
          >
            Create Collection
          </LoadingButton>
        </Box>
      </FormProvider>
    </Container>
  );
};

This is quite a long one! However, what goes on is easy to grasp. Firstly, we declare a constant variable, passing in to it the NftQueryClient we created in step 04 — we would use this to validate that the denomId, denom name, and denom symbol inputs are not already taken. The component uses Zod package to validate form input fields.

The Denom form validation schema is defined with ZodObject. We define simple validation rules like minimum and maximum characters. The interesting rule is where we used Zod’s refine method.

...
const denomFormSchema = object({
  denomId: string()
    .min(4, "DenomId should be at least 4 alphanumeric character")
    .max(8, "DenomId should not be more than 8 characters")
    .refine(
      async (val) => {
        try {
          const denom = await (await nftQueryClient.getDenom(val)).denom;
          return denom == null;
        } catch (e: any) {
          return e.message.includes("not found denom");
        }
      },
      { message: "DenomId already in use." }
    ),
  ...
  description: string().optional(),
});

The custom validation with zod checks if the provided denomId already exists by making a query to the nftQueryClient.getDenom(). If the denom name is already taken, a valid object is returned, we expect a null response for an available denomId. Hence the validation returns false for a denomId that is in use, and returns true for a denomId that is available. Similar logic is used for the name, and symbol validation. The defined schema

The DenomForm component takes the following props:

  • createDenom function that sends denom creation request to the blockchain network, it returns a promise containing the denomId string.

  • props.account - object containining the details of the connected account, or null if no account is connected.

  • setDenomId this function sets the DenomId property in the Mint component after a Denom has been successfully created.

  • setIsCreatingCollectionSucceed this function is used to inform the parent Mint component whether the denom was created successfully.

  • setNextStep this function sets the next step in the NFT minting process

Now let us have a look at what happens when a form is submitted.

const onSubmitHandler: SubmitHandler<IDenom> = async (values: IDenom) => {
    if (account?.address) {
      const denom: IssueMessage = {
        id: values.denomId,
        ... };
      try {
        const successReturnsDenom = await createDenom(denom);
        setDenomId(successReturnsDenom);
        setIsCreatingCollectionSucceed(true);
        enqueueSnackbar("Collection Created", { variant: "success" });
        setNextStep(); 
      } catch (error: any) {
           ...
    }
  };

When the form is submitted, it triggers the onSubmitHandler function, which creates the collection using the form data. If the creation is successful, it sets the state and also calls the function that sets the next step, and displays a success message. If there's an error during the creation process, an error message is displayed. The form also includes a loading button that indicates the submission is in progress.

NftForm

Also a child of the Mint component, this is involved in the 2nd step of the minting process. It is used to mint the actual NFTs in a collection/denom. In the MintForm directory, create a file named NftForm.tsx and paste the below code into it.

import React, { useState } from "react";
import { Container,  Box,  Typography,  FormControlLabel,  Checkbox} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import { useForm, SubmitHandler, FormProvider } from "react-hook-form";
import { boolean, object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import FormInput from "./FormInput";
import { MintMessage } from "../../types/nft";
import { AccountDetails } from "../../ledgers/KeplrLedger";
import { useSnackbar } from "notistack";

const nftFormSchema = object({
  name: string()
    .min(4, "Name is required")
    .max(20, "Name should not be more than 20 character"),
  uri: string().url("This must be a valid url"),
  data: string().optional(),
  mintForAnotherAddress: boolean().optional(),
  recipient: string(),
});
type INFT = TypeOf<typeof nftFormSchema>;

export type NftFormProps = {
  account: AccountDetails | null;
  mintNft: (mintMessage: MintMessage) => Promise<string | undefined>;
  denomId: string | undefined;
  setIsMintingNFTSucceed: any;
};

export const NftForm = ({
  mintNft,
  account,
  denomId,
  setIsMintingNFTSucceed,
}: NftFormProps) => {
  const [currentAccountIsRecipient, setCurrentAccountIsRecipient] =
    useState(true);
  const [isLoading, setIsLoading] = useState(false);
  const { enqueueSnackbar } = useSnackbar();

  const defaultValues: INFT = {
    name: "",
    recipient: account?.address ?? "",
    data: "",
    uri: "",
    mintForAnotherAddress: false,
  };

  const methods = useForm<INFT>({
    resolver: zodResolver(nftFormSchema),
    defaultValues,
  });

  const onSubmitHandler: SubmitHandler<INFT> = async (values: INFT) => {
    if (account?.address && denomId) {
      const mintMsg: MintMessage = {
        denomId: denomId,
        name: values.name,
        uri: values.uri,
        data: values.data ?? "",
        from: account.address,
        recipient: values.mintForAnotherAddress
          ? values.recipient
          : account.address,
        chainId: import.meta.env.VITE_APP_CHAIN_ID,
      };
      try {
        await mintNft(mintMsg);
        enqueueSnackbar("NFT Minted", {
          variant: "success",
        });
        setIsMintingNFTSucceed(true);
        methods.reset(defaultValues);
        handleNFTOwnerToggle();
      } catch (error: any) {
        enqueueSnackbar(error.message, {
          variant: "error",
        });
      }
    } else {
      throw new Error("Please Connect your Keplr Wallet");
    }
  };

  const handleNFTOwnerToggle = () => {
    setCurrentAccountIsRecipient(!currentAccountIsRecipient);
    if (currentAccountIsRecipient) {
      methods.setValue("recipient", "");
    } else {
      methods.setValue("recipient", "currentUser");
    }
  };

  return (
    <Container
      sx={{ height: "100%", backgroundColor: "text.secondary", width: "40em" }}
    >
      <FormProvider {...methods}>
        <Box
          display="flex"
          flexDirection="column"
          component="form"
          noValidate
          autoComplete="off"
          sx={{ py: "6rem", px: "1rem" }}
          onSubmit={(e) => {
            setIsLoading(true);
            methods
              .handleSubmit(onSubmitHandler)(e)
              .catch((error) => {
                enqueueSnackbar(error.message, {
                  variant: "error",
                });
              })
              .finally(() => setIsLoading(false));
          }}
        >
          <Typography
            variant="h6"
            component="h1"
            sx={{ textAlign: "center", mb: "1.5rem" }}
          >
            Mint Asset as NFT
          </Typography>

          <FormInput label="Name" type="text" name="name" focused required />
          <FormInput type="text" label="URI" name="uri" required focused />

          <FormInput
            type="text"
            label="Description"
            name="data"
            focused
            required
            multiline
            rows={5}
          />
          <FormControlLabel
            control={
              <Checkbox
                size="small"
                aria-label="mint for someone else checkbox"
                sx={{ outline: "1px solid #576b92" }}
                color="secondary"
                checked={!currentAccountIsRecipient}
                inputProps={{ "aria-label": "controlled" }}
                {...methods.register("mintForAnotherAddress", {
                  onChange: (e) => {
                    handleNFTOwnerToggle();
                  },
                })}
              />
            }
            label={
              <Typography
                variant="body2"
                sx={{
                  fontSize: "1rem",
                  fontWeight: 400,
                  color: "#5e5b5d",
                  padding: "1rem",
                }}
              >
                Mint NFT to a Separate Address
              </Typography>
            }
          />

          {!currentAccountIsRecipient && (
            <FormInput
              type="text"
              label="Recipient Address"
              name="recipient"
              InputProps={{
                readOnly: currentAccountIsRecipient,
              }}
            />
          )}

          <LoadingButton
            loading={isLoading}
            type="submit"
            variant="contained"
            sx={{
              py: "0.8rem",
              mt: 2,
              width: "80%",
              marginInline: "auto",
            }}
          >
            Mint Asset
          </LoadingButton>
        </Box>
      </FormProvider>
    </Container>
  );
};

NftForm component takes the following props:

  • mintNftfunction that sends the mint request to the blockchain returns a promise containing the nft's name as string.

  • account containining the details of the connected account, or null if no account is connected.

  • denomId this is the denomId of the Collection the NFT is a part of.

  • setIsMintingNFTSucceed tthis function is used to inform the parent component (Mint) whether the NFT was minted successfully (true) or not (false).

This component uses react-hook-form and Zod just like the DenomForm component, so we would not have to go into details as most of it has been explained earlier. The nft form has a checkbox that can be toggled by the user to indicate whether the owner of the NFT is the connected account, or a different one — if it a different account, an input field is displayed where the recipient account can be entered.

Mint

The Mint component is where we tie all the 3 components created earlier in this step together. The component includes sub-components such as DenomForm for creating a collection/denom and NftForm for minting NFTs. It uses MUI stepper to track the progress of the minting process, handles the navigation between steps, and allows the user to reset the process or view their collections.

Create a file named Mint.tsx in the MintForm folder, pasting into it the code below.

import React, { useEffect, useState } from "react";
import {StepLabel, Button, Box, Stepper, Step, StepContent,} from "@mui/material";
import Typography from "@mui/material/Typography";
import { DenomForm } from "./DenomForm";
import { NftForm } from "./NftForm";
import { IssueMessage, MintMessage } from "../../types/nft";
import { AccountDetails } from "../../ledgers/KeplrLedger";
import { useLocation, useNavigate } from "react-router-dom";

const steps = ["Create Collection", "Mint NFT for an Asset"];

type MintProps = {
  account: AccountDetails | null;
  mintNft: (mintMessage: MintMessage) => Promise<string | undefined>;
  createDenom: (denom: IssueMessage) => Promise<string | undefined>;
};
export const Mint = ({ account, createDenom, mintNft }: MintProps) => {
  const [activeStep, setActiveStep] = useState(0);
  const [denomId, setDenomId] = useState<string | undefined>();
  const [isCreatingCollectionSucceed, setIsCreatingCollectionSucceed] =
    useState(false);
  const [isMintingNFTFSucceed, setIsMintingNFTSucceed] = useState(false);
  const navigate = useNavigate();
  const location = useLocation();

  const handleReset = () => {
    setActiveStep(0);
    setDenomId(undefined);
    setIsCreatingCollectionSucceed(false);
    setIsMintingNFTSucceed(false);
  };

  useEffect(() => {
    setDenomId(location.state);
    if (location.state) {
      handleNext();
    }
  }, [location]);

  const handleBack = () => {
    setIsMintingNFTSucceed(false);
  };

  const handleNext = () => {
    setActiveStep((prevActiveStep) => prevActiveStep + 1);
  };

  return (
    <Box sx={{ padding: "2em" }}>
      <Box sx={{ backgroundColor: "background.default", padding: "2em" }}>
        <Box sx={{ padding: "2em" }}>
          <Typography variant="h6" color="text.secondary">
            Mint NFTs to represent your physical assets on the Cudos Blockchain
          </Typography>
        </Box>
        <Box>
          <Stepper activeStep={activeStep} orientation="vertical">
            <Step>
              <StepLabel> {steps[0]}</StepLabel>
              <StepContent>
                <DenomForm
                  createDenom={createDenom}
                  account={account}
                  setDenomId={setDenomId}
                  setIsCreatingCollectionSucceed={
                    setIsCreatingCollectionSucceed
                  }
                  setNextStep={handleNext}
                />
              </StepContent>
            </Step>

            <Step>
              <StepLabel>{steps[1]}</StepLabel>
              <StepContent>
                <NftForm
                  mintNft={mintNft}
                  account={account}
                  denomId={denomId}
                  setIsMintingNFTSucceed={setIsMintingNFTSucceed}
                />
              </StepContent>
            </Step>
          </Stepper>
          {isMintingNFTFSucceed && (
            <React.Fragment>
              <Typography sx={{ mt: 2, mb: 1 }}>
                Successfully minted NFT
              </Typography>
              <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
                <Box sx={{ flex: "1 1 auto" }} />
                  <Button onClick={(event) => {
                      event.preventDefault();
                      handleReset();
                    }} variant="contained">
                        Create A New Collection
                  </Button>
                  <Box sx={{ flex: "1 1 auto" }} />
                  <Button
                    variant="contained"
                    onClick={(event) => {
                      event.preventDefault();
                      navigate(`/collections`);
                    }}
                  >
                    View Your Collections
                  </Button>
                </Box>
            </React.Fragment>
          )}
            </Box>
      </Box>
    </Box>
  );
};

Upon completing this step, your project should be similar to what can be found here.

Step 06 Creating the components for Displaying NFTs

Collection Components

The components in this folder are going to be used in displaying the details related to an NFT collection. Create a folder inside of the components folder, giving it the name Collection.

The Collection.tsx file contains code for the Collection component. This component renders a card that displays information about each NFT collection i.e. denom.

import React from "react";
import {  Tooltip,  Box,  Card,  CardHeader,  CardContent,  CardActions,
  Avatar,  IconButton,  Typography,} from "@mui/material";
import { OpenInFullRounded, MoreVert } from "@mui/icons-material";
import { Denom } from "../../types/nft";
import { useNavigate } from "react-router-dom";

type CollectionProps = {
  denom: Denom;
};

export const Collection = ({ denom }: CollectionProps) => {
  const navigate = useNavigate();

  return (
    <Box>
      <Card
        sx={{
          maxWidth: 400,
          height: 300,
          bgcolor: "background.default",
          boxShadow: 1,
          borderRadius: 5,
          p: 2,
          borderColor: "secondary.light",
          "&:hover": {
            opacity: [0.9, 0.8, 0.7],
          },
        }}
        variant="outlined"
      >
        <CardHeader
          avatar={
            <Tooltip title={denom.symbol}>
              <Avatar
                sx={{
                  bgcolor: "primary.light",
                  width: 48,
                  height: 48,
                  borderColor: "primary",
                }}
                sizes="md"
                aria-label="symbol"
              >
                <Typography sx={{ fontSize: 10, fontWeight: "bold" }}>
                  {denom.symbol
                    ? denom.symbol.substring(0, 5).toLocaleUpperCase() //truncate the denom symbol to display the first 5 characters
                    : ""}
                </Typography>
              </Avatar>
            </Tooltip>
          }
          title={
            <Tooltip title={denom.name}>
              <Typography color="text.secondary">
                {`${denom.name.substring(0, 5).toLocaleUpperCase()}`}
              </Typography>
            </Tooltip>
          }
          subheader={
            <Tooltip title={denom.creator}>
              <Typography sx={{ my: 1.5, fontSize: 12 }} color="text.secondary">
                {`${denom.creator.substring(0, 7)}...${denom.creator.substring(
                  denom.creator.length - 5,
                  denom.creator.length - 1
                )}`}
              </Typography>
            </Tooltip>
          }
        />
        <CardContent>
          <Typography variant="body2" color="text.secondary">
            {denom.schema}
          </Typography>
        </CardContent>
        <CardActions disableSpacing>
          <Tooltip title="View NFTs in Collection">
            <IconButton
              aria-label="View Collection"
            >
              <OpenInFullRounded color="primary" />
            </IconButton>
          </Tooltip>
        </CardActions>
      </Card>
    </Box>
  );
};

This component takes denom object, containing information about the collection to be displayed, as a prop. The navigate function inside the component uses the useNavigate hook from react-router-dom, we would use this later after we have defined our routes.

Reviewing the code, you will see that we are using a card to display the denom info. The card's content includes a CardHeader with an avatar, title, and subheader. The avatar displays the first 5 characters of the denom symbol, and the title displays the name of the collection also truncated to the first 5 characters. The subheader displays the account that created of the collection truncated to show only a portion of the address. The body of the card displays more details about the denom.

The CollectionsList component

import React from "react";
import { Box, Grid, Typography, Divider, Button, Card, CardMedia, CardContent} from "@mui/material";
import { QueryDenomsResponse } from "cudosjs/build/stargate/modules/nft/proto-types/query";
import { useEffect, useState } from "react";
import { AccountDetails } from "../../ledgers/KeplrLedger";
import { Collection } from "./Collection";
import reified from "../../assets/reified.png";
import { Denom } from "../../types/nft";
import { useNavigate } from "react-router-dom";

type CollectionListProps = {
  getDenom: () => Promise<QueryDenomsResponse>;
  account: AccountDetails | null;
};
export const CollectionList = ({ getDenom, account }: CollectionListProps) => {
  const navigate = useNavigate();
  const [filteredDenoms, setFilteredDenom] = useState<Denom[]>([]);

  useEffect(() => {
    (async () => {
      const resp = await getDenom();
      setFilteredDenom(
        resp?.denoms.filter((denom) => denom.creator == account?.address) ?? []
      );
    })();
  }, [getDenom, account]);

  if (filteredDenoms.length < 1) {
    return (
      <Card sx={{ display: "flex", width: "95vw", height: "100vh" }}>
        <Box sx={{ display: "flex", flexDirection: "column" }}>
          <CardContent sx={{ flex: "1", width: "40vW", padding: 5 }}>
            <Typography
              gutterBottom
              variant="h5"
              component="div"
              sx={{ padding: 5 }}
            >
              You have not Created any Collections
            </Typography>
            <Typography
              variant="h6"
              color="text.secondary"
              component="div"
              sx={{ padding: 5 }}
            >
              Mint NFTs to represent your physical assets on the Cudos
              Blockchain. Connect your Wallet to start minting NFTs on Reified.
            </Typography>
            <Button
              variant="contained"
              color="primary"
              sx={{ margin: 5 }}
              onClick={(event) => {
                event.preventDefault();
                navigate(`/mint`);
              }}
            >
              Create Collection
            </Button>
          </CardContent>
        </Box>
        <CardMedia
          component="img"
          sx={{ flex: "1", width: "100%", height: "100%", objectFit: "cover" }}
          image={reified}
          alt="Live from space album cover"
          sizes="(max-width: 600px) 50vw,
                    800px"
        />
      </Card>
    );
  }
 return (
    <Box sx={{ padding: "5em", bgcolor: "background.paper" }}>
      {/* <Box sx={{ padding:'2em' }}> */}
      <Grid container wrap="nowrap" direction="column">
        <Grid item>
          <Typography gutterBottom variant="h5" component="div">
            Your Assets Collections
          </Typography>
        </Grid>
        <Grid item>
          <Typography gutterBottom variant="h6" component="div">
            You can Expand Each Collection to Load the NFTs in each.
          </Typography>
        </Grid>
      </Grid>
      {/* </Box> */}
      <Divider sx={{ margin: "1em" }}></Divider>
      <Box sx={{ flexGrow: 1, minHeight: "60vh", my: 5, mx: 3, padding: 3 }}>
        <Grid
          container
          spacing={{ xs: 2, md: 3 }}
          columns={{ xs: 4, sm: 8, md: 12, lg: 25 }}
        >
          {filteredDenoms.map((denom, index) => (
            <Grid item xs={4} sm={4} md={4} lg={5} key={index}>
              <Collection denom={denom}></Collection>
            </Grid>
          ))}
        </Grid>
      </Box>
    </Box>
  );
};

The CollectionList component displays a list of the connected user's NFT denoms. It uses the collection component as its child component; it takes 2 props: getDenom function, and an account object. We get and set the user’s NFT collections/denom in the useEffect hook, using the getDenom function. The denoms owned by the connected account are stored in the filteredDenoms state.

When a user does not have any NFT collections, the component displays a message with a button to create a collection, otherwise the collections are displayed in a grid.

NFT components

Create a folder inside of the components folder, giving it the name Nft, this folder will contain the components related to NFT.

Now, create a file named Nft.tsx with the below content:

import React from "react";
import {
  Tooltip,
  Box,
  Card,
  CardHeader,
  CardContent,
  CardActions,
  Avatar,
  IconButton,
  Typography,
} from "@mui/material";
import { OpenInFullRounded, MoreVert } from "@mui/icons-material";
import { Nft as Token } from "../../types/nft";

type NFTProps = {
  nft: Token;
};

export const NFT = ({ nft }: NFTProps) => {
  return (
    <Box>
      <Card
        sx={{
          maxWidth: 400,
          height: 300,
          bgcolor: "background.default",
          boxShadow: 1,
          borderRadius: 5,
          p: 2,
          borderColor: "secondary.light",
          "&:hover": {
            opacity: [0.9, 0.8, 0.7],
          },
        }}
        variant="outlined"
      >
        <CardHeader
          avatar={
            <Tooltip title={nft.name}>
              <Avatar
                sx={{
                  bgcolor: "primary.light",
                  width: 48,
                  height: 48,
                  borderColor: "primary",
                }}
                sizes="md"
                aria-label="symbol"
              >
                <Typography sx={{ fontSize: 10, fontWeight: "bold" }}>
                  {nft.name.substring(0, 5).toLocaleUpperCase()}
                </Typography>
              </Avatar>
            </Tooltip>
          }
          title={
            <Tooltip title={nft.name}>
              <Typography color="text.secondary">
                {`${nft.name.toLocaleUpperCase()}`}
              </Typography>
            </Tooltip>
          }
          subheader={
            <Tooltip title={nft.owner}>
              <Typography sx={{ my: 1.5, fontSize: 12 }} color="text.secondary">
                {`${nft.owner.substring(0, 7)}...${nft.owner.substring(
                  nft.owner.length - 5,
                  nft.owner.length - 1
                )}`}
              </Typography>
            </Tooltip>
          }
        />
        <CardContent>
          <Typography variant="body2" color="text.secondary">
            {nft.data}
          </Typography>
        </CardContent>
        <CardActions disableSpacing>
          <Tooltip title={nft.uri}>
            <IconButton aria-label="Url">
              <OpenInFullRounded color="primary" />
            </IconButton>
          </Tooltip>
        </CardActions>
      </Card>
    </Box>
  );
};

This component is a card displaying information about an NFT. It receives a prop nft, which is an object containing information about the NFT.

The card displays the name, owner address, and data of the NFT. The name is shown as an avatar containing the first 5 characters displayed in uppercase. The NFT name is also displayed as the card's title. The owner's full address is displayed as a tooltip when the runcated version of the owner's address is hovered on. The NFT data is displayed in the card's content section.

The card also includes a button represented by the OpenInFullRounded icon. Clicking on this button opens the URL associated with the NFT's URI. The URI is displayed as a tooltip for the button.

NftList.tsx

Using the Nft component as a child component, we are going to create an NtList component; this displays a list of user’s NFT. To do this, create a file in the same folder with the name NftList.tsx and paste the following code into it:

import React, { useEffect, useState } from "react";
import {
  Box,
  Grid,
  Typography,
  Divider,
  CardContent,
  Card,
  Button,
  CardMedia,
  Fab,
  Tooltip,
} from "@mui/material";
import Add from "@mui/icons-material/Add";
import { AccountDetails } from "../../ledgers/KeplrLedger";
import { Nft as NftType } from "../../types/nft";
import { NFT as Nft } from "./Nft";
import reified from "../../assets/reified.png";
import { useNavigate, useLocation } from "react-router-dom";
import { QueryCollectionResponse } from "cudosjs/build/stargate/modules/nft/proto-types/query";

type NftListProps = {
  getNft: (denomId: string) => Promise<QueryCollectionResponse>;
  account: AccountDetails | null;
};
export const NftList = ({ getNft, account }: NftListProps) => {
  const navigate = useNavigate();
  const location = useLocation();
  const denomId = location.state;
  const [collection, setCollection] = useState<QueryCollectionResponse>();
  const [filteredNFT, setFilteredNFTs] = useState<NftType[]>([]);

  useEffect(() => {
    (async () => {
      const resp = await getNft(denomId);
      setCollection(resp);
      setFilteredNFTs(
        resp?.collection?.nfts?.filter(
          (nft) => nft.owner == account?.address
        ) ?? []
      );
    })();
  }, [account]);

  if (filteredNFT.length < 1) {
    return (
      <Card sx={{ display: "flex", height: "100vh" }}>
        <Box sx={{ display: "flex", flexDirection: "column" }}>
          <CardContent sx={{ flex: "1", width: "40vW", padding: 5 }}>
            <Typography
              gutterBottom
              variant="h4"
              component="div"
              sx={{ padding: 5 }}
            >
              You do not own any NFT in this Collection.
            </Typography>
            <Typography
              variant="h6"
              color="text.secondary"
              component="div"
              sx={{ padding: 5 }}
            >
              Mint NFTs to represent your physical assets on the Cudos
              Blockchain. Connect your Wallet to start minting NFTs on Reified.
            </Typography>
            <Button
              variant="contained"
              color="primary"
              sx={{ margin: 5 }}
              onClick={(event) => {
                event.preventDefault();
                navigate(`/mint`, { state: denomId });
              }}
            >
              Mint NFTs for Physical Assets
            </Button>
          </CardContent>
        </Box>
        <CardMedia
          component="img"
          sx={{ flex: "1", width: "100%", height: "100%", objectFit: "cover" }}
          image={reified}
          sizes="(max-width: 600px) 50vw,
                    800px"
        />
      </Card>
    );
  }
  return (
    <Box sx={{ padding: "5em", bgcolor: "background.paper" }}>
      <Grid container wrap="nowrap">
        <Grid item xs={8}>
          <Typography gutterBottom variant="h5" component="div">
            {collection?.collection?.denom?.name} Collection
          </Typography>
        </Grid>
        <Grid item xs={3}>
          <Typography gutterBottom variant="h5" component="div">
            Symbol: {collection?.collection?.denom?.symbol}
          </Typography>
        </Grid>
        <Grid item xs={1}>
          <Tooltip title="Mint NFT To Add to Collection">
            <Fab
              color="primary"
              aria-label="add"
              onClick={(event) => {
                event.preventDefault();
                navigate(`/mint`, { state: denomId });
              }}
            >
              <Add />
            </Fab>
          </Tooltip>
        </Grid>
      </Grid>
      <Grid container wrap="nowrap" spacing={3}>
        <Grid item xs={8}>
          <Typography color="text.secondary" variant="h6" component="div">
            {collection?.collection?.denom?.schema}
          </Typography>
        </Grid>
        <Grid item xs={4}>
          <Typography color="text.secondary" variant="h6" component="div">
            Count: {filteredNFT?.length}
          </Typography>
        </Grid>
      </Grid>

      <Divider sx={{ m: "1em", color: "primary.dark" }} />
      <Box sx={{ flexGrow: 1, minHeight: "60vh", my: 5, mx: 3, padding: 3 }}>
        <Grid
          container
          spacing={{ xs: 2, md: 3 }}
          columns={{ xs: 4, sm: 8, md: 12, lg: 25 }}
        >
          {filteredNFT.map((nft, index) => (
            <Grid item xs={4} sm={4} md={4} lg={5} key={index}>
              <Nft nft={nft}></Nft>
            </Grid>
          ))}
        </Grid>
      </Box>
    </Box>
  );
};

NftList shows a list of NFTs (Non-Fungible Tokens) in a collection, along with the information about the NFT collection, including the collection's name, symbol, schema, and the number of NFTs in the collection. It also provides a button to mint new NFTs and add them to the collection.

The component takes two props: getNft, a function that retrieves the NFT collection data, and account, an object containing details about the user's account.

We use the useLocation hook to get the denomId from the location state. We would be using react’s useState hook to manage the collection and filteredNFTs data. We are filtering the NFTs because we are only interested in displaying only NFTs owned by the connected user account.

Inside the useEffect hook, an asynchronous function is called to retrieve the NFT collection data using the getNft function. The retrieved collection data is stored in the collection state, and the filtered NFTs owned by the connected user account are stored in the filteredNFT state.

If there are no NFTs owned by this user in the collection, we display the message “You do not own any NFT in this Collection.”, along with a button that the user can click to mint NFTs in the collection.

Otherwise, we display a list of Nfts. It displays the collection's name, symbol, schema, and the count. The list is represented by a grid layout, and each NFT is rendered using the Nft component, passing the respective NFT data as a prop.

Upon completing this step, your project should be similar to what can be found here.

Step 07 Tying the Components Together

Congratulations, you made it to the final step, this is where we tie together all the components we created earlier, by making a nav bar, a home page, and adding routing to our dApp.

Nav Bar

This is a responsive navigation component that displays a material UI App Bar with logos and navigation links as well as the user wallet/account information. Create a folder inside the components folder and give it the name Nav , and in this folder create a file named Nav.tsx .

import * as React from "react";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu";
import MenuIcon from "@mui/icons-material/Menu";
import Container from "@mui/material/Container";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import DiamondIcon from "@mui/icons-material/Diamond";
import { AccountDetails } from "../../ledgers/KeplrLedger";
import { useNavigate } from "react-router-dom";

interface NavProps {
  connect: () => Promise<void>;
  account: AccountDetails | null;
  disconnect: () => void;
  isConnected: boolean;
}

function Nav({ connect, account, disconnect, isConnected }: NavProps) {
  const navigate = useNavigate();

  const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(
    null
  );
  const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(
    null
  );

  const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
    setAnchorElNav(event.currentTarget);
  };
  const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
    setAnchorElUser(event.currentTarget);
  };

  const handleCloseNavMenu = () => {
    setAnchorElNav(null);
  };

  const handleCloseUserMenu = () => {
    setAnchorElUser(null);
  };

  return (
    <AppBar
      position="fixed"
      sx={{
        height: "5em",
        backgroundColor: "primary.dark",
        marginBottom: "5em",
      }}
    >
      <Container maxWidth="xl">
        <Toolbar disableGutters>
          <DiamondIcon sx={{ display: { xs: "none", md: "flex" }, mr: 1 }} />
          <Typography
            variant="h6"
            noWrap
            component="a"
            href=""
            sx={{
              mr: 2,
              display: { xs: "none", md: "flex" },
              fontFamily: "monospace",
              fontWeight: 700,
              letterSpacing: ".3rem",
              color: "inherit",
              textDecoration: "none",
            }}
            onClick={(e) => {
              e.preventDefault();
              navigate("/");
            }}
          >
            REIFIED
          </Typography>

          {isConnected && (
            <Box sx={{ flexGrow: 1, display: { xs: "flex", md: "none" } }}>
              <IconButton
                size="large"
                aria-label="account of current user"
                aria-controls="menu-appbar"
                aria-haspopup="true"
                onClick={handleOpenNavMenu}
                color="inherit"
              >
                <MenuIcon />
              </IconButton>
              <Menu
                id="menu-appbar"
                anchorEl={anchorElNav}
                anchorOrigin={{
                  vertical: "bottom",
                  horizontal: "left",
                }}
                keepMounted
                transformOrigin={{
                  vertical: "top",
                  horizontal: "left",
                }}
                open={Boolean(anchorElNav)}
                onClose={handleCloseNavMenu}
                sx={{
                  display: { xs: "block", md: "none" },
                }}
              >
                <MenuItem>
                  <Typography textAlign="center">Assets</Typography>
                </MenuItem>

                <MenuItem>
                  <Typography textAlign="center">Mint</Typography>
                </MenuItem>
              </Menu>
            </Box>
          )}

          <DiamondIcon sx={{ display: { xs: "flex", md: "none" }, mr: 1 }} />
          <Typography
            variant="h5"
            noWrap
            component="a"
            href=""
            sx={{
              mr: 2,
              display: { xs: "flex", md: "none" },
              flexGrow: 1,
              fontFamily: "monospace",
              fontWeight: 700,
              letterSpacing: ".3rem",
              color: "inherit",
              textDecoration: "none",
            }}
          >
            REIFIED
          </Typography>

          <Box sx={{ flexGrow: 1, display: { xs: "none", md: "flex" } }}>
            {isConnected && (
              <>
                <Button
                  sx={{ my: 2, color: "white", display: "block" }}
                >
                  Assets
                </Button>
                <Button
                  sx={{ my: 2, color: "white", display: "block" }}
                >
                  Mint
                </Button>
              </>
            )}
          </Box>

          <Box sx={{ flexGrow: 0 }}>
            {isConnected ? (
              <>
                <Button
                  onClick={handleOpenUserMenu}
                  color="secondary"
                  variant="contained"
                >
                  <Typography>
                    {account!.username.substring(0, 10).toLocaleUpperCase()}
                  </Typography>
                </Button>

                <Menu
                  sx={{ mt: "45px" }}
                  id="menu-appbar"
                  anchorEl={anchorElUser}
                  anchorOrigin={{
                    vertical: "top",
                    horizontal: "right",
                  }}
                  keepMounted
                  transformOrigin={{
                    vertical: "top",
                    horizontal: "right",
                  }}
                  open={Boolean(anchorElUser)}
                  onClose={handleCloseUserMenu}
                >
                  <MenuItem
                    onClick={() => {
                      handleCloseUserMenu();
                      disconnect();
                    }}
                  >
                    <Typography textAlign="center">Disconnect</Typography>
                  </MenuItem>
                </Menu>
              </>
            ) : (
              <Button color="secondary" variant="contained" onClick={connect}>
                Connect Wallet
              </Button>
            )}
          </Box>
        </Toolbar>
      </Container>
    </AppBar>
  );
}
export default Nav;

We import the Material-UI components such as AppBar, Button, MenuItem, etc. used to style the navigation bar. We also import the useNavigate hook from React Router, to handle navigation.

The Nav component receives several props (that will be later passed in from the App.tsx file)

  • connect - A function to initiate wallet connection.

  • account - Account data of the connected user.

  • disconnect - A function to disconnect the wallet.

  • isConnected - A boolean value indicating whether the wallet is connected or not.

The component's structure consists of a container Container with a fixed AppBar inside. The app bar contains a Toolbar that holds the various elements.

The elements within the navigation bar include a logo (represented by a DiamondIcon), a brand name (Typography), navigation links (Button), and an account-related section.

The account-related section varies based on the isConnected prop. If the wallet is connected, it displays the user's account info, including the first 10 characters of the username and a menu (Menu) with an option to disconnect. If the wallet is not connected, it shows a "Connect Wallet" button, and the menu is hidden.

The component also handles the opening and closing of menus (handleOpenNavMenu, handleOpenUserMenu, handleCloseNavMenu, handleCloseUserMenu) using the useState hook.

You would also notice that we handle responsive with Material UI’s display prop to toggle which components are displayed depending on the screen width.

Home Page

To set up the required file structure, follow these steps:

  1. Create an assets folder in the src directory and place insert link this image file inside it.

  2. Create a new folder named "pages" inside the src directory.

  3. Inside the pages folder, add a file called Home.tsx.

Paste the code below into the Home.tsx file:

import {
    Box,
    Card,
    CardContent,
    CardMedia,
    Typography,
  } from "@mui/material";
  import React from "react";
  import reified from "../assets/reified.png";

  export const Home = () => {
    return (
      <Card sx={{ display: "flex", height: "100vh" }}>
        <Box sx={{ display: "flex", flexDirection: "column" }}>
          <CardContent sx={{ flex: "1", width: "40vW", padding: 5 }}>
            <Typography component="div" variant="h2" sx={{ padding: 5 }}>
              Reified NFTs
            </Typography>
            <Typography
              variant="h6"
              color="text.secondary"
              component="div"
              sx={{ padding: 5 }}
            >
              Mint NFTs to represent your physical assets on the Cudos Blockchain.
              Connect your Wallet to start minting NFTs on Reified.
            </Typography>
          </CardContent>
        </Box>
        <CardMedia
          component="img"
          sx={{ flex: "1", width: "100%", height: "100%", objectFit: "cover" }}
          image={reified}
          alt="Live from space album cover"
          sizes="(max-width: 600px) 50vw,
                800px"
        />
      </Card>
    );
  };

This is a simple homepage displaying a card layout with some content about the dApp, and an image.

Updating App.tsx

Currently, if you run npm run dev in your terminal the dApp simply shows the message Hello… We are now going to make it show all the components we created together.

First, with the imports, add the following to the already existing imports in the file:

...
import { useSnackbar } from "notistack";
import { Routes, Route } from "react-router-dom";
import { Home } from "./pages/Home/Home";
import Nav from "./components/Nav/Nav";
import { NftClient, NftQueryClient } from "./ledgers/NftClient";
import {  keplrSigningClient,  getConnectedAccount,  AccountDetails,} from "./ledgers/KeplrLedger";
import { CollectionList } from "./components/Collection/CollectionList";
import { NftList } from "./components/Nft/NftList";
import { Mint } from "./components/MintForm/Mint";
import { IssueMessage, MintMessage } from "./types/nft";

Along with the components, we are importing the Nft clients, functions associated with KeplrWallet as well as Routes and Route from React-Router. Next, we will initialise the state variables, because we will be using react’s useState hook, we need to update the import from react library to include the useState hook. So this becomes import React, {useState} from "react";

...
function App() {
      const [nftSigningClient, setNftSigningClient] = useState<NftClient | null>(
        null
      );
      const nftQueryClient: NftQueryClient | null = new NftQueryClient();

      const [isConnected, setIsConnected] = useState<boolean>(false);
      const [account, setAccount] = useState<AccountDetails | null>(null);
      const { enqueueSnackbar } = useSnackbar();
...

The nftQueryclient class’s methods do not need a user to be connected, so we can instantiate the variable that holds it. However, the state of the nftSigningClient, isConnected, and account can all change, hence we use the useState hook for these.

Below this, we will create the functions used in the App components and some passed onto its children components.

...
const connectWallet = async () => {
      try {
        setNftSigningClient(new NftClient());
        setAccount(await getConnectedAccount());
        setIsConnected(true);
      } catch (error: any) {
        enqueueSnackbar(`Error Connecting! ${error.message}`, {
          variant: "error",
        });
      }
    };
...

The connectWallet function lets a user connect to the Cudos blockchain network. We set the nft signing client as an instance of nftClient We also set the account, and set the connection state setIsConnected, to true.

  const disconnect = () => {
    if (nftSigningClient) {
      nftSigningClient.disconnect();
      setNftSigningClient(null);
      setAccount(null);
      setIsConnected(false);
    }
  };

The disconnect function handles disconnecting from the Cudos blockchain network if there's a current connection. It also resets the following state variables: nftSigningClient, account, and isConnected.

...
const getAllNftsById = async (denomId: string) => {
    return await nftQueryClient.getAllTokensInCollection(denomId);
  };

  const createDenom = async (denomMessage: IssueMessage) => {
    if (isConnected) {
      await nftSigningClient?.issueDenom(denomMessage);
      return denomMessage.id;
    } else throw new Error("Keplr Wallet not connected");
  };

  const mintNft = async (mintMessage: MintMessage) => {
    if (isConnected) {
      await nftSigningClient?.mintNFT(mintMessage);
      return mintMessage.denomId;
    } else throw new Error("Keplr Wallet not connected");
  };
...

The three functions getAllftsById, createDenom, and mintNft above fetch NFTs belonging to a denom, hands the creation of a new NFT denom, and handles the minting of NFTs respectively.

Now, let us bring it all together.

import React, {useState} from "react";
...
import { IssueMessage, MintMessage } from "./types/nft";

function App() {
  const [nftSigningClient, setNftSigningClient] = useState<NftClient | null>(
    null
  );
 ...
    const mintNft = async (mintMessage: MintMessage) => {
      if (isConnected) {
        await nftSigningClient?.mintNFT(mintMessage);
        return mintMessage.denomId;
      } else throw new Error("Keplr Wallet not connected");
    };

  return (
    <Box sx={{ maxWidth: "100%" }}>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Box sx={{ display: "flex", flexDirection: "column" }}>
          <Nav
            connect={connectWallet}
            disconnect={disconnect}
            account={account}
            isConnected={isConnected}
          />
          <Box sx={{ paddingTop: "5em" }}>
            <Routes>
              <Route path="/" element={<Home />} />
              <Route
                path="/collections"
                element={
                  <CollectionList
                    getDenom={nftQueryClient.getAllDenoms}
                    account={account}
                  />
                }
              />
              <Route
                path="/assets/:denomId"
                element={<NftList getNft={getAllNftsById} account={account} />}
              />
              <Route
                path="/mint"
                element={
                  <Mint
                    createDenom={createDenom}
                    mintNft={mintNft}
                    account={account}
                  />
                }
              />
            </Routes>
          </Box>
        </Box>
      </ThemeProvider>
    </Box>
  );
}

export default App;

The App is made up of the Nav component and a material Ui Box containing React Router’s Routes component. The connectWallet , disconnect, functions along with the account, and isConnected states are passed into the Nav component as props.

Routes

The Routes component from React Router is used to define the different routes in our dApp and their corresponding components.

RouteComponent
“/”Home
“/collections”CollectionList
“/assets/:denomId”NftList
“/mint”Mint

We are now going to update the Nav component by adding navigation to the menu items. To do this, open the Nav.tsx file.

...
function Nav({ connect, account, disconnect, isConnected }: NavProps) {
  const navigate = useNavigate();
  //add new lines from here
  const goToCollection = () => {
    if (account) {
      handleCloseNavMenu();
      navigate("/collections");
    }
  };

  const goToMintPage = () => {
    handleCloseNavMenu();
    if (account !== null) {
      navigate("/mint");
    }
  };

  const goToHomePage = () => {
    handleCloseNavMenu();
    navigate("/");
  };
  //end of the new lines
  const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(
    null
...

All of these use the useNavigate hook to handle navigation to the various components. We are now going to add it to the onClick event handler for the corresponding elements of the Nav bar elements that we created earlier.

function Nav({ connect, account, disconnect, isConnected }: NavProps) {
 ...
  return (
    <AppBar
      ...
                <MenuItem onClick={goToCollection}>
                  <Typography textAlign="center">Assets</Typography>
                </MenuItem>

                <MenuItem
                  onClick={(event) => {
                    event.preventDefault();
                    goToMintPage();
                  }}
                >
                  <Typography textAlign="center">Mint</Typography>
                </MenuItem>
          ...

          <Box sx={{ flexGrow: 1, display: { xs: "none", md: "flex" } }}>
            {isConnected && (
              <>
                <Button
                  onClick={goToCollection}
                  sx={{ my: 2, color: "white", display: "block" }}
                >
                  Assets
                </Button>
                <Button
                  onClick={goToMintPage}
                  sx={{ my: 2, color: "white", display: "block" }}
                >
                  Mint
                </Button>
              </>
            )}
          </Box>
            ...
          <MenuItem
            onClick={() => {
            handleCloseUserMenu();
            disconnect();
            goToHomePage();
            }}
          >
            <Typography textAlign="center">Disconnect</Typography>
          </MenuItem>
        </Menu>
         ...

Lastly, in the Collection component, add a navigate hook to the IconButton in the CardActions Component, so that it becomes like this:

...
<CardActions disableSpacing>
    <Tooltip title="View NFTs in Collection">
        <IconButton
        aria-label="View Collection"
        onClick={(event) => {
        event.preventDefault();
          navigate(`/assets/${denom.id}`, { state: denom.id }); 
        }}
      >
        <OpenInFullRounded color="primary" />
      </IconButton>
    </Tooltip>
</CardActions>
...

The complete project can be found here.

That's it! You've built your NFT dApp with me, congratulations on completing this comprehensive tutorial! I want to express my sincere gratitude for following through to the end! By reaching this point, you've learnt how to build fronttend that integrates with not just Cudos but other Cosmos SDK blockchain, awesome, isn't it? I hope you apply this knowledge and build more using what you've learnt here.