DEV Community

Cover image for Create a simple minting DApp using NextJS, Brownie, Solidity and TailwindCSS.
Rafael Abuawad
Rafael Abuawad

Posted on

Create a simple minting DApp using NextJS, Brownie, Solidity and TailwindCSS.

Features

  • Let's the user create an NFT, with an image and metadata (properties)
  • Stores and displays the NFTs the user owns
  • Collect fees from every mint
  • Dark/Light mode

Summary

In this tutorial, we will create an NFT minting web application, that allows users to upload an Image to IPFS, add properties and attributes, and mint a custom-made NFT.

Setup

For this you will need to have installed python, node.js and brownie-eth.

We also need a local blockchain to test and develop our application, I'll be using Ganache, which is a beginner-friendly app to run a local blockchain, and there is a lot of tutorial and documentation on how to use it.

After that, open your terminal and run brownie bake react simple-mint, this will generate Brownie project using ReactJS, but wait I said that we will use NextJS for this project, go to the folder simple-mint (the project that you just created) and delete the folder called client. After that is done go back to your terminal and inside the simple-mint folder run npx create-next-app client, this command will create a brand new NextJS project. For now, that's all we need now onto the smart contracts!

Smart contracts

Before we start programming the smart contract, we need to modify the brownie-config.yaml, here we will include the dependencies we need, re-mappings for the compiler, also now that we are here, create a .env file to avoid annoying errors.

The brownie-config.yaml file should now look like this:

# change the build directory to be within react's scope
project_structure:
    build: client/artifacts


dependencies:
    - OpenZeppelin/openzeppelin-contracts@4.5.0


# automatically fetch contract sources from Etherscan
autofetch_sources: True
dotenv: .env


compiler:
    solc:
        version: '0.8.4'
        optimizer:
            enabled: true
            runs: 200
        remappings:
            - "@openzeppelin=OpenZeppelin/openzeppelin-contracts@4.5.0"


networks:
  default: development
  development:

    update_interval: 60
    verify: False

  kovan:

    verify: False
    update_interval: 60


wallets:
  from_key: ${PRIVATE_KEY}


# enable output of development artifacts to load with react
dev_deployment_artifacts: true
Enter fullscreen mode Exit fullscreen mode

For this dapp we only need one smart contract, the contract is really simple is an extended version of an ERC-721 contract. Inside the contracts folder create a SimpleMint.sol file.

// SPDX-License-Identifier: MIT
// contracts/SimpleMint.sol
pragma solidity ^0.8.4;

// Te will extend/use this open zeppelin smart contract to save time
// if you nee more information about ERC721 checkout the OpenZeppelin docs
// https://docs.openzeppelin.com/contracts/4.x/erc721
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

// This smart contract enabled us to give access control to some functions
// https://docs.openzeppelin.com/contracts/4.x/api/access#Ownable
import "@openzeppelin/contracts/access/Ownable.sol";

contract SimpleMint is ERC721, ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;
    // This is the minting fee users have to pay to mint an NFT
    // on the platform
    uint256 private _fee = 0.0025 ether;

    constructor() ERC721("SimpleMint", "SIMPLE") {}

    function safeMint(string memory uri) public payable {
        // This 'require' ensures the user is paying
        // the minting fee
        require (
            msg.value == _fee,
            "You nee to pay a small fee to mint the NFT."
        );

        uint256 tokenId = _tokenIdCounter.current();
        _tokenIdCounter.increment();
        _safeMint(msg.sender, tokenId);
        _setTokenURI(tokenId, uri);
    }

    // The following functions are overrides required by Solidity.

    function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
        super._burn(tokenId);
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    // This function will return a list of Token URIs
    // given an Ethreum address
    function tokensOf(address minter)
        public
        view
        returns (string[] memory)
    {
        // Here we count how many tokens does the user have 
        uint256 count = 0;

        for(uint256 i = 0; i < _tokenIdCounter.current(); i++) {
            if(ownerOf(i) == minter) {
                count ++;
            }
        }

        // Here we create and populate the tokens with their
        // correspoding Token URI
        string[] memory tokens = new string[](count);
        uint256 index = 0;

        for(uint256 i = 0; i < _tokenIdCounter.current(); i++) {
            if(ownerOf(i) == minter) {
                tokens[index] = tokenURI(i);
                index ++;
            }
        }

        return tokens;
    }

    // This function returns the minting fee to users
    function fee()
        public
        view
        returns (uint256)
    {
        return _fee;
    }

    // This function allows you, **and only you**, to change 
    // the minting fee
    function setFee(uint256 newFee)
        public
        onlyOwner
    {
        _fee = newFee;
    }

    // This function will transfer all the fees collected
    // to the owner
    function withdraw()
        public
        onlyOwner
    {
        (bool success, ) = payable(owner()).call{ value: address(this).balance }("");
        require (success);
    }
}
Enter fullscreen mode Exit fullscreen mode

Well, that's it, this is the smart contract that will be the backbone of our application.

Testing

Before we deploy this dapp to production, we need to make sure everything is working properly, for this we will write some automated tests that will check if the functionality of the smart contract is what we expect.

Go to the tests folder, and open the conftest.py file, this is code that will execute before each test. The file should be looking like this:

# tests/conftest.py
import pytest


@pytest.fixture(autouse=True)
def setup(fn_isolation):
    """
    Isolation setup fixture.
    This ensures that each test runs against the same base environment.
    """
    pass


@pytest.fixture(scope="module")
def simple_mint(accounts, SimpleMint):
    """
    Yield a `Contract` object for the SimpleMint contract.
    """
    yield accounts[0].deploy(SimpleMint)

Enter fullscreen mode Exit fullscreen mode

Now create a file called test_simple_mint.py inside the tests folder. The first test that we will write is to check if the contract is deployed correctly.

# tests/test_simple_mint.py
from brownie import Wei

def test_simple_mint_deploy(simple_mint):
    """
    Test if the contract is correctly deployed.
    """
    assert simple_mint.fee() == Wei('0.0025 ether')

Enter fullscreen mode Exit fullscreen mode

Now we create a test to check if the user can and cannot mint NFTs while paying the fee.

# tests/test_simple_mint.py
# ...


def test_simple_mint_minting(accounts, simple_mint):
    """
    Test if the contract can mint an NFT, and charge the
    corresponding fee.
    """
    token_uri = 'https://example.mock/uri.json'

    # can't mint, not paying fee
    with reverts():
        simple_mint.safeMint(token_uri, {'from': accounts[1]})

    # can mint, paying fee
    fee = simple_mint.fee()
    simple_mint.safeMint(token_uri, {'from': accounts[1], 'value': fee})

Enter fullscreen mode Exit fullscreen mode

The next test will check the display of user tokens after minting, and if the owner of the token is correct, this means that the users can only see NFTs that they own.

# tests/test_simple_mint.py
# ...


def test_simple_mint_tokens(accounts, simple_mint):
    """
    Test if the contract can mint an NFT, and charge the
    corresponding fee.
    """
    token_uri = 'https://example.mock/uri.json'
    user_one, user_two = accounts[1], accounts[2]
    fee = simple_mint.fee()

    # minting 3 tokens as user one
    simple_mint.safeMint(token_uri, {'from': user_one, 'value': fee})
    simple_mint.safeMint(token_uri, {'from': user_one, 'value': fee})
    simple_mint.safeMint(token_uri, {'from': user_one, 'value': fee})

    # minting 2 tokens as user two
    simple_mint.safeMint(token_uri, {'from': user_two, 'value': fee})
    simple_mint.safeMint(token_uri, {'from': user_two, 'value': fee})


    user_one_tokens = simple_mint.tokensOf(user_one.address)
    assert len(user_one_tokens) == 3

    # here we assert that the owner of the token is the correct one
    print("--- user one's tokens")
    for token_uri, token_id in user_one_tokens:
        assert simple_mint.ownerOf(token_id) == user_one.address
        print(token_uri, token_id)

    user_two_tokens = simple_mint.tokensOf(user_two.address)
    assert len(user_two_tokens) == 2

    # here we assert that the owner of the token is the correct one
    print("--- user two's tokens")
    for token_uri, token_id in user_two_tokens:
        assert simple_mint.ownerOf(token_id) == user_two.address
        print(token_uri, token_id)

Enter fullscreen mode Exit fullscreen mode

We will test the fee-related functions, first, we will test if we can change the minting fee.

# tests/test_simple_mint.py
# ...


def test_simple_mint_fees(accounts, simple_mint):
    """
    Test if the owner, and the owner only, can change the minting fee.
    """

    fee = simple_mint.fee()
    assert simple_mint.fee() == Wei('0.0025 ether')

    # another user cannot change the minting fee
    with reverts():
        simple_mint.setFee(Wei('0.5 ether'), {'from': accounts[1]})

    # the owner can change the minting fee
    new_fee = Wei('0.0025 ether')
    simple_mint.setFee(new_fee, {'from': accounts[0]})
    assert simple_mint.fee() == new_fee

Enter fullscreen mode Exit fullscreen mode

Finally, we will test the withdrawal function, and check if we got the correct amount of collected fees.

# tests/test_simple_mint.py
# ...

def test_simple_withdraw(accounts, simple_mint):
    """
    Test if the owner, and the owner only, can withdraw all the
    collected fees.
    """
    fee = Wei('0.5 ether')
    simple_mint.setFee(fee, {'from': accounts[0]})
    initial_balance = accounts[0].balance()
    print(f'Intial balance: {initial_balance}')

    # here we will mint 10 tokens, at a 0.5 ETH fee, this will cost 5 ETH
    # to account one, so the collected fees should amount to 5 ETH
    token_uri = 'https://example.mock/uri.json'
    for i in range(10):
        print(f'mint {i}/10')
        simple_mint.safeMint(
            token_uri, {'from': accounts[1], 'value': fee})

    simple_mint.withdraw({'from': accounts[0]})

    # the owner new balance should be:
    # initial_balance + 5 ETH
    new_balance = accounts[0].balance()
    print(f'New balance: {new_balance}')
    assert initial_balance + Wei('5 ether') == new_balance

Enter fullscreen mode Exit fullscreen mode

And that's it for testing, we checked the basic functionality that the smart contract needs to have.

To run the tests open ganache, once ganache is running a local blockchain, you can go back to your terminal/console and run the following command brownie test, this will run all the tests that we have written (you can also run brownie test -s to view prints and more details on each test).

If you want to see how the code is structured, check out the code repository.

Front-end

Now go back to your terminal, and go to the client folder, we need to install some dependencies, go check the setup guide for TailwindCSS with NextJS, after that is complete we need to install some package to interact with a blockchain from NextJS, run npm install web3modal ethers Axios ipfs-http-client, once that is installed we can start building our front-end.

Components

First, we will build all the components that we need to have to use the application, take in mind these are dumb components, which means that they will not interact with the state directly.

Loading component

A simple component to display an overlay, with a spinning wheel and some text. The text is variable, to allow you to display any message that you want.

// components/Loading/index.js
export default function Loading({ text }) {
  return (
    <div className="overflow-none fixed top-0 left-0 flex h-screen w-screen items-center justify-center bg-black bg-opacity-50">
      <div className="flex items-center text-white">
        <svg
          className="-ml-1 mr-3 h-5 w-5 animate-spin text-white"
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
        >
          <circle
            className="opacity-25"
            cx="12"
            cy="12"
            r="10"
            stroke="currentColor"
            strokeWidth="4"
          ></circle>
          <path
            className="opacity-75"
            fill="currentColor"
            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
          ></path>
        </svg>
        {text}...
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Navbar

The first component we will create is a navbar, to navigate between views and connect our web3 wallet to the application. This component displays a 'connect wallet button' and the logo, once the user connects the 'connect wallet button' will disappear and the wallet address and navigation links will show up. This component also has the theme toggler button, although the button is fixed since this component will be used in every view is better to have it here.

// components/Navbar/index.js
import Image from 'next/image'
import Link from 'next/link'

// This returns a **readable** wallet address
const formatAddress = (address) =>
  address.slice(0, 5) + '...' + address.slice(38)

export default function Navbar({ address, connectWallet, theme, setTheme }) {
  return (
    <div className="py-6 md:px-6">
      <div className="flex items-center justify-center border-b border-zinc-100 px-3 pb-6 dark:border-zinc-600 sm:justify-between">
        {/* logo */}
        <div className="hidden cursor-pointer sm:inline-flex">
          <Link href="/">
            <a>
              <Image src="/logo.png" width={90} height={78} />
            </a>
          </Link>
        </div>

        {/* connect button */}
        {!address && (
          <div className="flex items-center">
            <button
              onClick={connectWallet}
              className="cursor-pointer rounded-md bg-green-400 py-2 px-3 text-white hover:bg-green-500"
            >
              Connect
            </button>
          </div>
        )}

        {/* navigation & user's address */}
        {address && (
          <div className="flex items-center space-x-3">
            <Link href="/">
              <a className="hover:underline">My NFTs</a>
            </Link>
            <Link href="/create">
              <a className="hover:underline">Create</a>
            </Link>
            <p className="rounded-md bg-green-400 py-2 px-3 text-white">
              {formatAddress(address)}
            </p>
          </div>
        )}
      </div>

      {/* theme toggler */}
      <div className="fixed -bottom-1 -right-1 rounded-md border border-zinc-100 bg-white bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-600">
        {theme === 'light' && (
          <svg
            cursor="pointer"
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth={2}
            onClick={() => setTheme('dark')}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
            />
          </svg>
        )}
        {theme === 'dark' && (
          <svg
            cursor="pointer"
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth={2}
            onClick={() => setTheme('light')}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
            />
          </svg>
        )}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

No wallet

This is a simple component to prompt the user to connect their wallet to the user of the application.

// components/NoWallet/index.js

export default function NoWallet() {
  return (
    <div className="flex h-screen w-screen items-center justify-center md:h-[65vh]">
      <p className="font-3xl font-bold text-zinc-400 dark:text-zinc-200">
        Connect your wallet to access the application
      </p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

No Mints

We also need a no mints component, to show the user that he hasn't created an NFT with that address.

// components/NoMints/index.js
import Link from 'next/link'

export default function NoMints() {
  return (
    <div className="flex h-screen w-full items-center justify-center md:h-[65vh]">
      <p className="font-3xl font-bold text-zinc-400 dark:text-zinc-200">
        Looks like you haven't created any NFT's yet,{' '}
        <Link href="/create">
          <span className="cursor-pointer text-green-500 hover:underline">
            creaate one now
          </span>
        </Link>
        .
      </p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

NFT Card

This component will display the image and the name of the NFT that the user owns/mints, and will also include a link to a details page of that NFT.

// components/NFTCard/index.js
import Link from 'next/link'
import Image from 'next/image'

export default function NFTCard({ data }) {
  return (
    <div className="max-w-96 group relative h-72 cursor-pointer rounded-md duration-100 ease-in-out hover:scale-105 sm:w-72">
      <div className="max-w-96 relative h-72 rounded-md sm:w-72">
        <Image
          className="rounded-md"
          layout="fill"
          objectFit="cover"
          quality={100}
          src={data.metadata.image}
          alt="text"
        />
        <div className="none absolute bottom-0 flex hidden h-12 w-full items-center rounded-b-md bg-zinc-800 px-3 font-bold text-white ease-in-out group-hover:flex dark:bg-white dark:text-zinc-800 ">
          <Link href={`/details/${data.tokenId}`}>
            <a className="hover:text-green-300 hover:underline">
              {data.metadata.name}
            </a>
          </Link>
        </div>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Details

This is a wrapper component, a dropdown to display the details from an NFT. The details themselfs can be anything that you want, and the component can either display them on a grid or not.

// components/Details/index.js
export default function Details({ summary, isGrid, children }) {
  return (
    <details className="group border border-zinc-100 p-3 hover:cursor-pointer dark:border-zinc-600">
      <summary className="font-xl flex w-full list-none items-center justify-between font-bold">
        {/* The title of the drop down */}
        <span className="group-hover:underline">{summary}</span>

        <div className="icon">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth="2"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M19 9l-7 7-7-7"
            />
          </svg>
        </div>
      </summary>

      {/* if its children should be in a grid */}
      {isGrid && (
        <div className="grid grid-flow-row grid-cols-2 gap-4 pt-3 md:grid-cols-3 xl:grid-cols-4">
          {children}
        </div>
      )}

      {/* else */}
      {!isGrid && (
        <div className="pt-3 text-zinc-800 dark:text-zinc-50">{children}</div>
      )}
    </details>
  )
}
Enter fullscreen mode Exit fullscreen mode

Details tile

This component will be used inside the details component, and it will display properties and attributes from the NFT, is really simple.

// components/DetailTile/index.js
export default function DetailTile({ title, value }) {
  return (
    <div className="flex h-32 w-full flex-col items-center justify-center rounded-md border border-zinc-100 p-3 dark:border-zinc-600">
      <p className="text-3xl text-zinc-800 dark:text-zinc-50">{value}</p>
      <p className="text-xl font-bold text-green-500">{title}</p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Add attributes form

This component will be used in the create NFT view, it will be responsible of handle the logic to add an attribute, an attribute is stored inside the metadata of an NFT, in the attributes array, which gives it unique properties and values.

// components/AddAttributes/index.js
import { useState } from 'react'

// A function to capitalize text
// Ex. capitalize("soME TeXT") => "Some Text"
const capitalize = (text) =>
  text
    .trim()
    .toLowerCase()
    .split(' ')
    .map((word) => word[0].toUpperCase() + word.slice(1))
    .join(' ')

export default function AddAttributes({ addAttribute }) {
  // ERC-721 metadata attributes
  // {
  //    "display_type": "boost_number",
  //    "trait_type": "Aqua Power",
  //    "value": 40
  // }

  const [displayType, setDisplayType] = useState('')
  const [traitType, setTraitType] = useState('text')
  const [value, setValue] = useState('')

  function handleAddAttribute(e) {
    e.preventDefault()

    // if one field is empty return
    if (!displayType || !traitType || !value) {
      return
    }

    let data = { displayType: capitalize(displayType), traitType, value }
    switch (data.traitType) {
      case 'text': {
        data.value = capitalize(data.value)
        break
      }
      case 'boost_percentage': {
        data.value = Number(data.value) + '%'
        break
      }
      case 'boost_number':
      case 'number': {
        data.value = Number(data.value)
        break
      }
    }

    addAttribute(data)

    // reset all fields
    setDisplayType('')
    setTraitType('text')
    setValue('')
  }

  return (
    <form className="flex h-16 w-full items-center space-x-3">
      <div className="flex flex-grow flex-col">
        <label
          htmlFor="name"
          className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
        >
          Name
        </label>
        <input
          className="placeholder-text-xl h-12 rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500"
          id="name"
          type="text"
          value={displayType}
          placeholder="Ex. Power"
          onChange={(e) => setDisplayType(e.target.value)}
        />
      </div>
      <div className="flex flex-grow flex-col">
        <label
          htmlFor="traitType"
          className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
        >
          Trait Type
        </label>
        <select
          className="placeholder-text-xl h-12 rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500"
          name="traitType"
          value={traitType}
          onChange={(e) => setTraitType(e.target.value)}
        >
          <option value="text">Text</option>
          <option value="boost_percentage">Boost Percentage</option>
          <option value="boost_number">Boost Number</option>
          <option value="number">Number</option>
        </select>
      </div>
      <div className="flex flex-grow flex-col">
        <label
          htmlFor="value"
          className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
        >
          Value
        </label>
        <input
          id="value"
          className="placeholder-text-xl h-12 rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500"
          type="text"
          value={value}
          placeholder="Ex. 25"
          onChange={(e) => setValue(e.target.value)}
        />
      </div>
      <div className="flex w-12 flex-col">
        <button
          className="mt-5 flex h-12 w-12 items-center justify-center rounded-md bg-green-400 text-white hover:bg-green-500"
          type="submit"
          onClick={handleAddAttribute}
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth={4}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M12 6v6m0 0v6m0-6h6m-6 0H6"
            />
          </svg>
        </button>
      </div>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Attributes table

This component will be use to display the attributes as we add them.

// components/AttributesTable/index.js
export default function AttributesTable({ attributes, removeAttribute }) {
  // If there are no attributes, don't show anything
  if (attributes.length === 0) {
    return null
  }

  return (
    <table className="w-full border border-zinc-100 dark:border-zinc-600">
      <thead>
        <tr>
          <th className="border border-zinc-100 p-3 text-sm uppercase text-zinc-500 dark:border-zinc-600 dark:text-zinc-300">
            Name
          </th>
          <th className="border border-zinc-100 p-3 text-sm uppercase text-zinc-500 dark:border-zinc-600 dark:text-zinc-300">
            Display Type
          </th>
          <th className="border border-zinc-100 p-3 text-sm uppercase text-zinc-500 dark:border-zinc-600 dark:text-zinc-300">
            Value
          </th>
          <th className="border border-zinc-100 p-3 text-sm uppercase text-zinc-500 dark:border-zinc-600 dark:text-zinc-300">
            Remove
          </th>
        </tr>
      </thead>
      <tbody>
        {attributes.map((attribute, i) => (
          <tr key={i}>
            <td className="border border-zinc-100 text-center dark:border-zinc-600">
              {attribute.displayType}
            </td>
            <td className="border border-zinc-100 text-center lowercase text-zinc-400 dark:border-zinc-600 dark:text-zinc-300">
              {attribute.traitType}
            </td>
            <td className="border border-zinc-100 text-center dark:border-zinc-600">
              {attribute.value}
            </td>
            <td className="border border-zinc-100 text-center dark:border-zinc-600">
              <button
                className="my-1 rounded-md px-2 py-1 font-semibold text-red-500 hover:bg-red-500 hover:text-white"
                onClick={() => removeAttribute(i)}
              >
                REMOVE
              </button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}
Enter fullscreen mode Exit fullscreen mode

Well, that's it for components, we separated the logic between components and pages, because components should be small pieces of code, not aware of the global state, this makes them easy to debug and easy to test.

If you want to see how the code is structured, check out the code repository.

Pages

Here we will manage our context, our smart contracts, and all the logic that connects to something else. I don't expect you to be an expert with the Context API, but I would recommend you to take a look at how it works if you haven't already.

First, we will create a Web3 Context, which will contain all the information about the smart contracts and the connected wallet. Go ahead, and inside the client folder create a file store/web3Context.js.

import { createContext } from 'react'

export default createContext({
  simpleMint: null,
  signer: null,
  address: null,
})
Enter fullscreen mode Exit fullscreen mode

Now we will create the Theme Context, it may be a little more complex, but it only takes care of the app theme (light or dark).

export const getInitialTheme = () => {
  if (typeof window !== 'undefined' && window.localStorage) {
    const storedPrefs = window.localStorage.getItem('color-theme')
    if (typeof storedPrefs === 'string') {
      return storedPrefs
    }

    const userMedia = window.matchMedia('(prefers-color-scheme: dark)')
    if (userMedia.matches) {
      return 'dark'
    }
  }

  return 'light' // light theme as the default;
}

export const rawSetTheme = (rawTheme) => {
  const root = window.document.documentElement
  const isDark = rawTheme === 'dark'

  root.classList.remove(isDark ? 'light' : 'dark')
  root.classList.add(rawTheme)

  localStorage.setItem('color-theme', rawTheme)
}
Enter fullscreen mode Exit fullscreen mode

_app page

This is a wrapper component, here we will put in place our contexts, and connect them to our web3 application. This page displays the NoWallet component if there is no wallet connected to the application.

It does a lot of things, so take your time to read the comments.

import { useState, useEffect } from 'react'
import Web3Modal from 'web3modal'
import { ethers } from 'ethers'

// components
import Navbar from '../components/Navbar'
import NoWallet from '../components/NoWallet'

// store and context
import { getInitialTheme, rawSetTheme } from '../store/themeContext'
import Web3Context, { getNetworkName } from '../store/web3Context'

// styles
import '../styles/globals.css'

// smart-contracts
import SimpleMint from '../artifacts/contracts/SimpleMint.json'

function App({ Component, pageProps }) {
  // app theme
  const [theme, setTheme] = useState(getInitialTheme)

  // web3 dapp state
  const [signer, setSigner] = useState(null)
  const [address, setAddress] = useState(null)
  const [simpleMint, setSimpleMint] = useState(null)

  // sets the theme on change
  useEffect(() => {
    rawSetTheme(theme)
  }, [theme])

  async function connectWallet() {
    const web3Modal = new Web3Modal()
    const connection = await web3Modal.connect()
    const provider = new ethers.providers.Web3Provider(connection)
    const signer = provider.getSigner()
    const address = await signer.getAddress()
    const { chainId } = await provider.getNetwork()
    const chainName = getNetworkName(chainId)

    // this deployed simple mint smartcontract address 
    /// *** REPLACE THIS ***
    const simpleMintAddress = '0x854b699d119c5f89681c96d282098e4420eDa135'

    const simpleMintContract = new ethers.Contract(
      simpleMintAddress,
      SimpleMint.abi,
      signer
    )

    setSigner(signer)
    setAddress(address)
    setSimpleMint(simpleMintContract)
  }

  return (
    <div>
      <Navbar
        address={address}
        connectWallet={connectWallet}
        theme={theme}
        setTheme={setTheme}
      />
      {address && (
        <Web3Context.Provider value={{ signer, address, simpleMint }}>
          <Component {...pageProps} />
        </Web3Context.Provider>
      )}
      {!address && <NoWallet />}
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Index Page

This is the main page, here we display the NFTs that the user owns, and if the user hasn't minted any NFTs yet we will display the NoMints component.

// pages/index.js
import { useContext, useState, useEffect } from 'react'
import axios from 'axios'
import Head from 'next/head'

// store
import Web3Context from '../store/web3Context'

// components
import NFTCard from '../components/NFTCard'
import NoMints from '../components/NoMints'

export default function Home() {
  const { simpleMint, address } = useContext(Web3Context)
  const [nfts, setNfts] = useState([])

  // once connected a wallet load the nfts
  useEffect(() => {
    if (simpleMint && address) {
      loadNfts()
    }
  }, [simpleMint, address])

  async function loadNfts() {
    let nfts = await simpleMint.tokensOf(address)

    // tokensOf returns a Token ID and a Token URI
    // we need to retrive and parse that data
    nfts = await Promise.all(
      nfts.map(async (nft) => {
        // token as returned from the smart-contract
        let [metadata, tokenId] = nft

        // parsing the token id
        tokenId = tokenId.toString()
        // fetching the metadata
        metadata = await axios.get(metadata).then((res) => res.data)

        return { metadata, tokenId }
      })
    )

    setNfts(nfts)
  }

  return (
    <div>
      <Head>
        <title>Simple Mint</title>
        <meta name="description" content="NFT minting Dapp" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <div className="px-3 md:px-6">
        <h1 className="text-3xl font-bold">NFTs</h1>
        {nfts.length == 0 && <NoMints />}
        {nfts.length != 0 && (
          <div className="sm: grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 p-3">
            {nfts.map((nft, i) => (
              <NFTCard key={i} data={nft} />
            ))}
          </div>
        )}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Create page

This page gives the user a great UI to create an NFT, without code.

// pages/create.js
import { useContext, useState } from 'react'
import { create as ipfsHttpClient } from 'ipfs-http-client'
import { useRouter } from 'next/router'
import Head from 'next/head'
import Image from 'next/image'

// components
import AddAttributes from '../components/AddAttributes'
import AttributesTable from '../components/AttributesTable'
import Loading from '../components/Loading'

// store
import Web3Context from '../store/web3Context'

// IPFS access point
const client = ipfsHttpClient('https://ipfs.infura.io:5001/api/v0')

export default function Create() {
  const { simpleMint, address } = useContext(Web3Context)
  // we will use the router to change the view after creating the NFT
  const router = useRouter()

  const [name, setName] = useState('')
  const [description, setDescription] = useState('')
  const [attributes, setAttributes] = useState([])
  const [imageUrl, setImageUrl] = useState(null)
  const [uploading, setUploading] = useState(false)
  const [loading, setLoading] = useState(false)

  // simple function to remove an attribute
  function removeAttribute(index) {
    let newAttributes = []
    for (let i = 0; i < attributes.length; i++) {
      if (i == index) {
        continue
      }

      newAttributes.push(attributes[i])
    }
    setAttributes(newAttributes)
  }

  async function uploadImage(event) {
    try {
      setUploading(true)

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error('You must select an image to upload.')
      }

      const file = event.target.files[0]
      const added = await client.add(file)
      const url = `https://ipfs.infura.io/ipfs/${added.path}`
      setImageUrl(url)
    } catch (error) {
      alert(error.message)
    } finally {
      setUploading(false)
    }
  }

  async function createNft() {
    // all data is required to create an NFT
    if (!name && !description && attributes.length === 0 && !imageUrl) {
      return
    }

    // collect all data into an object
    const data = {
      name,
      image: imageUrl,
      description,
      attributes,
    }

    try {
      setLoading(true)
      // we parse the data as JSON before uploading it to IPFS
      const added = await client.add(JSON.stringify(data))
      const url = `https://ipfs.infura.io/ipfs/${added.path}`

      // get the minting fee to mint the NFT
      let fee = await simpleMint.fee()
      fee = fee.toString()

      // wait till the transaction is confirmed
      const tx = await simpleMint.safeMint(url, { value: fee })
      await tx.wait()

      router.push('/')
    } catch (error) {
      alert(error.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div>
      <Head>
        <title>Create | Simple Mint</title>
        <meta name="description" content="NFT minting Dapp" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      {loading && <Loading text="Processing" />}

      <div className="px-3 md:px-6">
        <h1 className="text-3xl font-bold">Create NFT</h1>
        <div className="flex flex-col space-y-6 py-12">
          <div className="flex flex-grow flex-col">
            <label
              htmlFor="name"
              className="mb-1 text-sm uppercase text-zinc-500 dark:text-zinc-300"
            >
              Image
            </label>
            <input
              className="
                block w-full cursor-pointer text-sm
                text-slate-500 file:mr-4 file:rounded-full
                file:border-0 file:bg-green-400
                file:py-2 file:px-4
                file:text-sm file:font-semibold
                file:text-white
                hover:file:bg-green-500
              "
              type="file"
              id="single"
              accept="image/*"
              onChange={uploadImage}
              disabled={uploading}
            />
          </div>
          <div className="flex flex-grow flex-col">
            <label
              htmlFor="name"
              className="mb-1 text-sm uppercase text-zinc-500 dark:text-zinc-300"
            >
              Loaded Image
            </label>
            {uploading && (
              <div className="max-w-96 flex h-72 w-full animate-pulse items-center justify-center rounded-md bg-zinc-400">
                <p>Loading...</p>
              </div>
            )}
            {!uploading && imageUrl && (
              <div className="max-w-96 relative h-72 rounded-md sm:w-72">
                <Image
                  className="rounded-md"
                  layout="fill"
                  objectFit="cover"
                  quality={100}
                  src={imageUrl}
                  alt="text"
                />
              </div>
            )}
          </div>
          <div className="flex flex-grow flex-col">
            <label
              htmlFor="name"
              className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
            >
              Name
            </label>
            <input
              className="placeholder-text-xl h-12 rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500 dark:focus:border-green-400"
              id="name"
              type="text"
              value={name}
              placeholder="Ex. Power"
              onChange={(e) => setName(e.target.value)}
            />
          </div>
          <div className="flex flex-grow flex-col">
            <label
              htmlFor="description"
              className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
            >
              Description
            </label>
            <textarea
              className="placeholder-text-xl rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500 dark:focus:border-green-400"
              id="description"
              type="text"
              value={description}
              placeholder="Ex. Lorem ipsum dolor sit amet."
              onChange={(e) => setDescription(e.target.value)}
            />
          </div>
          <div className="flex flex-grow flex-col">
            <AddAttributes
              addAttribute={(data) => setAttributes((prev) => [...prev, data])}
            />
          </div>
          <div className="flex-grow">
            <p className="pb-1 text-sm uppercase text-zinc-500 dark:text-zinc-300">
              Attributes
            </p>
            <AttributesTable
              attributes={attributes}
              removeAttribute={removeAttribute}
            />
          </div>
          <div>
            <button
              onClick={createNft}
              className="w-full cursor-pointer rounded-md bg-green-400 py-2 px-3 text-white hover:bg-green-500"
            >
              Create
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Details page

This page is really self explenatory, it fetches and display data from an specific token. This page has a parameter, the token ID, check the next docs for more information.

// pages/details/[tokenId].js
import { useContext, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import Image from 'next/image'
import axios from 'axios'

// components
import Details from '../../components/Details'
import DetailTile from '../../components/DetailTile'
import Loading from '../../components/Loading'

// store
import Web3Context from '../../store/web3Context'

export default function TokenDetails() {
  const { simpleMint, address } = useContext(Web3Context)

  const router = useRouter()
  const { tokenId } = router.query

  const [nft, setNft] = useState(null)
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    if (simpleMint && address) {
      loadNft()
    }
  }, [simpleMint, address])

  async function loadNft() {
    try {
      setLoading(true)
      const tokenURI = await simpleMint.tokenURI(tokenId)
      const metadata = await axios.get(tokenURI).then((res) => res.data)

      setNft({ metadata, tokenId })
      setLoading(false)
    } catch (err) {
      window.alert(err)
    }
  }

  return (
    <div>
      <Head>
        <title>Token: #{tokenId} | Simple Mint</title>
        <meta name="description" content="NFT minting Dapp" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      {loading && <Loading text="Loading" />}

      {nft && (
        <div className="px-3 md:px-6">
          <h1 className="text-3xl font-bold">Token: #{nft.tokenId}</h1>
          <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
            <div className="group relative h-96 rounded-md p-3 md:max-w-[100%]">
              <div className="relative h-96 rounded-md">
                <Image
                  className="rounded-md"
                  layout="fill"
                  objectFit="cover"
                  quality={100}
                  src={nft.metadata.image}
                  alt="text"
                />
              </div>
            </div>

            <div className="p-3">
              {/* NFT Name */}
              <Details summary="Name">
                <p>{nft.metadata.name}</p>
              </Details>

              {/* NFT Description */}
              <Details summary="Description">
                <p>{nft.metadata.description}</p>
              </Details>

              {/* NFT Properties, if there are no properties don't display */}
              {nft.metadata.attributes.filter(
                (attr) => attr.traitType == 'text' || attr.traitType == 'number'
              ).length !== 0 && (
                <Details summary="Properties" isGrid>
                  {nft.metadata.attributes
                    .filter(
                      (attr) =>
                        attr.traitType == 'text' || attr.traitType == 'number'
                    )
                    .map((attr) => (
                      <DetailTile title={attr.displayType} value={attr.value} />
                    ))}
                </Details>
              )}

              {/* NFT Boosts, if there are no boosts don't display */}
              {nft.metadata.attributes.filter(
                (attr) =>
                  attr.traitType == 'boost_percentage' ||
                  attr.traitType == 'boost_number'
              ).length !== 0 && (
                <Details summary="Boosts" isGrid>
                  {nft.metadata.attributes
                    .filter(
                      (attr) =>
                        attr.traitType == 'boost_percentage' ||
                        attr.traitType == 'boost_number'
                    )
                    .map((attr) => (
                      <DetailTile title={attr.displayType} value={attr.value} />
                    ))}
                </Details>
              )}
            </div>
          </div>
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

And that's all we needed to do with the front end.

Deploy

To deploy from the terminal/console, open the scripts/deploy.js file, this file will be run by Brownie ETH to deploy the contract.

from brownie import SimpleMint, accounts, network

def main():
    # requires brownie account to have been created
    if network.show_active()=='development':
        # add these accounts to metamask by importing private key
        owner = accounts[0]
        SimpleMint.deploy({'from':accounts[0]})

Enter fullscreen mode Exit fullscreen mode

After this, you can run brownie run deploy to deploy your Simple Mint smart contract, be sure to run Ganache before running this command and to change the smart contract address in the _app.js file in the front-end.

That's all you needed to deploy the simple mint program to a local blockchain, there are many tutorials on how to deploy to an actual blockchain, so if you want to do that feel free to try it out.

Thanks for reading, if you have any questions let me know in the comments.

Top comments (0)