NFT Project Series Part 10: Modifying our React Frontend

NFT Project Series Part 10: Modifying our React Frontend

Learn How to Integrate Solidity Smart Contract with React

In the last part of this project series, we completed building our Angular Web 3 NFT Minting Project. In this article, we will modify our React App we created in the 5th part of our series, to work with the Smart Contract.

What will we have at the end of this part?

This is what we will have at the end of this article. But before we start, we need one new package installed, so run:

yarn add ethers react-hot-toast

# or

npm install ethers react-hot-toast

The Modification Begins!

There are 2 main things we need to change in our app:

  1. Let our app talk to smart contract deployment code on Rinkeby Test Network rather than our API Server.

  2. Enable the tip functionality.

Let's start with our App.js file. We will add contractAddress and contractabi variable and also change isEthereum to ethereum, isConnected to connectedAccount, and add isLoading as well:

const contractAddress = '0x488295ECdFc67d1a44aF585264EF8e4EE0b0f08C';
const contractabi = contract.abi;
const [ethereum, setEthereum] = useState(undefined);
const [connectedAccount, setConnectedAccount] = useState(undefined);
const [keyboardNFTs, setKeyboardNFTs] = useState([]);
const [isLoading, setIsLoading] = useState(true);

Next, we are going to change our connectMetamask() function with:

async function connectMetamask(origin) {
        if (window.ethereum) {
            setEthereum(window.ethereum);
        }
        if (ethereum) {
            try {
                let accounts;
                if (origin === 'click') {
                    accounts = await window.ethereum.request({
                        method: 'eth_requestAccounts',
                    });
                } else {
                    accounts = await window.ethereum.request({
                        method: 'eth_accounts',
                    });
                }
                const account = accounts[0];
                if (typeof account === 'string') {
                    setConnectedAccount(account);
                    toast.success('Account connected!', {
                        ...toastOption,
                        style: {
                            background: 'green',
                            color: 'white',
                        },
                    });
                }
            } catch (err) {
                const errMessage = err.message;
                toast.error(errMessage, {
                    ...toastOption,
                    style: {
                        background: 'red',
                        color: 'white',
                    },
                });
            }
        }
}

And also change its corresponding useEffect() where we were checking for window.ethereum.

useEffect(() => {
        if (typeof window !== 'undefined' && typeof window.ethereum !== 'undefined') {
            connectMetamask();
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ethereum]);

Next we change our getKeyboardNFTs() function:

async function getKeyboardNFTs() {
        if (!ethereum) {
            console.error('Ethereum object is required to submit a tip');
            return;
        }
        if (ethereum)
            try {
                const provider = await new ethers.providers.Web3Provider(ethereum);
                const signer = provider.getSigner();
                const keyboardContract = new ethers.Contract(contractAddress, contractabi, signer);
                const keyboards = await keyboardContract.getKeyboards();
                setKeyboardNFTs(keyboards);
            } catch (err) {
                console.error(err.message);
            } finally {
                setIsLoading(false);
            }
}

Previously, we were getting our keyboards from our API Server. This time, we are getting it from our blockchain deployed smart contract address. For this, we get the provider using ethers library function and then signer and finally keyboardContract using contractAddress, contractabi, and signer.

We then call our smart-contract-method: getKeyboards() using the contract and set our keyboardNFTs to the result.

We must change our useEffect() associated with this method as:

useEffect(() => {
        getKeyboardNFTs();
        // eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectedAccount]);

When connectedAccount changes, we retrieve the keyboards again to re-render our UI state to change the "tip" and "no-tip" status.

Finally, we add a new function addContractEventListeners() to listen to our 2 new events we will be adding next in our smart contract:

const addContractEventListeners = async () => {
        if (!ethereum) {
            console.error('Ethereum object is required');
            return;
        }
        if (ethereum)
            try {
                const provider = await new ethers.providers.Web3Provider(ethereum);
                const signer = provider.getSigner();
                const keyboardContract = new ethers.Contract(contractAddress, contractabi, signer);
                if (keyboardContract && connectedAccount) {
                    keyboardContract.on('KeyboardCreated', async (keyboard) => {
                        if (connectedAccount && !addressEqual(keyboard.owner, connectedAccount)) {
                            toast('Somebody created a new keyboard!', {
                                ...toastOption,
                                id: JSON.stringify(keyboard),
                                style: {
                                    background: 'dodgerblue',
                                    color: 'white',
                                },
                            });
                        }
                        await getKeyboardNFTs();
                    });

                    keyboardContract.on('TipSent', async (recipient, amount) => {
                        if (connectedAccount && addressEqual(recipient, connectedAccount)) {
                            toast(`You received a tip of ${ethers.utils.formatEther(amount)} eth!`, {
                                ...toastOption,
                                id: recipient + amount,
                                style: {
                                    background: 'dodgerblue',
                                    color: 'white',
                                },
                            });
                        }
                    });
                }
            } catch (err) {
                console.error(err.message);
            }
    };

This monitors the events like new keyboard minting and tipping to our account. Here, we have 2 events: KeyboardCreated and TipSent. Let's add these 2 events to our smart contract end. Let's go back to our Keyboard.sol file in our smart contract and add the following code:

event KeyboardCreated(_Keyboard keyboard);
event TipSent(address recipient, uint256 amount);

function create(
        KeyboardKind _kind,
        bool _isPBT,
        string calldata _filter
    ) external {
        _Keyboard memory newKeyboard = _Keyboard({
            kind: _kind,
            isPBT: _isPBT,
            filter: _filter,
            owner: msg.sender
        });

        keyboards.push(newKeyboard);
        emit KeyboardCreated(newKeyboard);
}

function tip(uint256 _index) external payable {
        address payable owner = payable(keyboards[_index].owner);
        owner.transfer(msg.value);
        emit TipSent(owner, msg.value);
}

We added 2 events and then we are emitting these events after specific operations. This is what we catch on the frontend. So, at this point our full Keyboard.sol will look like:

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

import "hardhat/console.sol";

contract Keyboard {
    enum KeyboardKind {
        SixtyPercent,
        SeventyFivePercent,
        EightyPercent,
        Iso105
    }

    struct _Keyboard {
        KeyboardKind kind;
        // ABS = false, PBT = true
        bool isPBT;
        string filter;
        address owner;
    }

    event KeyboardCreated(_Keyboard keyboard);
    event TipSent(address recipient, uint256 amount);

    _Keyboard[] public keyboards;

    function getKeyboards() public view returns (_Keyboard[] memory) {
        return keyboards;
    }

    function create(
        KeyboardKind _kind,
        bool _isPBT,
        string calldata _filter
    ) external {
        _Keyboard memory newKeyboard = _Keyboard({
            kind: _kind,
            isPBT: _isPBT,
            filter: _filter,
            owner: msg.sender
        });

        keyboards.push(newKeyboard);
        emit KeyboardCreated(newKeyboard);
    }

    function tip(uint256 _index) external payable {
        address payable owner = payable(keyboards[_index].owner);
        owner.transfer(msg.value);
        emit TipSent(owner, msg.value);
    }
}

And our full App.js file after modification will look like:

import { useEffect, useState } from 'react';
import { ethers } from 'ethers';
import Keyboard from './components/Keyboard';
import { Link } from 'react-router-dom';
import contract from './utils/Keyboard.json';
import toast from 'react-hot-toast';
import toastOption from './utils/toast.option';

function App() {
    const contractAddress = '0x488295ECdFc67d1a44aF585264EF8e4EE0b0f08C';
    const contractabi = contract.abi;
    const [ethereum, setEthereum] = useState(undefined);
    const [connectedAccount, setConnectedAccount] = useState(undefined);
    const [keyboardNFTs, setKeyboardNFTs] = useState([]);
    const [isLoading, setIsLoading] = useState(true);

    useEffect(() => {
        if (typeof window !== 'undefined' && typeof window.ethereum !== 'undefined') {
            connectMetamask();
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [ethereum]);

    useEffect(() => {
        getKeyboardNFTs();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [connectedAccount]);

    useEffect(() => {
        addContractEventListeners();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [connectedAccount]);

    const addContractEventListeners = async () => {
        if (!ethereum) {
            console.error('Ethereum object is required');
            return;
        }
        if (ethereum)
            try {
                const provider = await new ethers.providers.Web3Provider(ethereum);
                const signer = provider.getSigner();
                const keyboardContract = new ethers.Contract(contractAddress, contractabi, signer);
                if (keyboardContract && connectedAccount) {
                    keyboardContract.on('KeyboardCreated', async (keyboard) => {
                        if (connectedAccount && !addressEqual(keyboard.owner, connectedAccount)) {
                            toast('Somebody created a new keyboard!', {
                                ...toastOption,
                                id: JSON.stringify(keyboard),
                                style: {
                                    background: 'dodgerblue',
                                    color: 'white',
                                },
                            });
                        }
                        await getKeyboardNFTs();
                    });

                    keyboardContract.on('TipSent', async (recipient, amount) => {
                        if (connectedAccount && addressEqual(recipient, connectedAccount)) {
                            toast(`You received a tip of ${ethers.utils.formatEther(amount)} eth!`, {
                                ...toastOption,
                                id: recipient + amount,
                                style: {
                                    background: 'dodgerblue',
                                    color: 'white',
                                },
                            });
                        }
                    });
                }
            } catch (err) {
                console.error(err.message);
            }
    };

    async function getKeyboardNFTs() {
        if (!ethereum) {
            console.error('Ethereum object is required to submit a tip');
            return;
        }
        if (ethereum)
            try {
                const provider = await new ethers.providers.Web3Provider(ethereum);
                const signer = provider.getSigner();
                const keyboardContract = new ethers.Contract(contractAddress, contractabi, signer);
                const keyboards = await keyboardContract.getKeyboards();
                setKeyboardNFTs(keyboards);
            } catch (err) {
                console.error(err.message);
            } finally {
                setIsLoading(false);
            }
    }

    async function connectMetamask(origin) {
        if (window.ethereum) {
            setEthereum(window.ethereum);
        }
        if (ethereum) {
            try {
                let accounts;
                if (origin === 'click') {
                    accounts = await window.ethereum.request({
                        method: 'eth_requestAccounts',
                    });
                } else {
                    accounts = await window.ethereum.request({
                        method: 'eth_accounts',
                    });
                }
                const account = accounts[0];
                if (typeof account === 'string') {
                    setConnectedAccount(account);
                    toast.success('Account connected!', {
                        ...toastOption,
                        style: {
                            background: 'green',
                            color: 'white',
                        },
                    });
                }
            } catch (err) {
                const errMessage = err.message;
                toast.error(errMessage, {
                    ...toastOption,
                    style: {
                        background: 'red',
                        color: 'white',
                    },
                });
            }
        }
    }

    const addressEqual = (owner, currentOwner) => {
        if (!owner || !currentOwner) return false;
        console.log(owner, currentOwner);
        console.log(owner.toUpperCase(), currentOwner.toUpperCase());
        console.log(owner.toUpperCase() === currentOwner.toUpperCase());
        return owner.toUpperCase() === currentOwner.toUpperCase();
    };

    if (!ethereum) {
        return (
            <main className="home">
                <h1 className="heading">Keyboard NFT Minter</h1>
                <a href="https://metamask.io" className="btn btn-black btn-link" target="_blank" rel="noreferrer">
                    Please install metamask wallet to use this app
                </a>
            </main>
        );
    }

    if (ethereum && !connectedAccount) {
        return (
            <main className="home">
                <h1 className="heading">Keyboard NFT Minter</h1>
                <button className="btn btn-black" onClick={() => connectMetamask('click')}>
                    Connect with Metamask
                </button>
            </main>
        );
    }

    if (ethereum && connectedAccount) {
        return (
            <main className="home">
                <h1 className="heading">Keyboard NFT Minter</h1>
                <Link className="btn btn-black btn-link" to="/create-nft">
                    Create new NFT
                </Link>
                <section className="nfts">
                    {keyboardNFTs.length <= 0 && isLoading && <p>Loading...</p>}
                    {keyboardNFTs.length > 0 &&
                        keyboardNFTs.map((keyboard, index) => {
                            return (
                                <Keyboard
                                    key={index}
                                    index={index}
                                    keyboard={keyboard}
                                    preview={false}
                                    ethereum={ethereum}
                                    connectedAccount={connectedAccount}
                                />
                            );
                        })}
                </section>
            </main>
        );
    }

    return null;
}

export default App;

Notice in our App.js file, we are now passing ethereum and connectedAccount in our Keyboard component. So, let's now go to our Keyboard.js component file and modify it with the following code:

import { ethers } from 'ethers';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import toastOption from '../utils/toast.option';
import contract from '../utils/Keyboard.json';

export default function Keyboard({ keyboard, preview, ethereum, connectedAccount, index }) {
    const contractAddress = '0x488295ECdFc67d1a44aF585264EF8e4EE0b0f08C';
    const contractabi = contract.abi;
    const [alt, setAlt] = useState('');
    const [imagePath, setImagePath] = useState('');
    const [style, setStyle] = useState('');
    const [isTipping, setIsTipping] = useState(false);

    useEffect(() => {
        displayImage();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [keyboard]);

    function getKindDir(kind) {
        return {
            0: 'sixty-percent',
            1: 'seventy-five-percent',
            2: 'eighty-percent',
            3: 'iso-105',
        }[kind];
    }

    function displayImage() {
        const kindDir = getKindDir(keyboard[0]);
        const filename = keyboard[1] ? 'PBT' : 'ABS';
        setImagePath(`assets/keyboards/${kindDir}/${filename}.png`);
        setAlt(
            `${kindDir} keyboard with ${filename} keys ${
                keyboard[2]
                    ? `with
${keyboard[2]}`
                    : ''
            }`
        );
        setStyle(keyboard[2]);
    }

    const tip = async (index) => {
        if (!ethereum) {
            console.error('Ethereum object is required to submit a tip');
            return;
        }
        setIsTipping(true);
        try {
            const provider = await new ethers.providers.Web3Provider(ethereum);
            const signer = await provider.getSigner();
            const keyboardContract = new ethers.Contract(contractAddress, contractabi, signer);
            const tipTxn = await keyboardContract.tip(index, { value: ethers.utils.parseEther('0.01') });
            await tipTxn.wait();
            toast.success('Tip sent to ' + keyboard.owner.toString().toUpperCase(), {
                ...toastOption,
                style: {
                    background: 'green',
                    color: 'white',
                },
            });
        } catch (err) {
            const errMessage = err.message;
            toast.error(errMessage, {
                ...toastOption,
                style: {
                    background: 'red',
                    color: 'white',
                },
            });
        } finally {
            setIsTipping(false);
        }
    };

    return (
        <div className="nft">
            {preview && <h2>Preview</h2>}
            <div className="borders">
                <img height={230} width={360} className={style} src={imagePath} alt={alt} />
            </div>
            {!preview && connectedAccount.toString().toUpperCase() !== keyboard.owner.toString().toUpperCase() && (
                <button className="btn btn-tip" onClick={tip}>
                    {isTipping ? 'Tipping 0.01 ETH...' : 'Tip'}
                </button>
            )}
            {!preview && connectedAccount.toString().toUpperCase() === keyboard.owner.toString().toUpperCase() && (
                <button className="btn btn-no-tip">You own it!</button>
            )}
        </div>
    );
}

So, here we are adding a new tip() function that tips the creator of an NFT. For that, inside the tip function, we check for ethereum object first as we need it for retrieving our provider. Then, we get provider, signer, and keyboardContract and initiate our tip transaction. We use ethers methods for doing all of it. Finally, we display success.

One thing to note here is the change in how we access keyboard properties. We shifted to array index from object property. Meaning, rather than keyboard.kind, we now have keyboard[0]. This is as per the data we get from our blockchain.

Finally, we modify our CreateNFT.js file:

import { ethers } from 'ethers';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';
import Keyboard from './components/Keyboard';
import contract from './utils/Keyboard.json';
import toastOption from './utils/toast.option';

export function CreateNFT() {
    const contractAddress = '0x488295ECdFc67d1a44aF585264EF8e4EE0b0f08C';
    const contractabi = contract.abi;
    const [ethereum, setEthereum] = useState(undefined);
    const [connectedAccount, setConnectedAccount] = useState(undefined);
    const [minting, setMinting] = useState(false);
    const navigate = useNavigate();
    const [keyboard, setKeyboard] = useState({
        keyboardKind: 0,
        keyboardType: 'pbt',
        keyboardFilter: 'none',
    });

    useEffect(() => {
        connectMetamask();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [ethereum]);

    async function connectMetamask(origin) {
        if (window.ethereum) {
            setEthereum(window.ethereum);
        }
        if (ethereum) {
            try {
                let accounts;
                if (origin === 'click') {
                    accounts = await window.ethereum.request({
                        method: 'eth_requestAccounts',
                    });
                } else {
                    accounts = await window.ethereum.request({
                        method: 'eth_accounts',
                    });
                }
                const account = accounts[0];
                if (typeof account === 'string') {
                    setConnectedAccount(account);
                }
            } catch (err) {
                const errMessage = err.message;
                toast.error(errMessage, {
                    ...toastOption,
                    style: {
                        background: 'red',
                        color: 'white',
                    },
                });
            }
        }
    }

    function change(event) {
        setKeyboard({ ...keyboard, [event.target.name]: event.target.value });
    }

    async function onSubmit(event) {
        event.preventDefault();
        if (!ethereum) {
            console.error('Ethereum object is required to create a keyboard');
            return;
        }
        setMinting(true);
        try {
            const provider = new ethers.providers.Web3Provider(ethereum);
            const signer = provider.getSigner();
            const keyboardsContract = new ethers.Contract(contractAddress, contractabi, signer);
            const createTxn = await keyboardsContract.create(keyboard.keyboardKind, keyboard.keyboardType === 'pbt' ? true : false, keyboard.keyboardFilter);
            console.log('Create transaction started...', createTxn.hash);

            await createTxn.wait();
            console.log('Created keyboard!', createTxn.hash);
            const txnHash = createTxn.hash;
            toast.success('Created keyboard! with transaction hash ' + txnHash, {
                ...toastOption,
                style: {
                    background: 'green',
                    color: 'white',
                },
            });
            navigate('/', { replace: true });
        } finally {
            setMinting(false);
        }
    }

    const keys = [];
    keys[0] = keyboard.keyboardKind;
    keys[1] = keyboard.keyboardType === 'pbt' ? true : false;
    keys[2] = keyboard.keyboardFilter;

    return (
        <>
            <form className="form" onSubmit={onSubmit}>
                <div className="form-group">
                    <label htmlFor="kind">Keyboard Kind</label>
                    <select id="kind" name="keyboardKind" onChange={change} defaultValue={keyboard.keyboardKind}>
                        <option value="0">60%</option>
                        <option value="1">75%</option>
                        <option value="2">80%</option>
                        <option value="3">ISO-105</option>
                    </select>
                </div>
                <div className="form-group">
                    <label htmlFor="type">Keyboard Type</label>
                    <select id="type" name="keyboardType" onChange={change} defaultValue={keyboard.keyboardType}>
                        <option value="abs">ABS</option>
                        <option value="pbt">PBT</option>
                    </select>
                </div>
                <div className="form-group">
                    <label htmlFor="filter">Keyboard Filter</label>
                    <select id="filter" name="keyboardFilter" onChange={change} defaultValue={keyboard.keyboardFilter}>
                        <option value="none">None</option>
                        <option value="sepia">Sepia</option>
                        <option value="grayscale">Grayscale</option>
                        <option value="invert">Invert</option>
                        <option value="hue-rotate-90">Hue Rotate (90°)</option>
                        <option value="hue-rotate-180">Hue Rotate (180°)</option>
                    </select>
                </div>
                {!minting && (
                    <button type="submit" className="btn btn-black">
                        Mint
                    </button>
                )}
                {minting && (
                    <button type="submit" className="btn btn-black">
                        Minting...
                    </button>
                )}
            </form>

            <section className="preview">
                <Keyboard preview={true} keyboard={keys} ethereum={ethereum} connectedAccount={connectedAccount} />
            </section>
        </>
    );
}

Here, we modified our submit() function. We are using our contract method create() to now mint a new NFT on the blockchain itself. We are also using react-router-dom navigate hook to redirect to our home page on success.

For the preview, we modify what we pass to our Keyboard component as:

const keys = [];
keys[0] = keyboard.keyboardKind;
keys[1] = keyboard.keyboardType === 'pbt' ? true : false;
keys[2] = keyboard.keyboardFilter;

And that's it. This takes care of our app modification. One thing we can change is now in index.css file regarding button designs and nft text-alignment:

.nft {
    text-align: center;
}

.btn {
    outline: none;
    border: none;
    padding: 1rem;
    font-size: 1.2rem;
    border-radius: 0.2rem;
    cursor: pointer;
    min-width: max-content;
}

.btn-tip {
    padding: 0.7rem;
    font-size: 0.8rem;
    font-weight: 600;
    background: #0077ff;
    color: white;
    margin-top: 1rem;
    width: 100px;
}

.btn-no-tip {
    padding: 0.7rem;
    font-size: 0.8rem;
    font-weight: 600;
    background: #008b1f;
    color: white;
    margin-top: 1rem;
    width: 100px;
    cursor: none;
}

And that's it! Now try to open the app in 2 browsers, for checking the event feature as well, with different wallet addresses, and check by creating a new keyboard from one. The other one will get the event and auto-refresh. The same is in the case of tipping.

Final Words

So, this completes our React Web 3 Minting Project. In case you want to enhance this, you can explore more of the documentation to do so. In the next article, we will take a look at how to build the same in NextJS. If you don't want to read those, you can directly go to part 13 of the article series to know some key learning points from this project.