TECHNOLOGY

How to Build a Voting dApp on Tezos - Part 2

In this tutorial, we'll build the frontend of a Voting dApp using React.

By Adebola Adeniran

TRILITECH

1,600 words, 8 minute read

Shubham Dhage K Zpr Vt Q8 L7 E Unsplash 2

If you made it through Part 1, well done! If you stumbled on this without reading Part 1, read it here now!

We’ll continue building a dApp that lets users of our app vote for their favorite football players in 2023 — the Ballon tz’or!

In this part of the tutorial, we’ll be using NextJS (a React framework), Taquito — a JavaScript framework for interacting with the Tezos blockchain and Beacon SDK — a library for connecting to wallets. We’ll be using these tools within our codebase to interact with our smart contract.

TL;DR #

Here for the code only? See the full code for this tutorial here

See the full smart contract code here

Getting Started #

To get us up and running quickly, I have set up a NextJS repo you can clone.

Run the command

git clone --branch example-branch https://github.com/onedebos/ballon-tz-or.git
&& cd ballon-tz-or

Once done, run the command npm install to make sure all the libraries we need are installed.

The commands above will initialize our project with the beacon-wallet and taquito libraries.

Libraries Breakdown #

Get Coding #

Once you’re in the root directory of our project, run npm run dev to start the server. The server should start on localhost:3000 if you have nothing else running. Navigate to localhost:3000 . You should be presented with a page like below

Connecting to an RPC Node #

We’ll need the RPC_URL and CONTRACT_ADDRESS constants to help us get rolling. The RPC_URL is the URL of a server available over the internet through which we can reach a node on the Tezos blockchain. It abstracts away the complexities of setting up, running, and connecting to our own Tezos node. If you want to learn how to set up your own node, learn more here.

We’ll create a file to hold our constants. In the src folder, create a helpers folder and a constants.js file. This way we’ll keep our files organized and readable. Next, add the following.

const RPC_URL = "https://ghostnet.ecadinfra.com";
const CONTRACT_ADDRESS = "KT1EYcft2Loc4ykQTzTzG796yviNbGMLJnoq";

export { RPC_URL, CONTRACT_ADDRESS };

We’ll be using the RPC_URL above to reach a Tezos node being run by ECAD Labs — creators of the Taquito library. We’ll connect to this Node using Taquito.

Open up the index.js file in src > pages > index.js.

Add the following to your import statements. I’ve added some comments in the code blocks to help explain what’s going on.

import { BeaconWallet } from "@taquito/beacon-wallet";
import { TezosToolkit } from "@taquito/taquito";
import { CONTRACT_ADDRESS, RPC_URL } from "../helpers/constants";
import { useEffect, useState, useRef } from "react";

Then within the Home function, add;

// Step 2 - Initialise a Tezos instance  
const Tezos = new TezosToolkit(RPC_URL);

Add the code below

 // Step 3 - Set state to display content on the screen  
  const [players, setPlayers] = useState([]); // keeps track of players in the storage  
  const [reload, setReload] = useState(false); // Keeps track of our application state  
  const [message, setMessage] = useState(""); // Keeps track of messages to show to users within the dApp  

// Step 4 - Set a wallet ref to hold the user's wallet instance later on  
  const walletRef = useRef(null);

Connecting the dApp to the User’s Wallet #

Before a user can vote in our dApp, they’d need to connect their wallet. Let’s write a simple function that allows our app to connect to the user’s wallet.

 // Step 5 - Create a ConnectWallet Function  
  const connectWallet = async () => {
    setMessage("");
    try {
      const options = {
        name: "Ballon tz'or",
        network: { type: "ghostnet" },
      };
      const wallet = new BeaconWallet(options);
      walletRef.current = wallet;
      await wallet.requestPermissions();
      Tezos.setProvider({ wallet: walletRef.current });
    } catch (error) {
      console.error(error);
      setMessage(error.message);
    }
  };

Disconnect Wallet

Now, let’s have a function to disconnectWallet

 // Step 6 - Create a function to allow users disconnect their wallet from the  
  // dApp  
  const disconnectWallet = () => {
    setMessage("");
    if (walletRef.current != "disconnected") {
      walletRef.current?.client.clearActiveAccount();
      walletRef.current = "disconnected";
      console.log("Disconnected");
    } else {
      console.log("Already disconnected");
    }
  };

Getting the List of Players from the Storage #

Next, we need to connect to the storage on the chain to get a list of players and the votes they have received.

 const getPlayers = async () => {
    try {
      const contract = await Tezos.contract.at(CONTRACT_ADDRESS);
      const storage = await contract.storage();
      const players = [];

      if (storage) {
        storage.players.forEach((value, key) => {
          players.push({
            playerId: key,
            name: value.name,
            year: value.year,
            votes: value.votes.toNumber(),
          });
        });
      }

      setPlayers(players);
      return players;
    } catch (error) {
      setMessage(error.message);
      console.log(error);
    }
  };

We’ll use this same function to reload the data displayed on the page each time a vote is cast.

Sending a Transaction #

To vote for a player, we need our dApp to send a transaction to the chain -i.e. to interact with our Smart contract. We’ll write a votePlayer function that sends our vote to the smart contract.

 // Step 8 - Create a function that calls the increase_votes entrypoint  
  // and reloads the page to update it and display the new state of the storage  
  const votePlayer = async (playerId) => {
    try {
      await connectWallet();
      const contract = await Tezos.wallet.at(CONTRACT_ADDRESS);
      const op = await contract.methods.increase_votes(playerId).send();
      setMessage("Awaiting Confirmation....");
      const hash = await op.confirmation(2);
      console.log(hash);
      if (hash) {
        setMessage("Vote Confirmed.");
        setReload(true);
      }
    } catch (error) {w
      setMessage(error.message);
      console.log(error);
    }
  };

One thing to note from above, the line const hash = await op.confirmation(2) tells our application to check for the confirmation of the transaction after 2 blocks. On Tezos, block finality is 2 — which means that after 2 blocks, you can say for certain that your transaction is complete.

Getting Players when Pages Load #

The last function we need to write is our useEffect that would run when our dApp loads to fetch the players from the storage.

 // Step 9 - Use a useEffect to call the getPlayers function when the page loads  
  // initially  
  useEffect(() => {
    getPlayers(); // run when the page loads  
    if (reload) {
      setReload(false);
    }
  }, [reload]); // if reload is true (when the transaction is confirmed), run the useEffect function again

Our useEffect function will run when the page loads to fetch the data from the storage. It’ll also run when the votePlayer function runs and we get a hash. See the votePlayer function for more.

Full Code #

Here’s the full code for our dApp’s index.js file including all the styles

import { Inter } from "next/font/google";
import Card from "@/components/Card";

// Step 1  
import { BeaconWallet } from "@taquito/beacon-wallet";
import { TezosToolkit } from "@taquito/taquito";
import { CONTRACT_ADDRESS, RPC_URL } from "@/helpers/constants";
import { useEffect, useState, useRef } from "react";

const inter = Inter({ subsets: ["latin"] });

export default function Home() {
  // Step 2 - Initialise a Tezos instance  
  const Tezos = new TezosToolkit(RPC_URL);

  // Step 3 - Set state to display content on the screen  
  const [players, setPlayers] = useState([]);
  const [reload, setReload] = useState(false);
  const [message, setMessage] = useState("");

  // Step 4 - Set a wallet ref to hold the wallet instance later  
  const walletRef = useRef(null);

  // Step 5 - Create a ConnectWallet Function  
  const connectWallet = async () => {
    setMessage("");
    try {
      const options = {
        name: "Ballon tz'or",
        network: { type: "ghostnet" },
      };
      const wallet = new BeaconWallet(options);
      walletRef.current = wallet;
      await wallet.requestPermissions();
      Tezos.setProvider({ wallet: walletRef.current });
    } catch (error) {
      console.error(error);
      setMessage(error.message);
    }
  };

  // Step 6 - Create a function to allow users disconnect their wallet from the  
  // dApp  
  const disconnectWallet = () => {
    setMessage("");
    if (walletRef.current != "disconnected") {
      walletRef.current?.client.clearActiveAccount();
      walletRef.current = "disconnected";
      console.log("Disconnected");
    } else {
      console.log("Already disconnected");
    }
  };

  // Step 7 - Create an async function that fetches the users from the storage  
  const getPlayers = async () => {
    try {
      const contract = await Tezos.contract.at(CONTRACT_ADDRESS);
      const storage = await contract.storage();
      const players = [];

      if (storage) {
        storage.players.forEach((value, key) => {
          players.push({
            playerId: key,
            name: value.name,
            year: value.year,
            votes: value.votes.toNumber(),
          });
        });
      }

      setPlayers(players);
      return players;
    } catch (error) {
      setMessage(error.message);
      console.log(error);
    }
  };

  // Step 8 - Create a function that calls the increase_votes entrypoint  
  // and reloads the page to update it and display the new state of the storage  
  const votePlayer = async (playerId) => {
    try {
      await connectWallet();
      const contract = await Tezos.wallet.at(CONTRACT_ADDRESS);
      const op = await contract.methods.increase_votes(playerId).send();
      setMessage("Awaiting Confirmation....");
      const hash = await op.confirmation(2);
      console.log(hash);
      if (hash) {
        setMessage("Vote Confirmed.");
        setReload(true);
      }
    } catch (error) {
      setMessage(error.message);
      console.log(error);
    }
  };

  // Step 9 - Use a useEffect to call the getPlayers function when the page loads  
  // initially  
  useEffect(() => {
    getPlayers();
    if (reload) {
      setReload(false);
    }
  }, [reload]);

  return (
    <main
      className={`flex min-h-screen flex-col items-center justify-between p-24 ${inter.className}`}
    >
      <div>
        <h1 className="text-2xl font-bold mb-3">Ballon Tz'or 2023</h1>
      </div>

      <div className="flex flex-col items-center w-full gap-2">
        {players?.length > 0
          ? players.map((player, index) => (
              <Card
                index={index}
                onClick={() => votePlayer(player.playerId)}
                playerId={player.playerId}
                playerName={player.name}
                votes={player.votes}
              />
            ))
          : "Loading....."}
      </div>
      <p className="my-3">
        {message ? <span className="text-gray-300">{message}</span> : ""}
      </p>
      <button
        className="mt-4 rounded-full bg-red-500 p-3 hover:bg-red-700 transition-all ease-in-out"
        onClick={() => disconnectWallet()}
      >
        Disconnect Wallet
      </button>
    </main>
  );
}