DEV Community

Cover image for Build a Decentralized Voting Dapp with Next.js, TypeScript, Tailwind CSS, and CometChat
Gospel Darlington
Gospel Darlington

Posted on

6 1 1 1

Build a Decentralized Voting Dapp with Next.js, TypeScript, Tailwind CSS, and CometChat

What you will be building, see the live demo at sepolia test net and the git repo.

Landing Page Shows All Active Polls

Shows the Voting Page

Shows Live Group Chat for discussing about the ongoing elections

Introduction

Imagine a world where technology and democracy intersect, where the power of decentralized systems meets the voice of the people. This is the future of voting, and you can help shape it.

In this guide, we will teach you how to build your own decentralized voting dapp using Next.js, TypeScript, Tailwind CSS, and CometChat. These cutting-edge technologies will allow you to create a secure, user-friendly, and engaging voting system that anyone can use.

Whether you're a coding beginner or a seasoned developer, this guide has something for you. We'll start by explaining the basics of decentralized voting and then walk you through the process of building your own dapp step-by-step.

By the end of this guide, you'll have the skills you need to create a decentralized voting dapp that can change the world.

What you'll learn

  • How to set up your development workspace for Dapp development.
  • How to use Next.js, TypeScript, Tailwind CSS, and CometChat to build a decentralized voting dapp
  • How to secure your Dapp with smart contract logics
  • How to make your Dapp user-friendly using Tailwind CSS
  • How to enhance your Dapp code base with TypeScript
  • How to integrate your Dapp with NextJs SSR
  • How to engage users with your Dapp using real-time chat

Who this guide is for
This guide is for anyone who wants to learn how to build a decentralized voting dapp. Whether you're a coding beginner or a seasoned developer, you'll find something useful in this guide.

We're excited to help you build the future of voting. Let's get started!

Prerequisites

You will need the following tools installed to build along with me:

  • Node.js
  • Yarn
  • MetaMask
  • React
  • Solidity
  • CometChat SDK
  • Tailwind CSS

Please check out this video to learn how to set up your MetaMask for this project, it will be an important step to follow through this tutorial.

Installing Dependencies

Clone the starter kit and open it in VS Code using the command below:

git clone https://github.com/Daltonic/tailwind_ethers_starter_kit dappVote
cd dappVote
Enter fullscreen mode Exit fullscreen mode

Next, update the package.json with the snippet below.

{
"name": "starter_kit",
"description": "A Next.js starter that includes all you need to build amazing projects",
"version": "1.0.0",
"private": true,
"author": "darlington gospel<darlingtongospel@gmail.com>",
"license": "MIT",
"keywords": [
"nextjs",
"starter",
"typescript"
],
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start",
"export": "next build && next export",
"lint": "next lint",
"format": "prettier --ignore-path .gitignore \"pages/**/*.+(ts|js|tsx)\" --write",
"postinstall": "husky install"
},
"lint-staged": {
"./src/**/*.{ts,js,jsx,tsx}": [
"yarn lint --fix",
"yarn format"
]
},
"dependencies": {
"@cometchat-pro/chat": "3.0.13",
"@headlessui/react": "1.7.17",
"@openzeppelin/test-helpers": "0.5.16",
"@reduxjs/toolkit": "1.9.3",
"ethers": "^5.4.7",
"next": "13.1.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "4.8.0",
"react-identicons": "1.2.5",
"react-redux": "8.0.5",
"react-toastify": "9.1.2",
"sharp": "0.32.5"
},
"devDependencies": {
"@emotion/react": "11.10.5",
"@emotion/styled": "11.10.5",
"@ethersproject/abi": "^5.4.7",
"@ethersproject/providers": "^5.4.7",
"@faker-js/faker": "7.6.0",
"@nomicfoundation/hardhat-chai-matchers": "^1.0.0",
"@nomicfoundation/hardhat-network-helpers": "^1.0.0",
"@nomicfoundation/hardhat-toolbox": "^2.0.0",
"@nomiclabs/hardhat-ethers": "^2.0.0",
"@nomiclabs/hardhat-etherscan": "^3.0.0",
"@nomiclabs/hardhat-waffle": "2.0.3",
"@openzeppelin/contracts": "4.8.1",
"@typechain/ethers-v5": "^10.1.0",
"@typechain/hardhat": "^6.1.2",
"@types/node": "18.11.18",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"@typescript-eslint/eslint-plugin": "5.48.1",
"@typescript-eslint/parser": "5.48.1",
"autoprefixer": "10.4.13",
"chai": "^4.2.0",
"dotenv": "16.0.3",
"eslint": "8.32.0",
"eslint-config-alloy": "4.9.0",
"eslint-config-next": "13.1.2",
"hardhat": "2.12.7",
"hardhat-gas-reporter": "^1.0.8",
"husky": "8.0.3",
"lint-staged": "13.1.0",
"postcss": "8.4.21",
"prettier": "2.8.3",
"solidity-coverage": "^0.8.0",
"tailwindcss": "3.2.4",
"typechain": "^8.1.0",
"typescript": "4.9.4"
}
}
view raw package.json hosted with ❤ by GitHub

Next, run the command yarn install in your terminal to install the dependencies for this project.

Configuring CometChat SDK

To configure the CometChat SDK, please follow the steps provided below. Once completed, make sure to save the generated keys as environment variables for future use.

STEP 1:
Head to CometChat Dashboard and create an account.

Register a new CometChat account if you do not have one

STEP 2:
Log in to the CometChat dashboard, only after registering.

Log in to the CometChat Dashboard with your created account

STEP 3:
From the dashboard, add a new app called Play-To-Earn.

Create a new CometChat app - Step 1

Create a new CometChat app - Step 2

STEP 4:
Select the app you just created from the list.

Select your created app

From the Quick Start copy the APP_ID, REGION, and AUTH_KEY, to your .env file. See the image and code snippet.

Copy the the APP_ID, REGION, and AUTH_KEY

Replace the NEXT_PUBLIC_COMET_CHAT placeholder keys with their appropriate values.

NEXT_PUBLIC_COMET_CHAT_APP_ID=****************
NEXT_PUBLIC_COMET_CHAT_AUTH_KEY=******************************
NEXT_PUBLIC_COMET_CHAT_REGION=**
Enter fullscreen mode Exit fullscreen mode

The .env file should be created at the root of your project.

Configuring the Hardhat script

Navigate to the root directory of the project and open the "hardhat.config.js" file. Replace the existing content of the file with the provided settings.

require('@nomiclabs/hardhat-waffle')
require('dotenv').config()
module.exports = {
defaultNetwork: 'localhost',
networks: {
localhost: {
url: 'http://127.0.0.1:8545',
},
},
solidity: {
version: '0.8.11',
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
paths: {
sources: './src/contracts',
artifacts: './src/abis',
},
mocha: {
timeout: 40000,
},
}

This code configures Hardhat for your project. It does this by importing essential plugins, setting up networks (with localhost as the default), specifying the Solidity compiler version, defining paths for contracts and artifacts, and setting a timeout for Mocha tests.

The Smart Contract File

The subsequent sections outline the process of crafting the smart contract file for the DappVotes project. Before you delve into the steps below, create a new folder called contracts at the root of this project and a new file inside of it called DappVotes.sol .

Next, do the following inside the just created file:

  1. Begin by initiating a new contract named DappVotes, adhering to the MIT licensing standards.
  2. Employ the Counters library from OpenZeppelin to facilitate the management of counters for polls and contestants.
  3. Define two Counter instances: totalPolls and totalContestants, privately tracking the overall count of polls and contestants.

Here is the smart contract logic and we will look at what each function and variables do.

//SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
import '@openzeppelin/contracts/utils/Counters.sol';
contract DappVotes {
using Counters for Counters.Counter;
Counters.Counter private totalPolls;
Counters.Counter private totalContestants;
struct PollStruct {
uint id;
string image;
string title;
string description;
uint votes;
uint contestants;
bool deleted;
address director;
uint startsAt;
uint endsAt;
uint timestamp;
address[] voters;
string[] avatars;
}
struct ContestantStruct {
uint id;
string image;
string name;
address voter;
uint votes;
address[] voters;
}
mapping(uint => bool) pollExist;
mapping(uint => PollStruct) polls;
mapping(uint => mapping(address => bool)) voted;
mapping(uint => mapping(address => bool)) contested;
mapping(uint => mapping(uint => ContestantStruct)) contestants;
event Voted(address indexed voter, uint timestamp);
function createPoll(
string memory image,
string memory title,
string memory description,
uint startsAt,
uint endsAt
) public {
require(bytes(title).length > 0, 'Title cannot be empty');
require(bytes(description).length > 0, 'Description cannot be empty');
require(bytes(image).length > 0, 'Image URL cannot be empty');
require(startsAt > 0, 'Start date must be greater than 0');
require(endsAt > startsAt, 'End date must be greater than start date');
totalPolls.increment();
PollStruct memory poll;
poll.id = totalPolls.current();
poll.title = title;
poll.description = description;
poll.image = image;
poll.startsAt = startsAt;
poll.endsAt = endsAt;
poll.director = msg.sender;
poll.timestamp = currentTime();
polls[poll.id] = poll;
pollExist[poll.id] = true;
}
function updatePoll(
uint id,
string memory image,
string memory title,
string memory description,
uint startsAt,
uint endsAt
) public {
require(pollExist[id], 'Poll not found');
require(polls[id].director == msg.sender, 'Unauthorized entity');
require(bytes(title).length > 0, 'Title cannot be empty');
require(bytes(description).length > 0, 'Description cannot be empty');
require(bytes(image).length > 0, 'Image URL cannot be empty');
require(!polls[id].deleted, 'Polling already deleted');
require(polls[id].votes < 1, 'Poll has votes already');
require(endsAt > startsAt, 'End date must be greater than start date');
polls[id].title = title;
polls[id].description = description;
polls[id].startsAt = startsAt;
polls[id].endsAt = endsAt;
polls[id].image = image;
}
function deletePoll(uint id) public {
require(pollExist[id], 'Poll not found');
require(polls[id].director == msg.sender, 'Unauthorized entity');
require(polls[id].votes < 1, 'Poll has votes already');
polls[id].deleted = true;
}
function getPoll(uint id) public view returns (PollStruct memory) {
return polls[id];
}
function getPolls() public view returns (PollStruct[] memory Polls) {
uint available;
for (uint i = 1; i <= totalPolls.current(); i++) {
if(!polls[i].deleted) available++;
}
Polls = new PollStruct[](available);
uint index;
for (uint i = 1; i <= totalPolls.current(); i++) {
if(!polls[i].deleted) {
Polls[index++] = polls[i];
}
}
}
function contest(uint id, string memory name, string memory image) public {
require(pollExist[id], 'Poll not found');
require(bytes(name).length > 0, 'name cannot be empty');
require(bytes(image).length > 0, 'image cannot be empty');
require(polls[id].votes < 1, 'Poll has votes already');
require(!contested[id][msg.sender], 'Already contested');
totalContestants.increment();
ContestantStruct memory contestant;
contestant.name = name;
contestant.image = image;
contestant.voter = msg.sender;
contestant.id = totalContestants.current();
contestants[id][contestant.id] = contestant;
contested[id][msg.sender] = true;
polls[id].avatars.push(image);
polls[id].contestants++;
}
function getContestant(uint id, uint cid) public view returns (ContestantStruct memory) {
return contestants[id][cid];
}
function getContestants(uint id) public view returns (ContestantStruct[] memory Contestants) {
uint available;
for (uint i = 1; i <= totalContestants.current(); i++) {
if(contestants[id][i].id == i) available++;
}
Contestants = new ContestantStruct[](available);
uint index;
for (uint i = 1; i <= totalContestants.current(); i++) {
if(contestants[id][i].id == i) {
Contestants[index++] = contestants[id][i];
}
}
}
function vote(uint id, uint cid) public {
require(pollExist[id], 'Poll not found');
require(!voted[id][msg.sender], 'Already voted');
require(!polls[id].deleted, 'Polling not available');
require(polls[id].contestants > 1, 'Not enough contestants');
require(
currentTime() >= polls[id].startsAt && currentTime() < polls[id].endsAt,
'Voting must be in session'
);
polls[id].votes++;
polls[id].voters.push(msg.sender);
contestants[id][cid].votes++;
contestants[id][cid].voters.push(msg.sender);
voted[id][msg.sender] = true;
emit Voted(msg.sender, currentTime());
}
function currentTime() internal view returns (uint256) {
return (block.timestamp * 1000) + 1000;
}
}
view raw DappVotes.sol hosted with ❤ by GitHub

The DappVotes contract comprises essential structures that underlie its functionality:

  1. PollStruct: A structure encapsulating details of a poll, including its ID, image URL, title, description, vote count, contestant count, deletion status, director address, start and end timestamps, and more.
  2. ContestantStruct: This structure holds information about contestants, such as their ID, image URL, name, associated voter, vote count, and an array of voter addresses.

Mappings play a crucial role in managing the contract's data:

  1. pollExist: A mapping linking poll IDs to a boolean value indicating their existence.
  2. polls: A mapping connecting poll IDs to their respective PollStruct data, recording comprehensive poll information.
  3. voted: A mapping linking poll IDs and voter addresses to indicate whether a voter has cast their vote.
  4. contested: A mapping connecting poll IDs and contestant addresses to indicate whether a contestant has contested.
  5. contestants: A nested mapping associating poll IDs and contestant IDs to their respective ContestantStruct data, storing contestant-related details.

To facilitate user interaction and transparency, the contract emits the Voted event whenever a vote is cast, capturing the voter's address and the current timestamp.

The contract consists of various functions that enable the creation, management, and retrieval of poll and contestant data:

  1. createPoll: This function facilitates the creation of a new poll by initializing a PollStruct with relevant information, ensuring the provided data is valid.
  2. updatePoll: Allows the director of a poll to update its details, ensuring authorization and valid input.
  3. deletePoll: Enables the director of a poll to mark it as deleted, given certain conditions are met.
  4. getPoll: Retrieves detailed information about a specific poll using its ID.
  5. getPolls: Returns an array of active polls, excluding deleted ones.
  6. contest: Enables users to contest in a poll by adding contestant information to the contract.
  7. getContestant: Retrieves detailed information about a specific contestant in a poll.
  8. getContestants: Returns an array of contestants for a particular poll.
  9. vote: Allows users to cast their votes for contestants in a poll, considering eligibility, timing, and conditions.
  10. currentTime: An internal utility function that returns the current timestamp with adjusted precision.

By following these steps, you will establish a functional structure for the DappVotes smart contract, ready to manage polls, contestants, and voting interactions seamlessly.

Unleash your web3 potential with Dapp Mentors Academy!

  • Learn from expert blockchain developers
  • Access over 40 hours of premium content
  • Get exclusive NFT insights
  • Join our vibrant Discord community

Start your journey today for just $8.44/month!

Dapp Mentors Academy

The Test Script

The DappVotes test script has been meticulously designed to comprehensively assess and validate the functionalities and behaviors of the DappVotes smart contract. Below is a systematic breakdown of the primary tests and functions covered within the script:

  1. Test Setup:

    • Before executing any tests, the script prepares essential contract instances and sets up addresses for testing purposes.
    • It initializes parameters and variables that will be used throughout the test cases.
    • The contract is deployed, and several signers (addresses) are assigned for simulated user interactions.
  2. Poll Management:

    • This section encompasses the testing of poll creation, update, and deletion within the DappVotes smart contract.
    • Under the Success category, a series of tests evaluate different scenarios to confirm the successful execution of these poll management functions.
    • The should confirm poll creation success test validates the creation of a poll by checking the list of polls before and after creation, and confirming the attributes of the created poll.
    • The should confirm poll update success test demonstrates the successful update of a poll's attributes and validates the change.
    • The should confirm poll deletion success test ensures the proper deletion of a poll by verifying the list of polls before and after deletion, and confirming the deletion status of the poll.
  3. Poll Management Failure:

    • This section encompasses negative test scenarios where poll creation, update, and deletion should fail.
    • The tests under Failure confirm that the contract correctly reverts with appropriate error messages when invalid or unauthorized actions are attempted.
  4. Poll Contest:

    • Within this section, the test cases focus on contesting in a poll, which involves entering as a contestant.
    • Under Success, the should confirm contest entry success test verifies that contestants can successfully enter a poll, and the number of contestants is accurately recorded. It also checks the retrieval of contestants' information.
    • The Failure tests address scenarios where contest entry should fail, such as attempting to contest a non-existent poll or submitting incomplete data.
  5. Poll Voting:

    • This section evaluates the process of casting votes in a poll for specific contestants.
    • Under Success, the should confirm contest entry success test demonstrates successful voting by contestants and validates the accuracy of vote counts, voter addresses, and associated avatars.
    • The Failure tests address scenarios where voting should fail, such as trying to vote in a non-existent poll or voting in a deleted poll.

Through this organized and detailed breakdown, the critical functionalities of the DappVotes test script are explained, illustrating the purpose and expected outcomes of each test scenario. The test script, when executed, will comprehensively validate the behavior of the DappVotes smart contract.

At the root of the project, create a folder if not existing called “test”, copy and paste the code below inside of it.

const { expect } = require('chai')
const { expectRevert } = require('@openzeppelin/test-helpers')
describe('Contracts', () => {
let contract, result
const description = 'Lorem Ipsum'
const title = 'Republican Primary Election'
const image = 'https://image.png'
const starts = Date.now() - 10 * 60 * 1000
const ends = Date.now() + 10 * 60 * 1000
const pollId = 1
const contestantId = 1
const avater1 = 'https://avatar1.png'
const name1 = 'Nebu Ballon'
const avater2 = 'https://avatar2.png'
const name2 = 'Kad Neza'
beforeEach(async () => {
const Contract = await ethers.getContractFactory('DappVotes')
;[deployer, contestant1, contestant2, voter1, voter2, voter3] = await ethers.getSigners()
contract = await Contract.deploy()
await contract.deployed()
})
describe('Poll Management', () => {
describe('Success', () => {
it('should confirm poll creation success', async () => {
result = await contract.getPolls()
expect(result).to.have.lengthOf(0)
await contract.createPoll(image, title, description, starts, ends)
result = await contract.getPolls()
expect(result).to.have.lengthOf(1)
result = await contract.getPoll(pollId)
expect(result.title).to.be.equal(title)
expect(result.director).to.be.equal(deployer.address)
})
it('should confirm poll update success', async () => {
await contract.createPoll(image, title, description, starts, ends)
result = await contract.getPoll(pollId)
expect(result.title).to.be.equal(title)
await contract.updatePoll(pollId, image, 'New Title', description, starts, ends)
result = await contract.getPoll(pollId)
expect(result.title).to.be.equal('New Title')
})
it('should confirm poll deletion success', async () => {
await contract.createPoll(image, title, description, starts, ends)
result = await contract.getPolls()
expect(result).to.have.lengthOf(1)
result = await contract.getPoll(pollId)
expect(result.deleted).to.be.equal(false)
await contract.deletePoll(pollId)
result = await contract.getPolls()
expect(result).to.have.lengthOf(0)
result = await contract.getPoll(pollId)
expect(result.deleted).to.be.equal(true)
})
})
describe('Failure', () => {
it('should confirm poll creation failures', async () => {
await expectRevert(
contract.createPoll('', title, description, starts, ends),
'Image URL cannot be empty'
)
await expectRevert(
contract.createPoll(image, title, description, 0, ends),
'Start date must be greater than 0'
)
})
it('should confirm poll update failures', async () => {
await expectRevert(
contract.updatePoll(100, image, 'New Title', description, starts, ends),
'Poll not found'
)
})
it('should confirm poll deletion failures', async () => {
await expectRevert(contract.deletePoll(100), 'Poll not found')
})
})
})
describe('Poll Contest', () => {
beforeEach(async () => {
await contract.createPoll(image, title, description, starts, ends)
})
describe('Success', () => {
it('should confirm contest entry success', async () => {
result = await contract.getPoll(pollId)
expect(result.contestants.toNumber()).to.be.equal(0)
await contract.connect(contestant1).contest(pollId, name1, avater1)
await contract.connect(contestant2).contest(pollId, name2, avater2)
result = await contract.getPoll(pollId)
expect(result.contestants.toNumber()).to.be.equal(2)
result = await contract.getContestants(pollId)
expect(result).to.have.lengthOf(2)
})
})
describe('Failure', () => {
it('should confirm contest entry failure', async () => {
await expectRevert(contract.contest(100, name1, avater1), 'Poll not found')
await expectRevert(contract.contest(pollId, '', avater1), 'name cannot be empty')
await contract.connect(contestant1).contest(pollId, name1, avater1)
await expectRevert(
contract.connect(contestant1).contest(pollId, name1, avater1),
'Already contested'
)
})
})
})
describe('Poll Voting', () => {
beforeEach(async () => {
await contract.createPoll(image, title, description, starts, ends)
await contract.connect(contestant1).contest(pollId, name1, avater1)
await contract.connect(contestant2).contest(pollId, name2, avater2)
})
describe('Success', () => {
it('should confirm contest entry success', async () => {
result = await contract.getPoll(pollId)
expect(result.votes.toNumber()).to.be.equal(0)
await contract.connect(contestant1).vote(pollId, contestantId)
await contract.connect(contestant2).vote(pollId, contestantId)
result = await contract.getPoll(pollId)
expect(result.votes.toNumber()).to.be.equal(2)
expect(result.voters).to.have.lengthOf(2)
expect(result.avatars).to.have.lengthOf(2)
result = await contract.getContestants(pollId)
expect(result).to.have.lengthOf(2)
result = await contract.getContestant(pollId, contestantId)
expect(result.voters).to.have.lengthOf(2)
expect(result.voter).to.be.equal(contestant1.address)
})
})
describe('Failure', () => {
it('should confirm contest entry failure', async () => {
await expectRevert(contract.vote(100, contestantId), 'Poll not found')
await contract.deletePoll(pollId)
await expectRevert(contract.vote(pollId, contestantId), 'Polling not available')
})
})
})
})

Run the following commands to have your local blockchain server running and also test the smart contract:

  • yarn hardhat node
  • yarn hardhat test

By running these commands on two separate terminals, you will test out all the essential function of this smart contract.

The Deployment Script

The DappVotes deployment script is designed to deploy the DappVotes smart contract to the Ethereum network using the Hardhat development environment. Here's an overview of the script:

  1. Import Statements:

    • The script imports the required dependencies, including ethers for Ethereum interaction and fs for file system operations.
  2. main() Function:

    • The main() function serves as the entry point for the deployment script and is defined as an asynchronous function.
  3. Deployment Parameters:

    • The script specifies the contract_name parameter, which represents the name of the smart contract to be deployed.
  4. Contract Deployment:

    • The script uses the ethers.getContractFactory() method to obtain the contract factory for the specified contract_name.
    • It deploys the contract by invoking the deploy() method on the contract factory, which returns a contract instance.
    • The deployed contract instance is stored in the contract variable.
  5. Contract Deployment Confirmation:

    • The script waits for the deployment to be confirmed by awaiting the deployed() function on the contract instance.
  6. Writing Contract Address to File:

    • The script generates a JSON object containing the deployed contract address.
    • It writes this JSON object to a file named contractAddress.json in the artifacts directory, creating the directory if it doesn't exist.
    • Any errors during the file writing process are caught and logged.
  7. Logging Deployed Contract Address:

    • If the contract deployment and file writing processes are successful, the deployed contract address is logged to the console.
  8. Error Handling:

    • Any errors that occur during the deployment process or file writing are caught and logged to the console.
    • The process exit code is set to 1 to indicate that an error occurred.

This DappVotes deployment script streamlines the process of deploying the smart contract and generates a JSON file containing the deployed contract address for further utilization within the project.

In the root of the project, create a folder called “scripts” and another file inside of it called deploy.js if it doesn’t yet exist. Copy and paste the code below inside of it.

const { ethers } = require('hardhat')
const fs = require('fs')
async function main() {
const contract_name = 'DappVotes'
const Contract = await ethers.getContractFactory(contract_name)
const contract = await Contract.deploy()
await contract.deployed()
const address = JSON.stringify({ address: contract.address }, null, 4)
fs.writeFile('./artifacts/contractAddress.json', address, 'utf8', (err) => {
if (err) {
console.error(err)
return
}
console.log('Deployed contract address', contract.address)
})
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
view raw deploy.js hosted with ❤ by GitHub

To execute the script, run yarn hardhat run scripts/deploy.js in the terminal, ensure that your blockchain node is already running in another terminal.

Activities of Deployment on the Terminal

If you require additional support with setting up Hardhat or deploying your Fullstack DApp, I recommend watching this informative video that provides guidance and instructions.

Developing the Frontend

To start developing the frontend of our application, we will create a new folder called components at the root of this project. This folder will hold all the components needed for our project.

For each of the components listed below, you will need to create a corresponding file inside the components folder and paste its codes inside it.

The Navbar Component

The Navabar Component

The Navbar component provides navigation and wallet connection. It displays a navigation bar with the title "DappVotes" and a button to connect a wallet. The button changes appearance on hover. The component uses Redux and blockchain services for wallet connectivity and state management. Observe the codes below:

import { connectWallet, truncate } from '@/services/blockchain'
import { RootState } from '@/utils/types'
import Link from 'next/link'
import React from 'react'
import { useSelector } from 'react-redux'
const Navbar = () => {
const { wallet } = useSelector((states: RootState) => states.globalStates)
return (
<nav
className="h-[80px] flex justify-between items-center border border-gray-400
px-5 rounded-full"
>
<Link href="/" className="text-[20px] text-blue-800 sm:text-[24px]">
Dapp<span className="text-white font-bold">Votes</span>
</Link>
{wallet ? (
<button
className="h-[48px] w-[130px]
sm:w-[148px] px-3 rounded-full text-sm font-bold
transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"
>
{truncate({ text: wallet, startChars: 4, endChars: 4, maxLength: 11 })}
</button>
) : (
<button
className="h-[48px] w-[130px]
sm:w-[148px] px-3 rounded-full text-sm font-bold
transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"
onClick={connectWallet}
>
Connect wallet
</button>
)}
</nav>
)
}
export default Navbar
view raw Navbar.tsx hosted with ❤ by GitHub

Banner Component

The Banner Component

The Banner component is a React component that displays a centered banner with a main title and description. The title emphasizes the concept of "Vote Without Rigging." The description explains the nature of a beauty pageantry competition.

Below the description, there's a button labeled "Create poll." Upon clicking this button, if a wallet is connected, it triggers an action to open a create poll modal. If no wallet is connected, a warning toast is shown, reminding the user to connect their wallet. The component uses Redux for state management, and React Toastify for displaying notifications. See the code below:

import { globalActions } from '@/store/globalSlices'
import { RootState } from '@/utils/types'
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { toast } from 'react-toastify'
const Banner = () => {
const dispatch = useDispatch()
const { setCreateModal } = globalActions
const { wallet } = useSelector((states: RootState) => states.globalStates)
const onPressCreate = () => {
if (wallet === '') return toast.warning('Connect wallet first!')
dispatch(setCreateModal('scale-100'))
}
return (
<main className="mx-auto text-center space-y-8">
<h1 className="text-[45px] font-[600px] text-center leading-none">Vote Without Rigging</h1>
<p className="text-[16px] font-[500px] text-center">
A beauty pageantry is a competition that has traditionally focused on judging and ranking
the physical...
</p>
<button
className="text-black h-[45px] w-[148px] rounded-full transition-all duration-300
border border-gray-400 bg-white hover:bg-opacity-20 hover:text-white"
onClick={onPressCreate}
>
Create poll
</button>
</main>
)
}
export default Banner
view raw Banner.tsx hosted with ❤ by GitHub

Create Poll Component

The Create Poll Component

The CreatePoll component presents a modal form for users to create polls. It collects inputs for poll details such as title, start and end dates, banner URL, and description. Upon submission, it validates, converts data, displays creation status with animations, and handles errors. The user-friendly interface aids in creating polls within the application. See the code below:

import { createPoll } from '@/services/blockchain'
import { globalActions } from '@/store/globalSlices'
import { PollParams, RootState } from '@/utils/types'
import React, { ChangeEvent, FormEvent, useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useDispatch, useSelector } from 'react-redux'
import { toast } from 'react-toastify'
const CreatePoll: React.FC = () => {
const dispatch = useDispatch()
const { setCreateModal } = globalActions
const { wallet, createModal } = useSelector((states: RootState) => states.globalStates)
const [poll, setPoll] = useState<PollParams>({
image: '',
title: '',
description: '',
startsAt: '',
endsAt: '',
})
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!poll.image || !poll.title || !poll.description || !poll.startsAt || !poll.endsAt) return
if (wallet === '') return toast.warning('Connect wallet first!')
poll.startsAt = new Date(poll.startsAt).getTime()
poll.endsAt = new Date(poll.endsAt).getTime()
await toast.promise(
new Promise<void>((resolve, reject) => {
createPoll(poll)
.then((tx) => {
closeModal()
console.log(tx)
resolve(tx)
})
.catch((error) => reject(error))
}),
{
pending: 'Approve transaction...',
success: 'Poll created successfully 👌',
error: 'Encountered error 🤯',
}
)
}
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setPoll((prevState) => ({
...prevState,
[name]: value,
}))
}
const closeModal = () => {
dispatch(setCreateModal('scale-0'))
setPoll({
image: '',
title: '',
description: '',
startsAt: '',
endsAt: '',
})
}
return (
<div
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center
bg-black bg-opacity-50 transform z-50 transition-transform duration-300 ${createModal}`}
>
<div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] rounded-xl w-11/12 md:w-2/5 h-7/12 p-6">
<div className="flex flex-col">
<div className="flex flex-row justify-between items-center">
<p className="font-semibold">Add Poll</p>
<button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">
<FaTimes />
</button>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-col justify-center items-start rounded-xl mt-5 mb-5"
>
<div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">
<input
placeholder="Poll Title"
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="title"
value={poll.title}
onChange={handleChange}
required
/>
</div>
<div
className="py-4 w-full border border-[#212D4A] rounded-full
flex items-center px-4 mb-3 mt-2 space-x-2 relative"
>
<span
className="bg-[#1B5CFE] bg-opacity-20 text-[#4C6AD7]
absolute left-[2.5px] py-3 rounded-full px-5 w-48"
>
<span className="text-transparent">.</span>
</span>
<input
className="bg-transparent outline-none w-full placeholder-transparent text-sm"
name="startsAt"
type="datetime-local"
placeholder="Start Date"
value={poll.startsAt}
onChange={handleChange}
required
/>
</div>
<div
className="py-4 w-full border border-[#212D4A] rounded-full
flex items-center px-4 mb-3 mt-2 space-x-2 relative"
>
<span
className="bg-[#1B5CFE] bg-opacity-20 text-[#4C6AD7]
absolute left-[2.5px] py-3 rounded-full px-5 w-48"
>
<span className="text-transparent">.</span>
</span>
<input
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="endsAt"
type="datetime-local"
value={poll.endsAt}
onChange={handleChange}
required
/>
</div>
<div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">
<input
placeholder="Banner URL"
type="url"
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="image"
accept="image/*"
value={poll.image}
onChange={handleChange}
required
/>
</div>
<div className="py-4 w-full border border-[#212D4A] rounded-xl flex items-center px-4 h-20 mt-2">
<textarea
placeholder="Poll Description"
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="description"
value={poll.description}
onChange={handleChange}
required
/>
</div>
<button
className="h-[48px] w-full block mt-2 px-3 rounded-full text-sm font-bold
transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"
>
Create Poll
</button>
</form>
</div>
</div>
</div>
)
}
export default CreatePoll
view raw CreatePoll.tsx hosted with ❤ by GitHub

Polls Component

Polls Component

The Polls component displays a list of polls in a grid layout. Each poll includes the title, truncated description, start date, poll director's address, and an "Enter" button. Poll avatars are displayed alongside the information. Clicking the "Enter" button redirects the user to a detailed page for the poll. The component also uses helper functions for formatting and truncating text. The Polls component takes an array of PollStruct objects as a prop and maps through them to create individual Poll components with the relevant poll data. See the codes below:

/* eslint-disable @next/next/no-img-element */
import { formatDate, truncate } from '@/services/blockchain'
import { PollStruct } from '@/utils/types'
import { useRouter } from 'next/router'
import React from 'react'
const Polls: React.FC<{ polls: PollStruct[] }> = ({ polls }) => {
return (
<div>
<h1 className="text-center text-[34px] font-[550px] mb-5">Start Voting</h1>
<div className="grid grid-cols-1 xl:grid-cols-2 pb-7 gap-[62px] sm:w-2/3 xl:w-5/6 mx-auto">
{polls.map((poll, i) => (
<Poll key={i} poll={poll} />
))}
</div>
</div>
)
}
const Poll: React.FC<{ poll: PollStruct }> = ({ poll }) => {
const navigate = useRouter()
return (
<div className="grid grid-cols-1 md:grid-cols-2 mx-auto w-full">
<div
className="h-[392px] gap-[10px] md:w-[580px] md:h-[280px]
grid grid-cols-1 md:flex justify-start w-full"
>
<div className="w-full flex justify-between space-y-0 sm:space-y-2 sm:flex-col md:w-[217px]">
{[...poll.avatars, '/assets/images/question.jpeg', '/assets/images/question.jpeg']
.slice(0, 2)
.map((avatar, i) => (
<img
key={i}
src={avatar}
alt={poll.title}
className="w-[160px] md:w-full
h-[135px] rounded-[20px] object-cover"
/>
))}
</div>
<div
className="w-full h-[257px] gap-[14px] rounded-[24px] space-y-5
md:w-[352px] md:h-[280px] bg-[#151515] px-[15px] py-[18px] md:px-[22px]"
>
<h1 className="text-[18px] font-[600px] capitalize">
{truncate({ text: poll.title, startChars: 30, endChars: 0, maxLength: 33 })}
</h1>
<p className="text-[14px] font-[400px]">
{truncate({ text: poll.description, startChars: 104, endChars: 0, maxLength: 107 })}
</p>
<div className="flex justify-between items-center gap-[8px]">
<div
className="h-[26px] bg-[#2c2c2c] rounded-full py-[4px] px-[12px]
text-[12px] font-[400px]"
>
{formatDate(poll.startsAt)}
</div>
<div className="h-[32px] w-[119px] gap-[5px] flex items-center">
<div className="h-[32px] w-[32px] rounded-full bg-[#2c2c2c]" />
<p className="text-[12px] font-[400px]">
{truncate({ text: poll.director, startChars: 4, endChars: 4, maxLength: 11 })}
</p>
</div>
</div>
<button
onClick={() => navigate.push('/polls/' + poll.id)}
className="h-[44px] w-full rounded-full transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"
>
Enter
</button>
</div>
</div>
</div>
)
}
export default Polls
view raw Polls.tsx hosted with ❤ by GitHub

Footer Component

The footer component

The Footer component displays social media icons and copyright information for a webpage. The icons link to LinkedIn, YouTube, GitHub, and Twitter profiles. The copyright text shows the current year and the message "With Love ❤️ by Daltonic." The component's layout is responsive and visually appealing.

view raw Footer.tsx hosted with ❤ by GitHub

Details Component

The Details Component

The Details component displays detailed information about a poll, including the poll image, title, description, start and end dates, director, vote and contestant counts, and edit and delete buttons (if the user is the director and there are no votes). It also displays a "Contest" button if there are no votes, which opens the contest modal. The component uses Redux for global state management and the Image component from Next.js for responsive images. See the image below:

import { formatDate, truncate } from '@/services/blockchain'
import { globalActions } from '@/store/globalSlices'
import { PollStruct, RootState } from '@/utils/types'
import Image from 'next/image'
import React from 'react'
import { MdModeEdit, MdDelete } from 'react-icons/md'
import { useDispatch, useSelector } from 'react-redux'
import { toast } from 'react-toastify'
const Details: React.FC<{ poll: PollStruct }> = ({ poll }) => {
const dispatch = useDispatch()
const { setContestModal, setUpdateModal, setDeleteModal } = globalActions
const { wallet } = useSelector((states: RootState) => states.globalStates)
const onPressContest = () => {
if (wallet === '') return toast.warning('Connect wallet first!')
dispatch(setContestModal('scale-100'))
}
return (
<>
<div
className="w-full h-[240px] rounded-[24px]
flex items-center justify-center overflow-hidden"
>
<Image
className="w-full h-full object-cover"
width={3000}
height={500}
src={poll.image}
alt={poll.title}
/>
</div>
<div
className="flex flex-col items-center justify-center space-y-6
mt-5 w-full md:max-w-[736px] mx-auto"
>
<h1 className="text-[47px] font-[600px] text-center leading-none">{poll.title}</h1>
<p className="text-[16px] font-[500px] text-center">{poll.description}</p>
<div className=" h-[136px] gap-[16px] flex flex-col items-center mt-4">
<div
className="h-[36px] py-[6px] px-[12px] rounded-full gap-[4px] border
border-gray-400 bg-white bg-opacity-20"
>
<p className="text-[14px] font-[500px] text-center md:text-[16px]">
{formatDate(poll.startsAt)} - {formatDate(poll.endsAt)}
</p>
</div>
<div
className="flex items-center justify-center w-[133px] h-[32px]
py-[20px] rounded-[10px] gap-[12px]"
>
<div className="w-[32px] h-[32px] rounded-full bg-[#1B5CFE]" />
<p className="text-[14px] font-[500px]">
{truncate({ text: poll.director, startChars: 4, endChars: 4, maxLength: 11 })}
</p>
</div>
<div className="h-[36px] gap-[4px] flex justify-center items-center">
<button
className="py-[6px] px-[12px] border border-gray-400 bg-white bg-opacity-20
rounded-full text-[12px] md:text-[16px]"
>
{poll.votes} votes
</button>
<button
className="py-[6px] px-[12px]
border border-gray-400 bg-white bg-opacity-20 rounded-full text-[12px] md:text-[16px]"
>
{poll.contestants} contestants
</button>
{wallet && wallet === poll.director && poll.votes < 1 && (
<button
className="py-[6px] px-[12px]
border border-gray-400 bg-white bg-opacity-20 rounded-full
text-[12px] md:text-[16px] gap-[8px] flex justify-center items-center"
onClick={() => dispatch(setUpdateModal('scale-100'))}
>
<MdModeEdit size={20} className="text-[#1B5CFE]" />
Edit poll
</button>
)}
{wallet && wallet === poll.director && poll.votes < 1 && (
<button
className="py-[6px] px-[12px]
border border-gray-400 bg-white bg-opacity-20 rounded-full
text-[12px] md:text-[16px] gap-[8px] flex justify-center items-center"
onClick={() => dispatch(setDeleteModal('scale-100'))}
>
<MdDelete size={20} className="text-[#fe1b1b]" />
Delete poll
</button>
)}
</div>
{poll.votes < 1 && (
<button
className="text-black h-[45px] w-[148px] rounded-full transition-all duration-300
border border-gray-400 bg-white hover:bg-opacity-20 hover:text-white py-2"
onClick={onPressContest}
>
Contest
</button>
)}
</div>
</div>
</>
)
}
export default Details
view raw Details.tsx hosted with ❤ by GitHub

Update Poll Component

The Update Poll Component

The UpdatePoll component presents a modal form to edit the details of an existing poll. Users can modify the poll's image, title, description, start date, and end date. The component fetches and displays the current data of the selected poll.

Upon submission, it validates inputs, updates the poll information on the blockchain, and provides transaction status feedback through toast notifications. This component efficiently interacts with blockchain services, Redux state, and user inputs to facilitate poll updates. See the code below:

import { formatTimestamp, updatePoll } from '@/services/blockchain'
import { globalActions } from '@/store/globalSlices'
import { PollParams, PollStruct, RootState } from '@/utils/types'
import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useDispatch, useSelector } from 'react-redux'
import { toast } from 'react-toastify'
const UpdatePoll: React.FC<{ pollData: PollStruct }> = ({ pollData }) => {
const dispatch = useDispatch()
const { setUpdateModal } = globalActions
const { wallet, updateModal } = useSelector((states: RootState) => states.globalStates)
const [poll, setPoll] = useState<PollParams>({
image: '',
title: '',
description: '',
startsAt: '',
endsAt: '',
})
useEffect(() => {
if (pollData) {
const { image, title, description, startsAt, endsAt } = pollData
setPoll({
image,
title,
description,
startsAt: formatTimestamp(startsAt),
endsAt: formatTimestamp(endsAt),
})
}
}, [pollData])
const handleUpdate = async (e: FormEvent) => {
e.preventDefault()
if (!poll.image || !poll.title || !poll.description || !poll.startsAt || !poll.endsAt) return
if (wallet === '') return toast.warning('Connect wallet first!')
poll.startsAt = new Date(poll.startsAt).getTime()
poll.endsAt = new Date(poll.endsAt).getTime()
await toast.promise(
new Promise<void>((resolve, reject) => {
updatePoll(pollData.id, poll)
.then((tx) => {
closeModal()
console.log(tx)
resolve(tx)
})
.catch((error) => reject(error))
}),
{
pending: 'Approve transaction...',
success: 'Poll updated successfully 👌',
error: 'Encountered error 🤯',
}
)
}
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setPoll((prevState) => ({
...prevState,
[name]: value,
}))
}
const closeModal = () => {
dispatch(setUpdateModal('scale-0'))
}
return (
<div
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center
bg-black bg-opacity-50 transform z-50 transition-transform duration-300 ${updateModal}`}
>
<div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] rounded-xl w-11/12 md:w-2/5 h-7/12 p-6">
<div className="flex flex-col">
<div className="flex flex-row justify-between items-center">
<p className="font-semibold">Edit Poll</p>
<button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">
<FaTimes />
</button>
</div>
<form
onSubmit={handleUpdate}
className="flex flex-col justify-center items-start rounded-xl mt-5 mb-5"
>
<div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">
<input
placeholder="Poll Title"
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="title"
value={poll.title}
onChange={handleChange}
required
/>
</div>
<div
className="py-4 w-full border border-[#212D4A] rounded-full
flex items-center px-4 mb-3 mt-2 space-x-2 relative"
>
<span
className="bg-[#1B5CFE] bg-opacity-20 text-[#4C6AD7]
absolute left-[2.5px] py-3 rounded-full px-5 w-48"
>
<span className="text-transparent">.</span>
</span>
<input
className="bg-transparent outline-none w-full placeholder-transparent text-sm"
name="startsAt"
type="datetime-local"
placeholder="Start Date"
value={poll.startsAt}
onChange={handleChange}
required
/>
</div>
<div
className="py-4 w-full border border-[#212D4A] rounded-full
flex items-center px-4 mb-3 mt-2 space-x-2 relative"
>
<span
className="bg-[#1B5CFE] bg-opacity-20 text-[#4C6AD7]
absolute left-[2.5px] py-3 rounded-full px-5 w-48"
>
<span className="text-transparent">.</span>
</span>
<input
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="endsAt"
type="datetime-local"
value={poll.endsAt}
onChange={handleChange}
required
/>
</div>
<div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">
<input
placeholder="Banner URL"
type="url"
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="image"
accept="image/*"
value={poll.image}
onChange={handleChange}
required
/>
</div>
<div className="py-4 w-full border border-[#212D4A] rounded-xl flex items-center px-4 h-20 mt-2">
<textarea
placeholder="Poll Description"
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="description"
value={poll.description}
onChange={handleChange}
required
/>
</div>
<button
className="h-[48px] w-full block mt-2 px-3 rounded-full text-sm font-bold
transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"
>
Update Poll
</button>
</form>
</div>
</div>
</div>
)
}
export default UpdatePoll
view raw UpdatePoll.tsx hosted with ❤ by GitHub

Delete Poll Component

The Delete Component

The DeletePoll component offers a confirmation modal to delete a specific poll. When the user clicks the delete button, the component interacts with blockchain services to delete the selected poll's data. It utilizes Redux state for user authentication and modal state management.

After deleting the poll, the component redirects the user to the home page and provides transaction status feedback through toast notifications. This component effectively handles poll deletion, interacts with blockchain services, manages modal display, and provides user notifications. See the code below:

import { deletePoll } from '@/services/blockchain'
import { globalActions } from '@/store/globalSlices'
import { PollStruct, RootState } from '@/utils/types'
import { BsTrash3Fill } from 'react-icons/bs'
import React from 'react'
import { FaTimes } from 'react-icons/fa'
import { useDispatch, useSelector } from 'react-redux'
import { toast } from 'react-toastify'
import { useRouter } from 'next/router'
const DeletePoll: React.FC<{ poll: PollStruct }> = ({ poll }) => {
const dispatch = useDispatch()
const { setDeleteModal } = globalActions
const { wallet, deleteModal } = useSelector((states: RootState) => states.globalStates)
const router = useRouter()
const handleDelete = async () => {
if (wallet === '') return toast.warning('Connect wallet first!')
await toast.promise(
new Promise<void>((resolve, reject) => {
deletePoll(poll.id)
.then((tx) => {
closeModal()
console.log(tx)
router.push('/')
resolve(tx)
})
.catch((error) => reject(error))
}),
{
pending: 'Approve transaction...',
success: 'Poll deleted successfully 👌',
error: 'Encountered error 🤯',
}
)
}
const closeModal = () => {
dispatch(setDeleteModal('scale-0'))
}
return (
<div
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center
bg-black bg-opacity-50 transform z-50 transition-transform duration-300 ${deleteModal}`}
>
<div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] rounded-xl w-11/12 md:w-2/5 h-7/12 p-6">
<div className="flex flex-col">
<div className="flex flex-row justify-between items-center">
<p className="font-semibold">Delete Poll</p>
<button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">
<FaTimes />
</button>
</div>
<div className="flex flex-col justify-center items-center rounded-xl mt-5 mb-5">
<div className="flex flex-col justify-center items-center rounded-xl my-5 space-y-2">
<BsTrash3Fill className="text-red-600" size={50} />
<h4 className="text-[22.65px]">Delete Poll</h4>
<p className="text-[14px]">Are you sure you want to delete this question?</p>
<small className="text-xs italic">{poll?.title}</small>
</div>
<button
className="h-[48px] w-full block mt-2 px-3 rounded-full text-sm font-bold
transition-all duration-300 bg-red-600 hover:bg-red-500"
onClick={handleDelete}
>
Delete Poll
</button>
</div>
</div>
</div>
</div>
)
}
export default DeletePoll
view raw DeletePoll.tsx hosted with ❤ by GitHub

Contest Poll Component

The Contest Poll Component

The ContestPoll component displays a modal form for users to enter a contest for a specific poll. Users input their contestant name and an avatar URL. Upon submission, it validates the inputs, initiates the contest transaction, and displays transaction status with animations. The component interacts with the application's blockchain services and Redux state management, providing a seamless contest entry experience. See the code below:

import { contestPoll } from '@/services/blockchain'
import { globalActions } from '@/store/globalSlices'
import { PollStruct, RootState } from '@/utils/types'
import React, { ChangeEvent, FormEvent, useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useDispatch, useSelector } from 'react-redux'
import { toast } from 'react-toastify'
const ContestPoll: React.FC<{ poll: PollStruct }> = ({ poll }) => {
const dispatch = useDispatch()
const { setContestModal } = globalActions
const { wallet, contestModal } = useSelector((states: RootState) => states.globalStates)
const [contestant, setContestant] = useState({
name: '',
image: '',
})
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setContestant((prevState) => ({
...prevState,
[name]: value,
}))
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!contestant.name || !contestant.image) return
if (wallet === '') return toast.warning('Connect wallet first!')
await toast.promise(
new Promise<void>((resolve, reject) => {
contestPoll(poll.id, contestant.name, contestant.image)
.then((tx) => {
closeModal()
console.log(tx)
resolve(tx)
})
.catch((error) => reject(error))
}),
{
pending: 'Approve transaction...',
success: 'Poll contested successfully 👌',
error: 'Encountered error 🤯',
}
)
}
const closeModal = () => {
dispatch(setContestModal('scale-0'))
setContestant({
name: '',
image: '',
})
}
return (
<div
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center
bg-black bg-opacity-50 transform z-50 transition-transform duration-300 ${contestModal}`}
>
<div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] rounded-xl w-11/12 md:w-2/5 h-7/12 p-6">
<div className="flex flex-col">
<div className="flex flex-row justify-between items-center">
<p className="font-semibold">Become a Contestant</p>
<button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">
<FaTimes />
</button>
</div>
<form
onClick={handleSubmit}
className="flex flex-col justify-center items-start rounded-xl mt-5 mb-5"
>
<div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">
<input
placeholder="Contestant Name"
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="name"
value={contestant.name}
onChange={handleChange}
required
/>
</div>
<div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">
<input
placeholder="Avater URL"
type="url"
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="image"
accept="image/*"
value={contestant.image}
onChange={handleChange}
required
/>
</div>
<button
className="h-[48px] w-full block mt-2 px-3 rounded-full text-sm font-bold
transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"
>
Contest Now
</button>
</form>
</div>
</div>
</div>
)
}
export default ContestPoll
view raw ContestPoll.tsx hosted with ❤ by GitHub

Contestants Component

The Contestant Component

The Contestants component renders a list of contestants for a poll, including their images, names, voter information, voting buttons, and vote counts. It uses the contestants array and poll object to render each contestant's information.

Each Contestant sub-component represents an individual contestant and includes the following:

  • Image: The contestant's image.
  • Name: The contestant's name.
  • Voter information: A truncated version of the voter's wallet address who voted for the contestant.
  • Voting button: A button to vote for the contestant. The button is disabled if the user has already voted, the poll has not started, or the poll has ended.
  • Vote count: The number of votes the contestant has received.

The component also includes error handling and visual feedback to prevent voting in specific scenarios. This component allows users to view and participate in the voting process for contestants in the poll. See the code below:

import { truncate, voteCandidate } from '@/services/blockchain'
import { ContestantStruct, PollStruct, RootState } from '@/utils/types'
import Image from 'next/image'
import React from 'react'
import { BiUpvote } from 'react-icons/bi'
import { useSelector } from 'react-redux'
import { toast } from 'react-toastify'
const Contestants: React.FC<{ contestants: ContestantStruct[]; poll: PollStruct }> = ({
contestants,
poll,
}) => {
return (
<div className="space-y-2">
<h1 className="text-center text-[48px] font-[600px]">Contestants</h1>
<div className="grid grid-cols-1 xl:grid-cols-2 pb-7 gap-[62px] sm:w-2/3 xl:w-11/12 mx-auto">
{contestants.map((contestant, i) => (
<Contestant poll={poll} contestant={contestant} key={i} />
))}
</div>
</div>
)
}
const Contestant: React.FC<{ contestant: ContestantStruct; poll: PollStruct }> = ({
contestant,
poll,
}) => {
const { wallet } = useSelector((states: RootState) => states.globalStates)
const voteContestant = async () => {
if (wallet === '') return toast.warning('Connect wallet first!')
await toast.promise(
new Promise<void>((resolve, reject) => {
voteCandidate(poll.id, contestant.id)
.then((tx) => {
console.log(tx)
resolve(tx)
})
.catch((error) => reject(error))
}),
{
pending: 'Approve transaction...',
success: 'Poll contested successfully 👌',
error: 'Encountered error 🤯',
}
)
}
return (
<div className="flex justify-start items-center space-x-2 md:space-x-8 mt-5 md:mx-auto">
<div className="w-[187px] sm:w-[324px] h-[229px] sm:h-[180px] rounded-[24px] overflow-hidden">
<Image
className="w-full h-full object-cover"
width={3000}
height={500}
src={contestant.image}
alt={contestant.name}
/>
</div>
<div
className="bg-[#151515] h-[229px] w-[186px] sm:w-[253px] sm:h-fit rounded-[24px]
space-y-2 flex justify-center items-center flex-col pt-2 pb-2 px-3"
>
<h1 className="text-[16px] sm:text-[20px] font-[600px] capitalize">{contestant.name}</h1>
<div
className="flex items-center justify-center w-full
rounded-[10px] space-x-2"
>
<div className="w-[32px] h-[32px] rounded-full bg-[#2C2C2C]" />
<p className="text-[14px] font-[500px]">
{truncate({ text: contestant.voter, startChars: 4, endChars: 4, maxLength: 11 })}
</p>
</div>
<button
onClick={voteContestant}
disabled={
wallet
? contestant.voters.includes(wallet) ||
Date.now() < poll.startsAt ||
Date.now() >= poll.endsAt
: true
}
className={`w-[158px] sm:w-[213px] h-[48px] rounded-[30.5px] ${
(wallet && poll.voters.includes(wallet)) ||
Date.now() < poll.startsAt ||
Date.now() >= poll.endsAt
? 'bg-[#B0BAC9] cursor-not-allowed'
: 'bg-[#1B5CFE]'
}`}
>
{wallet && contestant.voters.includes(wallet) ? 'Voted' : 'Vote'}
</button>
<div className="w-[86px] h-[32px] flex items-center justify-center gap-3">
<div className="w-[32px] h-[32px] rounded-[9px] py-[8px] px-[9px] bg-[#0E1933]">
<BiUpvote size={20} className="text-[#1B5CFE]" />
</div>
<p className="text-[14px] font-[600px]">{contestant.votes} vote</p>
</div>
</div>
</div>
)
}
export default Contestants
view raw Contestants.tsx hosted with ❤ by GitHub

Chat Button Component

The Chat Button Component

The **ChatButton** component provides a dynamic dropdown menu for various chat-related actions based on the user's authentication status and the poll's group status.

Users can perform actions like signing up, logging in, creating a group, joining a group, viewing chats, and logging out. These actions interact with CometChat services for user and group management, and the component ensures proper feedback through toast notifications.

It also uses Redux to manage global state for user and group data, enhancing user interaction with chat functionalities within the application. See the code below:

import React from 'react'
import { FaUserPlus } from 'react-icons/fa'
import { RiArrowDropDownLine } from 'react-icons/ri'
import { FiLogIn } from 'react-icons/fi'
import { HiLogin, HiUserGroup, HiChat } from 'react-icons/hi'
import { SiGnuprivacyguard } from 'react-icons/si'
import { Menu } from '@headlessui/react'
import { toast } from 'react-toastify'
import {
createNewGroup,
joinGroup,
logOutWithCometChat,
loginWithCometChat,
signUpWithCometChat,
} from '../services/chat'
import { useDispatch, useSelector } from 'react-redux'
import { globalActions } from '@/store/globalSlices'
import { PollStruct, RootState } from '@/utils/types'
const ChatButton: React.FC<{ poll: PollStruct; group: any }> = ({ poll, group }) => {
const dispatch = useDispatch()
const { setCurrentUser, setChatModal, setGroup } = globalActions
const { wallet, currentUser } = useSelector((states: RootState) => states.globalStates)
const handleSignUp = async () => {
if (wallet === '') return toast.warning('Connect wallet first!')
await toast.promise(
new Promise((resolve, reject) => {
signUpWithCometChat(wallet)
.then((user) => resolve(user))
.catch((error) => {
alert(JSON.stringify(error))
reject(error)
})
}),
{
pending: 'Signning up...',
success: 'Signed up successfully, please login 👌',
error: 'Encountered error 🤯',
}
)
}
const handleLogin = async () => {
if (wallet === '') return toast.warning('Connect wallet first!')
await toast.promise(
new Promise((resolve, reject) => {
loginWithCometChat(wallet)
.then((user) => {
dispatch(setCurrentUser(JSON.parse(JSON.stringify(user))))
resolve(user)
})
.catch((error) => {
alert(JSON.stringify(error))
reject(error)
})
}),
{
pending: 'Logging...',
success: 'Logged in successfully 👌',
error: 'Encountered error 🤯',
}
)
}
const handleLogout = async () => {
if (wallet === '') return toast.warning('Connect wallet first!')
await toast.promise(
new Promise((resolve, reject) => {
logOutWithCometChat()
.then(() => {
dispatch(setCurrentUser(null))
resolve(null)
})
.catch((error) => {
alert(JSON.stringify(error))
reject(error)
})
}),
{
pending: 'Leaving...',
success: 'Logged out successfully 👌',
error: 'Encountered error 🤯',
}
)
}
const handleCreateGroup = async () => {
if (wallet === '') return toast.warning('Connect wallet first!')
await toast.promise(
new Promise((resolve, reject) => {
createNewGroup(`guid_${poll.id}`, poll.title)
.then((group) => {
dispatch(setGroup(JSON.parse(JSON.stringify(group))))
resolve(group)
})
.catch((error) => {
alert(JSON.stringify(error))
reject(error)
})
}),
{
pending: 'Creating group...',
success: 'Group created successfully 👌',
error: 'Encountered error 🤯',
}
)
}
const handleJoinGroup = async () => {
if (wallet === '') return toast.warning('Connect wallet first!')
await toast.promise(
new Promise((resolve, reject) => {
joinGroup(`guid_${poll.id}`)
.then((group) => {
dispatch(setGroup(JSON.parse(JSON.stringify(group))))
resolve(group)
})
.catch((error) => {
alert(JSON.stringify(error))
reject(error)
})
}),
{
pending: 'Joining group...',
success: 'Group joined successfully 👌',
error: 'Encountered error 🤯',
}
)
}
return (
<Menu as="div" className="inline-block text-left mx-auto fixed right-5 bottom-[80px]">
<Menu.Button
className="bg-[#1B5CFE] hover:bg-blue-700 text-white font-bold
rounded-full transition-all duration-300 p-3 focus:outline-none
focus-visible:ring-2 focus-visible:ring-white
focus-visible:ring-opacity-75 shadow-md shadow-black"
as="button"
>
<RiArrowDropDownLine size={20} />
</Menu.Button>
<Menu.Items
className="absolute right-0 bottom-14 mt-2 w-56 origin-top-right
divide-y divide-gray-100 rounded-md bg-white shadow-lg shadow-black
ing-1 ring-black ring-opacity-5 focus:outline-none"
>
{!currentUser ? (
<>
<Menu.Item>
{({ active }) => (
<button
className={`flex justify-start items-center space-x-1 ${
active ? 'bg-gray-200 text-black' : 'text-red-500'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
onClick={handleSignUp}
>
<SiGnuprivacyguard size={17} />
<span>SignUp</span>
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
className={`flex justify-start items-center space-x-1 ${
active ? 'bg-gray-200 text-black' : 'text-gray-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
onClick={handleLogin}
>
<FiLogIn size={17} />
<span>Login</span>
</button>
)}
</Menu.Item>
</>
) : (
<>
{!group && wallet === poll.director && (
<Menu.Item>
{({ active }) => (
<button
className={`flex justify-start items-center space-x-1 ${
active ? 'bg-gray-200 text-black' : 'text-gray-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
onClick={() => handleCreateGroup()}
>
<HiUserGroup size={17} />
<span>Create Group</span>
</button>
)}
</Menu.Item>
)}
{group && !group.hasJoined && wallet !== poll.director && (
<Menu.Item>
{({ active }) => (
<button
className={`flex justify-start items-center space-x-1 ${
active ? 'bg-gray-200 text-black' : 'text-gray-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
onClick={() => handleJoinGroup()}
>
<FaUserPlus size={17} />
<span>Join Group</span>
</button>
)}
</Menu.Item>
)}
{group?.hasJoined && (
<Menu.Item>
{({ active }) => (
<button
className={`flex justify-start items-center space-x-1 ${
active ? 'bg-gray-200 text-black' : 'text-gray-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
onClick={() => dispatch(setChatModal('scale-100'))}
>
<HiChat size={17} />
<span>Chats</span>
</button>
)}
</Menu.Item>
)}
<Menu.Item>
{({ active }) => (
<button
className={`flex justify-start items-center space-x-1 ${
active ? 'bg-gray-200 text-black' : 'text-gray-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
onClick={handleLogout}
>
<HiLogin size={17} />
<span>Logout</span>
</button>
)}
</Menu.Item>
</>
)}
</Menu.Items>
</Menu>
)
}
export default ChatButton
view raw ChatButton.tsx hosted with ❤ by GitHub

Chat Modal Component

The Chat Modal Component

The ChatModal component provides a user-friendly interface for real-time chat within a group. It fetches and displays existing messages, listens for new messages, and allows users to send and receive messages. It also provides message timestamps, sender identification, and identicons. The component integrates with Redux to manage the chat modal's display state and maintains proper UX through toast notifications. See the code below:

import Identicon from 'react-identicons'
import { globalActions } from '@/store/globalSlices'
import { RootState } from '@/utils/types'
import React, { FormEvent, useEffect, useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useDispatch, useSelector } from 'react-redux'
import { truncate } from '@/services/blockchain'
import { getMessages, listenForMessage, sendMessage } from '@/services/chat'
import { toast } from 'react-toastify'
const ChatModal: React.FC<{ group: any }> = ({ group }) => {
const dispatch = useDispatch()
const { setChatModal } = globalActions
const { wallet, chatModal } = useSelector((states: RootState) => states.globalStates)
const [message, setMessage] = useState<string>('')
const [messages, setMessages] = useState<any[]>([])
const [shouldAutoScroll, setShouldAutoScroll] = useState<boolean>(true)
useEffect(() => {
const handleListing = () => {
listenForMessage(group?.guid).then((msg) => {
setMessages((prevMsgs) => [...prevMsgs, msg])
setShouldAutoScroll(true)
})
}
const handleMessageRetrieval = () => {
getMessages(group?.guid).then((msgs) => {
setMessages(msgs as any[])
setShouldAutoScroll(true)
})
}
setTimeout(async () => {
if (typeof window !== 'undefined') {
handleMessageRetrieval()
handleListing()
}
}, 500)
}, [group?.guid])
useEffect(() => {
if (shouldAutoScroll) {
scrollToEnd()
}
}, [messages, shouldAutoScroll])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!message) return
if (wallet === '') return toast.warning('Connect wallet first!')
await sendMessage(group?.guid, message)
.then((msg) => {
setMessages((prevMsgs) => [...prevMsgs, msg])
setShouldAutoScroll(true)
scrollToEnd()
setMessage('')
})
.catch((error) => console.log(error))
}
const scrollToEnd = () => {
const elmnt: HTMLElement | null = document.getElementById('messages-container')
if (elmnt) elmnt.scrollTop = elmnt.scrollHeight
}
const closeModal = () => {
dispatch(setChatModal('scale-0'))
setMessage('')
scrollToEnd()
}
return (
<div
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center
bg-black bg-opacity-50 transform z-50 transition-transform duration-300 ${chatModal}`}
>
<div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] rounded-xl w-11/12 md:w-2/5 h-7/12 p-6">
<div className="flex flex-col">
<div className="flex flex-row justify-between items-center">
<p className="font-semibold">Chat</p>
<button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">
<FaTimes />
</button>
</div>
<div
id="messages-container"
className="flex flex-col justify-center items-start rounded-xl my-5 pt-5 max-h-[20rem] overflow-y-auto"
>
<div className="py-4" />
{messages.map((msg: any, i: number) => (
<Message
text={msg.text}
owner={msg.sender.uid}
time={Number(msg.sendAt + '000')}
you={wallet === msg.sender.uid}
key={i}
/>
))}
</div>
<form
onSubmit={handleSubmit}
className="flex flex-col justify-center items-start rounded-xl mt-5 mb-5"
>
<div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">
<input
placeholder="Send message..."
className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"
name="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
required
/>
</div>
</form>
</div>
</div>
</div>
)
}
const Message = ({ text, time, owner, you }) => {
return (
<div className="flex justify-start space-x-4 px-6 mb-4 w-full">
<div className="flex justify-start items-center w-full">
<Identicon
className="w-12 h-12 rounded-full object-cover mr-4 shadow-md bg-gray-400"
string={owner}
size={30}
/>
<div className="w-full">
<h3 className="text-md font-bold">
{you ? '@You' : truncate({ text: owner, startChars: 4, endChars: 4, maxLength: 11 })}
</h3>
<p className="text-gray-500 text-xs font-semibold space-x-2 w-4/5">{text}</p>
</div>
</div>
</div>
)
}
export default ChatModal
view raw ChatModal.tsx hosted with ❤ by GitHub

CometChat NoSSR Component

Lastly for the components, is the CometChatNoSSR component which initializes CometChat and checks the user's authentication state on the client-side. It dispatches the user data to Redux for further use in the application. See the code below:

import { initCometChat, checkAuthState } from '@/services/chat'
import { useEffect } from 'react'
import { globalActions } from '@/store/globalSlices'
import { useDispatch } from 'react-redux'
const CometChatNoSSR = () => {
const { setCurrentUser } = globalActions
const dispatch = useDispatch()
useEffect(() => {
setTimeout(async () => {
if (typeof window !== 'undefined') {
await initCometChat()
checkAuthState().then((user) => {
dispatch(setCurrentUser(JSON.parse(JSON.stringify(user))))
})
}
}, 500)
}, [dispatch, setCurrentUser])
return null
}
export default CometChatNoSSR

Want to learn how to build an Answer-To-Earn DApp with Next.js, TypeScript, Tailwind CSS, and Solidity? Watch this video now!

This video is a great resource for learning how to build decentralized applications and earn ether.

Now that we have covered all the components in this application, it is time to start connecting the different pages. Let's start with the homepage.

To start developing the pages of our application, go to the pages folder at the root of your project. This folder will contain all the pages needed for our project.

Home Page

The Home page

The Home component renders the home page of this application. It uses Redux to manage global state, fetches poll data from the server, and defines the HTML structure of the page. This page bundles the Navbar, Banner, CreatePoll, Polls, and Footer components together.

To follow up with this component, replace the content of the index.tsx file in the pages folder with the code below:

import Banner from '@/components/Banner'
import CreatePoll from '@/components/CreatePoll'
import Footer from '@/components/Footer'
import Navbar from '@/components/Navbar'
import Polls from '@/components/Polls'
import { getPolls } from '@/services/blockchain'
import { globalActions } from '@/store/globalSlices'
import { PollStruct, RootState } from '@/utils/types'
import Head from 'next/head'
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
export default function Home({ pollsData }: { pollsData: PollStruct[] }) {
const dispatch = useDispatch()
const { setPolls } = globalActions
const { polls } = useSelector((states: RootState) => states.globalStates)
useEffect(() => {
dispatch(setPolls(pollsData))
}, [dispatch, setPolls, pollsData])
return (
<>
<Head>
<title>Available Polls</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="min-h-screen relative backdrop-blur">
<div
className="absolute inset-0 before:absolute before:inset-0
before:w-full before:h-full before:bg-[url('/assets/images/bg.jpeg')]
before:blur-sm before:z-[-1] before:bg-no-repeat before:bg-cover"
/>
<section className="relative px-5 py-10 space-y-16 text-white sm:p-10">
<Navbar />
<Banner />
<Polls polls={polls} />
<Footer />
</section>
<CreatePoll />
</div>
</>
)
}
export const getServerSideProps = async () => {
const pollsData: PollStruct[] = await getPolls()
return {
props: { pollsData: JSON.parse(JSON.stringify(pollsData)) },
}
}
view raw index.tsx hosted with ❤ by GitHub

Polls Page

The Polls Page

The Polls component is a dynamic page that displays details about a specific poll. It uses Redux to manage global state, fetches poll and contestant data from the server, and defines the HTML structure of the page.

This component bundles the **Footer**, **Navbar**, **Details**, **Contestants**, **UpdatePoll**, **DeletePoll**, **ContestPoll**, **ChatModal**, and **ChatButton**, making up the structure of the poll page.

To proceed, create a new folder called polls inside the pages directory. Then, create a file named [id].tsx. Make sure to use the exact pattern, as this is required by Next.js to create a dynamic page. See the code below:

import Footer from '@/components/Footer'
import Navbar from '@/components/Navbar'
import Details from '@/components/Details'
import Contestants from '@/components/Contestants'
import Head from 'next/head'
import ContestPoll from '@/components/ContestPoll'
import { GetServerSidePropsContext } from 'next'
import { getContestants, getPoll } from '@/services/blockchain'
import { ContestantStruct, PollStruct, RootState } from '@/utils/types'
import { useDispatch, useSelector } from 'react-redux'
import { globalActions } from '@/store/globalSlices'
import { useEffect } from 'react'
import UpdatePoll from '@/components/UpdatePoll'
import DeletePoll from '@/components/DeletePoll'
import ChatButton from '@/components/ChatButton'
import ChatModal from '@/components/ChatModal'
import { getGroup } from '@/services/chat'
import { useRouter } from 'next/router'
export default function Polls({
pollData,
contestantData,
}: {
pollData: PollStruct
contestantData: ContestantStruct[]
}) {
const dispatch = useDispatch()
const { setPoll, setContestants, setGroup } = globalActions
const { poll, contestants, currentUser, group } = useSelector(
(states: RootState) => states.globalStates
)
const router = useRouter()
const { id } = router.query
useEffect(() => {
dispatch(setPoll(pollData))
dispatch(setContestants(contestantData))
const fetchData = async () => {
if (typeof window !== 'undefined') {
setTimeout(async () => {
const groupData = await getGroup(`guid_${id}`)
if (groupData) dispatch(setGroup(JSON.parse(JSON.stringify(groupData))))
}, 500)
}
}
fetchData()
}, [
dispatch,
setPoll,
setContestants,
setGroup,
contestantData,
pollData,
id,
currentUser,
group,
])
return (
<>
{poll && (
<Head>
<title>Poll | {poll.title}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
)}
<div className="min-h-screen relative backdrop-blur">
<div
className="absolute inset-0 before:absolute before:inset-0
before:w-full before:h-full before:bg-[url('/assets/images/bg.jpeg')]
before:blur-sm before:z-[-1] before:bg-no-repeat before:bg-cover"
/>
<section className="relative px-5 py-10 space-y-16 text-white sm:p-10">
<Navbar />
{poll && <Details poll={poll} />}
{poll && contestants && <Contestants poll={poll} contestants={contestants} />}
<Footer />
</section>
{poll && (
<>
<UpdatePoll pollData={poll} />
<DeletePoll poll={poll} />
<ContestPoll poll={poll} />
<ChatModal group={group} />
<ChatButton poll={poll} group={group} />
</>
)}
</div>
</>
)
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { id } = context.query
const pollData = await getPoll(Number(id))
const contestantData = await getContestants(Number(id))
return {
props: {
pollData: JSON.parse(JSON.stringify(pollData)),
contestantData: JSON.parse(JSON.stringify(contestantData)),
},
}
}
view raw [id].tsx hosted with ❤ by GitHub

The Redux Store

Managing data from the blockchain and smart contracts across components and pages can be difficult. That's why we use Redux Toolkit to manage data across all components and scripts. Here is the unique setup of Redux Toolkit:

  • Create a global states script to manage all states
  • Create a global actions script to manage all states mutations
  • Bundle the states and actions in a global slices script
  • Configuring the global slices as a store service
  • Wrap the store provider into your application

Step 1: Defining the Redux States
Create a folder called store at the root of the project. Create another folder called states inside the store folder. Create a file called globalState.ts inside the states folder and paste the code below into it.

import { GlobalState } from '@/utils/types'
export const globalStates: GlobalState = {
wallet: '',
createModal: 'scale-0',
updateModal: 'scale-0',
deleteModal: 'scale-0',
contestModal: 'scale-0',
chatModal: 'scale-0',
polls: [],
poll: null,
group: null,
contestants: [],
currentUser: null,
}
view raw globalStates.ts hosted with ❤ by GitHub

This code defines the initial state for Redux. It includes properties like wallet for user Ethereum wallet info, various modals' visibility states, an array for polls, poll for selected poll data, group for chat group info, contestants for poll contestants, and currentUser for the logged-in user details. These properties store data that can be accessed and updated globally across the application.

Step 2: Defining the Redux Actions
Create another folder called actions inside the store folder. Create a file called globalActions.ts inside the actions folder and paste the code below into it.

import { ContestantStruct, GlobalState, PollStruct } from '@/utils/types'
import { PayloadAction } from '@reduxjs/toolkit'
export const globalActions = {
setWallet: (state: GlobalState, action: PayloadAction<string>) => {
state.wallet = action.payload
},
setCreateModal: (state: GlobalState, action: PayloadAction<string>) => {
state.createModal = action.payload
},
setUpdateModal: (state: GlobalState, action: PayloadAction<string>) => {
state.updateModal = action.payload
},
setDeleteModal: (state: GlobalState, action: PayloadAction<string>) => {
state.deleteModal = action.payload
},
setContestModal: (state: GlobalState, action: PayloadAction<string>) => {
state.contestModal = action.payload
},
setChatModal: (state: GlobalState, action: PayloadAction<string>) => {
state.chatModal = action.payload
},
setPolls: (state: GlobalState, action: PayloadAction<PollStruct[]>) => {
state.polls = action.payload
},
setPoll: (state: GlobalState, action: PayloadAction<PollStruct>) => {
state.poll = action.payload
},
setGroup: (state: GlobalState, action: PayloadAction<any>) => {
state.group = action.payload
},
setContestants: (state: GlobalState, action: PayloadAction<ContestantStruct[]>) => {
state.contestants = action.payload
},
setCurrentUser: (state: GlobalState, action: PayloadAction<any>) => {
state.currentUser = action.payload
},
}

These Redux actions define functions to modify the global state. They receive a state object and a payload from an action. Each action corresponds to a specific state property and sets it to the payload's value. These actions are used throughout the application to update various global state properties like wallet, modal states, polls, group, contestants, and currentUser, enabling dynamic changes to the application's state as needed.

Step 3: Bundling the states and actions in a slice
Create a file called globalSlices.ts inside the store folder and paste the code below into it.

import { createSlice } from '@reduxjs/toolkit'
import { globalActions as GlobalActions } from './actions/globalActions'
import { globalStates as GlobalStates } from './states/globalState'
export const globalSlices = createSlice({
name: 'global',
initialState: GlobalStates,
reducers: GlobalActions,
})
export const globalActions = globalSlices.actions
export default globalSlices.reducer
view raw globalSlices.ts hosted with ❤ by GitHub

This Redux slice named global combines initial state and reducers from globalActions and GlobalStates . It creates actions and a reducer for the global state. The globalActions object contains action creators, and the globalSlices.reducer handles state updates based on these actions, simplifying state management for the global slice of the application.

Step 4: Configuring the slices as a store service
Create a file called index.ts inside the store folder and paste the code below into it.

import { configureStore } from '@reduxjs/toolkit'
import globalSlices from './globalSlices'
export const store = configureStore({
reducer: {
globalStates: globalSlices,
},
})
view raw index.ts hosted with ❤ by GitHub

This Redux store is configured using @reduxjs/toolkit. It incorporates the globalSlices reducer into the store, allowing access to global state management throughout the application. The configureStore function initializes the store with the specified reducer, enabling efficient state handling via Redux.

TypeScript Interfaces

The interfaces used across this application are defined in the type.d.ts file in the utils folder at the root of the project. This file defines the data structures used in the application. Create a folder called utils and within it a file called type.d.ts and past the codes below into it.

export interface TruncateParams {
text: string
startChars: number
endChars: number
maxLength: number
}
export interface PollParams {
image: string
title: string
description: string
startsAt: number | string
endsAt: number | string
}
export interface PollStruct {
id: number
image: string
title: string
description: string
votes: number
contestants: number
deleted: boolean
director: string
startsAt: number
endsAt: number
timestamp: number
avatars: string[]
voters: string[]
}
export interface ContestantStruct {
id: number
image: string
name: string
voter: string
votes: number
voters: string[]
}
export interface GlobalState {
wallet: string
createModal: string
updateModal: string
deleteModal: string
contestModal: string
chatModal: string
polls: PollStruct[]
poll: PollStruct | null
group: PollStruct | null
contestants: ContestantStruct[]
currentUser: PollStruct | null
}
export interface RootState {
globalStates: GlobalState
}
view raw type.d.ts hosted with ❤ by GitHub
  1. TruncateParams: Contains parameters for truncating text, including the input text, the number of characters to keep at the start and end, and the maximum length.
  2. PollParams: Represents parameters for creating a poll, including image, title, description, start and end times.
  3. PollStruct: Describes the structure of a poll object with attributes like id, image, title, description, votes, contestants count, deletion status, director, start and end times, timestamp, avatars, and voters.
  4. ContestantStruct: Represents the structure of a contestant in a poll, including attributes like id, image, name, voter, votes, and a list of voters who voted for them.
  5. GlobalState: Defines the shape of the global application state with properties for wallet, modal states (create, update, delete, contest, chat), poll-related data (polls, poll, group, contestants, current user).
  6. RootState: Specifies the root state structure, primarily containing the globalStates property, which encapsulates the global application state.

The App Services

To add web3 and chat features to our application, we will need to create some services. Create a folder called services at the root of the project and create the following scripts inside of it:

  • blockchain.ts: This script will connect to the Ethereum blockchain and manage web3-related tasks.
  • chat.ts: This script will handle chat-related tasks, such as connecting to CometChat and sending/receiving messages.

Ensure to copy and paste the codes below to their respective files.

Blockchain Service
This blockchain service interacts with a smart contract using Ethereum. It includes functions like connecting to the Ethereum wallet, creating, updating, and deleting polls, contesting polls, voting for contestants, and fetching poll-related data. See the code below:

import { store } from '@/store'
import { ethers } from 'ethers'
import { globalActions } from '@/store/globalSlices'
import address from '@/artifacts/contractAddress.json'
import abi from '@/artifacts/contracts/DappVotes.sol/DappVotes.json'
import { ContestantStruct, PollParams, PollStruct, TruncateParams } from '@/utils/types'
import { logOutWithCometChat } from './chat'
const { setWallet, setPolls, setPoll, setContestants, setCurrentUser } = globalActions
const ContractAddress = address.address
const ContractAbi = abi.abi
let ethereum: any
let tx: any
if (typeof window !== 'undefined') {
ethereum = (window as any).ethereum
}
const getEthereumContract = async () => {
const accounts = await ethereum?.request?.({ method: 'eth_accounts' })
const provider = accounts?.[0]
? new ethers.providers.Web3Provider(ethereum)
: new ethers.providers.JsonRpcProvider(process.env.NEXT_APP_RPC_URL)
const wallet = accounts?.[0] ? null : ethers.Wallet.createRandom()
const signer = provider.getSigner(accounts?.[0] ? undefined : wallet?.address)
const contract = new ethers.Contract(ContractAddress, ContractAbi, signer)
return contract
}
const connectWallet = async () => {
try {
if (!ethereum) return reportError('Please install Metamask')
const accounts = await ethereum.request?.({ method: 'eth_requestAccounts' })
store.dispatch(setWallet(accounts?.[0]))
} catch (error) {
reportError(error)
}
}
const checkWallet = async () => {
try {
if (!ethereum) return reportError('Please install Metamask')
const accounts = await ethereum.request?.({ method: 'eth_accounts' })
ethereum.on('chainChanged', () => {
window.location.reload()
})
ethereum.on('accountsChanged', async () => {
store.dispatch(setWallet(accounts?.[0]))
await checkWallet()
await logOutWithCometChat()
store.dispatch(setCurrentUser(null))
})
if (accounts?.length) {
store.dispatch(setWallet(accounts[0]))
} else {
store.dispatch(setWallet(''))
reportError('Please connect wallet, no accounts found.')
}
} catch (error) {
reportError(error)
}
}
const createPoll = async (data: PollParams) => {
if (!ethereum) {
reportError('Please install Metamask')
return Promise.reject(new Error('Metamask not installed'))
}
try {
const contract = await getEthereumContract()
const { image, title, description, startsAt, endsAt } = data
const tx = await contract.createPoll(image, title, description, startsAt, endsAt)
await tx.wait()
const polls = await getPolls()
store.dispatch(setPolls(polls))
return Promise.resolve(tx)
} catch (error) {
reportError(error)
return Promise.reject(error)
}
}
const updatePoll = async (id: number, data: PollParams) => {
if (!ethereum) {
reportError('Please install Metamask')
return Promise.reject(new Error('Metamask not installed'))
}
try {
const contract = await getEthereumContract()
const { image, title, description, startsAt, endsAt } = data
const tx = await contract.updatePoll(id, image, title, description, startsAt, endsAt)
await tx.wait()
const poll = await getPoll(id)
store.dispatch(setPoll(poll))
return Promise.resolve(tx)
} catch (error) {
reportError(error)
return Promise.reject(error)
}
}
const deletePoll = async (id: number) => {
if (!ethereum) {
reportError('Please install Metamask')
return Promise.reject(new Error('Metamask not installed'))
}
try {
const contract = await getEthereumContract()
const tx = await contract.deletePoll(id)
await tx.wait()
return Promise.resolve(tx)
} catch (error) {
reportError(error)
return Promise.reject(error)
}
}
const contestPoll = async (id: number, name: string, image: string) => {
if (!ethereum) {
reportError('Please install Metamask')
return Promise.reject(new Error('Metamask not installed'))
}
try {
const contract = await getEthereumContract()
const tx = await contract.contest(id, name, image)
await tx.wait()
const poll = await getPoll(id)
store.dispatch(setPoll(poll))
const contestants = await getContestants(id)
store.dispatch(setContestants(contestants))
return Promise.resolve(tx)
} catch (error) {
reportError(error)
return Promise.reject(error)
}
}
const voteCandidate = async (id: number, cid: number) => {
if (!ethereum) {
reportError('Please install Metamask')
return Promise.reject(new Error('Metamask not installed'))
}
try {
const contract = await getEthereumContract()
const tx = await contract.vote(id, cid)
await tx.wait()
const poll = await getPoll(id)
store.dispatch(setPoll(poll))
const contestants = await getContestants(id)
store.dispatch(setContestants(contestants))
return Promise.resolve(tx)
} catch (error) {
reportError(error)
return Promise.reject(error)
}
}
const getPolls = async (): Promise<PollStruct[]> => {
const contract = await getEthereumContract()
const polls = await contract.getPolls()
return structurePolls(polls)
}
const getPoll = async (id: number): Promise<PollStruct> => {
const contract = await getEthereumContract()
const polls = await contract.getPoll(id)
return structurePolls([polls])[0]
}
const getContestants = async (id: number): Promise<ContestantStruct[]> => {
const contract = await getEthereumContract()
const contestants = await contract.getContestants(id)
return structureContestants(contestants)
}
const truncate = ({ text, startChars, endChars, maxLength }: TruncateParams): string => {
if (text.length > maxLength) {
let start = text.substring(0, startChars)
let end = text.substring(text.length - endChars, text.length)
while (start.length + end.length < maxLength) {
start = start + '.'
}
return start + end
}
return text
}
const formatDate = (timestamp: number): string => {
const date = new Date(timestamp)
const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
const dayOfWeek = daysOfWeek[date.getUTCDay()]
const month = months[date.getUTCMonth()]
const day = date.getUTCDate()
const year = date.getUTCFullYear()
return `${dayOfWeek}, ${month} ${day}, ${year}`
}
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
const structurePolls = (polls: any[]): PollStruct[] =>
polls
.map((poll) => ({
id: Number(poll.id),
image: poll.image,
title: poll.title,
description: poll.description,
votes: Number(poll.votes),
contestants: Number(poll.contestants),
deleted: poll.deleted,
director: poll.director.toLowerCase(),
startsAt: Number(poll.startsAt),
endsAt: Number(poll.endsAt),
timestamp: Number(poll.timestamp),
voters: poll.voters.map((voter: string) => voter.toLowerCase()),
avatars: poll.avatars,
}))
.sort((a, b) => b.timestamp - a.timestamp)
const structureContestants = (contestants: any[]): ContestantStruct[] =>
contestants
.map((contestant) => ({
id: Number(contestant.id),
image: contestant.image,
name: contestant.name,
voter: contestant.voter.toLowerCase(),
votes: Number(contestant.votes),
voters: contestant.voters.map((voter: string) => voter.toLowerCase()),
}))
.sort((a, b) => b.votes - a.votes)
export {
connectWallet,
checkWallet,
truncate,
formatDate,
formatTimestamp,
createPoll,
updatePoll,
deletePoll,
getPolls,
getPoll,
contestPoll,
getContestants,
voteCandidate,
}
view raw blockchain.ts hosted with ❤ by GitHub

The key components in this service include:

  • Ethereum Connection: It connects to the user's Ethereum wallet using MetaMask or a JSON-RPC provider if MetaMask is not available.
  • Poll and Contestant Operations: It allows creating, updating, and deleting polls, contesting polls, and voting for contestants, with error handling.
  • Fetching Data: Functions for fetching poll data, poll lists, and contestant data from the smart contract.
  • Utilities: Utility functions like truncating text, formatting timestamps, and structuring poll and contestant data for consistency.
  • Global State Management: It uses Redux to manage the global state, enabling components throughout the application to access and modify data like the user's wallet, polls, and contestants.

This service plays a crucial role in the application's interaction with the Ethereum blockchain, enabling users to participate in polls and contests securely and transparently.

Chat Service
This chat service integrates the CometChat SDK into the application for real-time chat functionality. See the codes below:

let CometChat: any
if (typeof window !== 'undefined') {
import('@cometchat-pro/chat').then((cometChatModule) => {
CometChat = cometChatModule.CometChat
})
console.log('CometChat Loaded...')
}
const CONSTANTS = {
APP_ID: process.env.NEXT_PUBLIC_COMET_CHAT_APP_ID,
REGION: process.env.NEXT_PUBLIC_COMET_CHAT_REGION,
Auth_Key: process.env.NEXT_PUBLIC_COMET_CHAT_AUTH_KEY,
}
const initCometChat = async () => {
const appID = CONSTANTS.APP_ID
const region = CONSTANTS.REGION
const appSetting = new CometChat.AppSettingsBuilder()
.subscribePresenceForAllUsers()
.setRegion(region)
.autoEstablishSocketConnection(true)
.build()
CometChat.init(appID, appSetting)
.then(() => console.log('Initialization completed successfully'))
.catch((error: any) => console.log(error))
}
const loginWithCometChat = async (UID: string) => {
const authKey = CONSTANTS.Auth_Key
return new Promise((resolve, reject) => {
CometChat.login(UID, authKey)
.then((user: any) => resolve(user))
.catch((error: any) => reject(error))
})
}
const signUpWithCometChat = async (UID: string) => {
const authKey = CONSTANTS.Auth_Key
const user = new CometChat.User(UID)
user.setName(UID)
return new Promise((resolve, reject) => {
CometChat.createUser(user, authKey)
.then((user: any) => resolve(user))
.catch((error: any) => reject(error))
})
}
const logOutWithCometChat = async () => {
return new Promise((resolve, reject) => {
CometChat.logout()
.then(() => resolve(null))
.catch((error: any) => reject(error))
})
}
const checkAuthState = async () => {
return new Promise((resolve, reject) => {
CometChat.getLoggedinUser()
.then((user: any) => resolve(user))
.catch((error: any) => reject(error))
})
}
const createNewGroup = async (GUID: string, groupName: string) => {
const groupType = CometChat.GROUP_TYPE.PUBLIC
const password = ''
const group = new CometChat.Group(GUID, groupName, groupType, password)
return new Promise((resolve, reject) => {
CometChat.createGroup(group)
.then((group: any) => resolve(group))
.catch((error: any) => reject(error))
})
}
const getGroup = async (GUID: string) => {
return new Promise((resolve, reject) => {
CometChat.getGroup(GUID)
.then((group: any) => resolve(group))
.catch((error: any) => reject(error))
})
}
const joinGroup = async (GUID: string) => {
const groupType = CometChat.GROUP_TYPE.PUBLIC
const password = ''
return new Promise((resolve, reject) => {
CometChat.joinGroup(GUID, groupType, password)
.then((group: any) => resolve(group))
.catch((error: any) => reject(error))
})
}
const getMessages = async (GUID: string) => {
const limit = 30
const messagesRequest = new CometChat.MessagesRequestBuilder()
.setGUID(GUID)
.setLimit(limit)
.build()
return new Promise((resolve, reject) => {
messagesRequest
.fetchPrevious()
.then((messages: any[]) => resolve(messages.filter((msg) => msg.type === 'text')))
.catch((error: any) => reject(error))
})
}
const sendMessage = async (receiverID: string, messageText: string) => {
const receiverType = CometChat.RECEIVER_TYPE.GROUP
const textMessage = new CometChat.TextMessage(receiverID, messageText, receiverType)
return new Promise((resolve, reject) => {
CometChat.sendMessage(textMessage)
.then((message: any) => resolve(message))
.catch((error: any) => reject(error))
})
}
const listenForMessage = async (listenerID: string) => {
return new Promise((resolve) => {
CometChat.addMessageListener(
listenerID,
new CometChat.MessageListener({
onTextMessageReceived: (message: any) => resolve(message),
})
)
})
}
export {
initCometChat,
loginWithCometChat,
signUpWithCometChat,
logOutWithCometChat,
checkAuthState,
createNewGroup,
getGroup,
getMessages,
joinGroup,
sendMessage,
listenForMessage,
}
view raw chat.ts hosted with ❤ by GitHub

It performs several key functions:

  1. Initialization: The service initializes CometChat with the provided application ID and region, subscribing to user presence updates and establishing a socket connection.
  2. Authentication: It provides functions for user login and signup, which are essential for accessing chat features.
  3. User Management: Functions for checking the user's authentication state and logging out are included.
  4. Group Management: The service allows the creation of new groups, fetching group information, and joining groups.
  5. Messaging: It supports sending and receiving text messages in groups, providing a real-time chat experience. The listenForMessage function enables the app to react to incoming messages.
  6. Error Handling: Throughout these functions, error handling is implemented to manage and report errors effectively.
  7. Lazy Loading: The CometChat SDK is loaded lazily (only when the application is running in the browser), ensuring efficient resource utilization.

This service enables users to engage in real-time group chat within the application, enhancing the user experience and facilitating communication between participants.

The App Component

This component manages both the pages and the sub components in this NextJs application. It conditionally render child components based on the showChild state. It initializes CometChat, provides Redux store access, and displays toast notifications via the ToastContainer. This conditional rendering ensures that CometChat initializes only on the client side.

Head to the pages folder, open the _app.tsx file and replace its content with the code below:

import { AppProps } from 'next/app'
import '@/styles/global.css'
import { Provider } from 'react-redux'
import { store } from '@/store'
import { ToastContainer } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
import { useEffect, useState } from 'react'
import { checkWallet } from '@/services/blockchain'
import CometChatNoSSR from '@/components/CometChatNoSSR'
export default function MyApp({ Component, pageProps }: AppProps) {
const [showChild, setShowChild] = useState<boolean>(false)
useEffect(() => {
checkWallet()
setShowChild(true)
}, [])
if (!showChild || typeof window === 'undefined') {
return null
} else {
return (
<Provider store={store}>
<CometChatNoSSR />
<Component {...pageProps} />
<ToastContainer
position="bottom-center"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="dark"
/>
</Provider>
)
}
}
view raw _app.tsx hosted with ❤ by GitHub

Before you finish up the project create a folder called assets/images and add the images found in this link to this folder.

Congratulations on following this tutorial on how to build a decentralized voting dapp with Next.js, TypeScript, Tailwind CSS, and CometChat!

You can run yarn dev on another terminal to see that every aspect of the app works correctly and then yarn build to build the application. Also ensure that your local blockchain node is running and the smart contract deployed already to the network.

The video tutorial is also available below.

I hope you found the tutorial helpful. If you did, please subscribe to my YouTube channel, Dapp Mentors, for more tutorials like this and visiting our website for additional resources.

Till next time all the best!

Conclusion

In conclusion, this comprehensive tutorial has walked you through the process of developing a decentralized voting dapp with Next.js, TypeScript, Tailwind CSS, and CometChat. The objective of this article was to provide a step-by-step guide to building a feature-rich and interactive application, enabling users to create, participate in, and manage polls while also facilitating real-time group chat functionality.

Key highlights of this tutorial include the development of various frontend components, setting up a robust Redux store for global state management, defining TypeScript interfaces for structured data, and creating essential app services for blockchain interaction and chat integration. The tutorial's culmination is the App Component, which orchestrates the seamless operation of pages and subcomponents.

By following this tutorial and leveraging the provided resources, you are well-equipped to embark on your journey in web3 development, harnessing the power of blockchain and chat technologies to create innovative and engaging decentralized applications.

About Author

I am a web3 developer and the founder of Dapp Mentors, a company that helps businesses and individuals build and launch decentralized applications. I have over 7 years of experience in the software industry, and I am passionate about using blockchain technology to create new and innovative applications. I run a YouTube channel called Dapp Mentors where I share tutorials and tips on web3 development, and I regularly post articles online about the latest trends in the blockchain space.

Stay connected with us, join communities on
Discord: Join
Twitter: Follow
LinkedIn: Connect
GitHub: Explore
Website: Visit

Top comments (1)

Collapse
 
daltonic profile image
Gospel Darlington

Drop your comments here, I will like to get your thoughts!