How I built ClearVoter;  an opensource e-voting platform

How I built ClearVoter; an opensource e-voting platform

Introduction

In this article, I will walk you through how I built ClearVoter; an electronic voting (e-voting) platform that allows individuals or brands create polls in order to get the choice, opinion, or will on a question from a target audience or a group of persons. ClearVoter allows users or poll creators restrict the votes to a particular location or region.

Note: This article expects the reader to have some basic knowledge of Express.js, Node.js, Next.js and MongoDB

Project structure and technologies

The project is divided into two codebases; frontend and backend. The frontend will be built with Next.js while the backend will be built with Express.js. Thence, we will begin by creating two folders namely: server and web-client.

Setup server directory

To setup the server, I opened the server folder in the terminal, ran npm init to generate a package.json file. Next, I Installed the neccessary dependencies by running npm i axios bcryptjs cors dotenv express express-async-handler joi jsonwebtoken mongoose nodemailer slugify. After the packages were done installing, I also ran npm i -D nodemon.

Next, I added start and dev scripts and finally had my package.json setup like this:

{
  "name": "clearvoter-server",
  "version": "1.0.0",
  "description": "ClearVoter is an electronic voting (e-voting) platform that allows individuals or brands create polls in order to get the choice, opinion, or will on a question; from a target audience or a group of persons.",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "keywords": [
    "e-voting",
    "voting",
    "polling"
  ],
  "author": "Bonaventure Chukwudi",
  "license": "MIT",
  "dependencies": {
    "axios": "^0.27.2",
    "bcryptjs": "^2.4.3",
    "cors": "^2.8.5",
    "dotenv": "^16.0.1",
    "express": "^4.18.1",
    "express-async-handler": "^1.2.0",
    "joi": "^17.6.0",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^6.4.0",
    "nodemailer": "^6.7.5",
    "slugify": "^1.6.5"
  },
  "devDependencies": {
    "nodemon": "^2.0.18"
  }
}

Setup Database config

In server folder, I created a config folder and added db.js file. Inside the file I added the following line of code to setup mongodb with mongoose.

const mongoose = require("mongoose");

const MONGO_URI =
  process.env.NODE_ENV == "production"
    ? process.env.MONGO_URI_PRO
    : process.env.MONGO_URI_DEV;

const connectDB = async () => {
  try {
    const connection = await mongoose.connect(MONGO_URI, {
      useUnifiedTopology: true,
      useNewUrlParser: true,
    });
    console.log(`MongoDB Connected: ${connection.connection.host}`);
  } catch (error) {
    console.log(`Error: ${error.message}`);
    process.exit(1);
  }
};

module.exports = connectDB;

Setup server error handling middleware

Next, I created a middleware folder and added an errorMiddleware.js file. Inside the file I added the following lines of code.

const notFound = (req, res, next) => {
  const error = new Error(`Not Found - ${req.originalUrl} is invalid`);
  res.status(404);
  next(error);
};

const errorHandler = (err, req, res, next) => {
  const statusCode = res.statuscode === 200 ? 500 : res.statusCode;
  res.status(statusCode);
  res.json({
    message: err.message,
    stack: process.env.NODE_ENV === "production" ? null : err.stack,
  });
};

module.exports = { notFound, errorHandler };

The notFound function responds with an error when a route requested by a user is not available in the server while the errorHandler gives a generic error message pattern for the server and will be triggered when throw new Error("The error message") is used in a particular route.

Setup server.js

The server.js will serve as the entry point for the app. You might have noticed this: "main": "server.js" in the package.json. It is used to tell Node.js the entry point for our app.

To set up a server, I wrote the following lines of code inside the server.js:

const express = require("express");
const app = express();
require("dotenv/config");
require("./config/db")();
const cors = require("cors");

app.use(cors());
app.use(express.json());

// Error Middlewares
const { notFound, errorHandler } = require("./middleware/errorMiddleware");

//Not found URL middleware
app.use(notFound);

//Error handler for the whole app
app.use(errorHandler);

const PORT = process.env.PORT || 5020;
app.listen(PORT, () => console.log("Server running on PORT " + PORT));

Note: dotenv is used to retrieve the values inside .env file which I will create next. The cors is used to allow cross-origin resource sharing while the app.use(express.json()); is used to parse and receive json from requests.

Create .env file

The next thing I did was to create a .env file to enable me access important strings from "process.env".

MONGO_URI_DEV = mongodb://localhost:27017/a-name

MONGO_URI_PRO=mongodb+srv://name:password@cluster.some-random.mongodb.net/?retryWrites=true&w=majority

NODE_ENV=development

Note: The above values are dummy data and you should add the proper strings

Run server

At this point, I started the server by running npm run dev and it worked like magic!!!

Database schema and model

A database schema and model are used to define the structure of the data that would be stored in a database and the rules for creating, reading, updating and deleting them. Mongoose makes creating schemas and models very easy. I created a models folder in the server directory.

Setup user schema and model

Here, I created a user.model.js inside the moldels folder and imported bcryptjs and mongoose which are used for data encryption and as a mongodb orm respectively. Next, I created a model comprising of fields and their respective rules. Next, I created a method and attached it to the schema such that I can use it to check for password validity. Next, I created a middleware that runs on every database save and helps hash or rehash the password if modified. Finally, I created the model and exported it.

const mongoose = require("mongoose");
const bcrypt = require("bcryptjs");

const userSchema = mongoose.Schema(
  {
    username: {
      type: String,
      unique: true,
      required: true,
    },
    name: {
      type: String,
    },
    email: {
      type: String,
      unique: true,
      required: true,
    },
    password: {
      type: String,
      required: true,
    },
    isVerified: {
      type: Boolean,
      default: false,
    },
  },
  { timestamps: true }
);

// This method matches a User's password and can be used as: await TheUserFromDatabase.matchPassword(password)
userSchema.methods.matchPassword = async function (enteredPassword) {
  return await bcrypt.compare(enteredPassword, this.password);
};

/**
 * @description This rehashes a password if updated or changed.
 */
userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) {
    next();
  }
  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
});

const User = mongoose.model("User", userSchema);
module.exports = User;

Setup vote schema and model

The vote.models.js file is similar to the user.model.js however, it has two sub schemas namely: partiesSchema and targetLocationsSchema. It also has a middleware that runs before save and adds a slug to the model.

const mongoose = require("mongoose");
const slugify = require("slugify");

const partiesSchema = mongoose.Schema({
  name: {
    type: String,
  },
  description: {
    type: String,
  },
  voters: [String],
  voteCount: {
    type: Number,
    default: 0,
  },
});

const targetLocationSchema = mongoose.Schema({
  location: {
    type: String,
  },
});

const voteSchema = mongoose.Schema(
  {
    title: {
      type: String,
      required: true,
      unique: true,
    },
    slug: {
      type: String,
    },
    description: {
      type: String,
      required: true,
    },
    allowVpn: {
      type: Boolean,
      default: true,
    },
    targetLocations: [targetLocationSchema],
    parties: [partiesSchema],
    expiration: {
      type: String,
    },
    endVoting: {
      type: Boolean,
      default: false,
    },
    draft: {
      type: Boolean,
      default: true,
    },
    creator: {
      id: {
        type: mongoose.Schema.Types.ObjectId,
        ref: "User",
      },
      username: {
        type: String,
      },
    },
  },
  { timestamps: true }
);

voteSchema.pre("save", async function (next) {
  this.slug = await slugify(this.title);
  next();
});

const VoteModel = mongoose.model("Vote", voteSchema);

module.exports = VoteModel;

Form Validation

Validating forms are a good way of checking against a bad user's input. It helps checkmate what the user sends to the server. I made use of the already installed node package joi to validate forms. I started off by creating a utilities folder and inside the folder created a form-validation.

User routes form validation

I used this validation to check for the user input when they try to register or login. I began by creating a users folder inside the folder created three files: checkers.js, schema.js and index.js.

The checkers file has essentially the check for each field received from the client. It has check for email, username, password, name, login password, and login username.

const Joi = require("joi");

const checkers = {};

checkers.email = Joi.string()
  .email({
    minDomainSegments: 2,
    tlds: { allow: ["com", "net"] },
  })
  .required()
  .messages({
    "string.base":
      "Your email should have the format of name@example.com or name@example.net",
    "string.empty": "Your email address cannot have an empty field",
    "string.email":
      "Please enter a valid email address. It should have the format of name@example.com or name@example.net",
    "any.required":
      "Please enter a valid email address. It should have the format of name@example.com or name@example.net",
  });

checkers.username = Joi.string().required().alphanum().min(3).max(30).messages({
  "string.empty": "Your username cannot be an empty field",
  "string.min":
    "Your username should have a minimum length of three characters",
  "string.max":
    "Your username should have a maximum length of thirty characters",
  "string.alphanum":
    "Your username must contain only alphabets and/or numbers.",
  "any.required": "Please enter a valid username.",
});

checkers.password = Joi.string().required().min(6).messages({
  "string.empty": "Your password cannot be an empty field",
  "string.min": "Your password should have a minimum length of 6 characters",
  "any.required": "Please enter a secure password",
});

checkers.name = Joi.string().required().min(3).max(40).messages({
  "string.base": "Your name should be texts only",
  "string.empty": "Your name cannot be an empty field",
  "string.min": "Your name should have a minimum length of three characters",
  "string.max": "Your name should have a maximum length of forty characters",
  "any.required": "Please enter a valid name.",
});

checkers.loginPassword = Joi.string().required().messages({
  "string.empty": "Your password cannot be an empty field",
  "any.required": "Please enter your password",
});

checkers.loginUsername = Joi.string().required().alphanum().messages({
  "string.empty": "Your username cannot be an empty field",
  "string.alphanum":
    "Your username must contain only alphabets and/or numbers.",
  "any.required": "Please enter a valid username.",
});

module.exports = checkers;

The next step was to create the schema.js file content which essentially holds the various schema for our input.

const Joi = require("joi");
const checkers = require("./checkers");

const schema = {};

schema.register = Joi.object({
  email: checkers.email,
  username: checkers.username,
  password: checkers.password,
});

schema.login = Joi.object({
  username: checkers.loginUsername,
  password: checkers.loginPassword,
});

module.exports = schema;

Lastly, I created the validation middleware in the index.js. This middleware will be added to the user route making sure that I make the file as clean as possible.

const asyncHandler = require("express-async-handler");
const schema = require("./schema");

const validation = {};

validation._callable = (schemaName) =>
  asyncHandler(async (req, res, next) => {
    try {
      await schema[schemaName].validateAsync(req.body);
      next();
    } catch (error) {
      res.status(422);
      throw new Error(error.details[0].message);
    }
  });

validation.register = validation._callable("register");
validation.login = validation._callable("login");

module.exports = validation;

Vote routes form validation

Similar to the content of the users folder, the vote folder also has three files namely: checkers.js, schema.js and index.js.

The checkers.js:

const Joi = require("joi");

const checkers = {};

checkers.title = Joi.string().required().min(10).max(255).messages({
  "string.empty": "The title cannot be an empty field",
  "string.min": "The title should have a minimum length of ten characters",
  "string.max":
    "The title should have a maximum length of two hundred and fifty five  characters",
  "any.required": "Please enter a valid title.",
});

checkers.description = Joi.string().required().min(10).max(1000).messages({
  "string.empty": "The description cannot be an empty field",
  "string.min":
    "The description should have a minimum length of ten characters",
  "string.max":
    "The description should have a maximum length of one thousand characters",
  "any.required": "Please enter a valid description.",
});

checkers.expiration = Joi.string().required().messages({
  "any.required": "Please enter a valid date.",
});

checkers.targetLocation = Joi.string().required().min(2).messages({
  "string.min": "A location must have a minimum of two characters",
  "any.required": "Please enter a valid location.",
});

checkers.partyName = Joi.string().required().min(2).messages({
  "string.min": "Party name must have a minimum of two characters",
  "any.required": "Enter a party name",
});
checkers.partyDescription = Joi.string().required().min(10).messages({
  "string.min": "Party description must have a minimum of ten characters",
  "any.required": "Enter a party description",
});

checkers.allowVpn = Joi.boolean();

module.exports = checkers;

The schema.js:

const Joi = require("joi");
const checkers = require("./checkers");

const schema = {};

schema.createPoll = Joi.object({
  title: checkers.title,
  description: checkers.description,
  expiration: checkers.expiration,
  allowVpn: checkers.allowVpn,
});

schema.addParty = Joi.object({
  name: checkers.partyName,
  description: checkers.description,
});

schema.addLocation = Joi.object({
  location: checkers.targetLocation,
});

module.exports = schema;

The index.js:

const asyncHandler = require("express-async-handler");
const schema = require("./schema");

const validation = {};

validation._callable = (schemaName) =>
  asyncHandler(async (req, res, next) => {
    try {
      await schema[schemaName].validateAsync(req.body);
      next();
    } catch (error) {
      res.status(422);
      throw new Error(error.details[0].message);
    }
  });

validation.createPoll = validation._callable("createPoll");
validation.addParty = validation._callable("addParty");
validation.addLocation = validation._callable("addLocation");

module.exports = validation;

The index.js contains three middlewares that will be added to check when a vote is created, party added and location added to a poll.

Response and token handlers

The response handler will be stored in handleResponse.js file inside the utilities folder while the token handler will be written handleToken.js file.

handleResponse.js:

const responseHandle = {};

responseHandle.successResponse = (res, status, message, data) => {
  return res.status(Number(status)).json({
    message,
    data,
  });
};

module.exports = responseHandle;

handleToken.js:

const res = require("express/lib/response");
const jwt = require("jsonwebtoken");
const tokenHandler = {};
const secret = process.env.JWT_SECRET;

tokenHandler.generateToken = (fieldToSecure, duration) => {
  try {
    return jwt.sign({ fieldToSecure }, secret, {
      expiresIn: duration ? duration : 18408600000,
    });
  } catch (error) {
    throw new Error(error);
  }
};

tokenHandler.decodeToken = (token) => {
  try {
    return jwt.verify(token, secret);
  } catch (error) {
    res.status(422);
    throw new Error(error);
  }
};

module.exports = tokenHandler;

The token handler contains two helper functions generateToken and decodeToken which essentially makes use of the already installed jsonwebtoken node package.

Controllers

To create my controllers, I created the a controllers folder in the server folder. Inside the controllers two files were added namely: user.controllers.js and vote.controllers.js. Controllers are used to simply writing the logics for our routes.

User controller

The user controller which are stored in user.controller.js file. It has two controller asynchronous functions that are used to handle the user registration and login logic for our routes. The express-async-handler package was used to wrap around each function. This package helps us throw errors without littering our terminal or console.

The user.controllers.js file begins by importing required modules such as the user model, token handler etc.

const asyncHandler = require("express-async-handler");
const UserModel = require("../models/user.model");
const { successResponse } = require("../utilities/handleResponse");
const tokenHandler = require("../utilities/handleToken");

const user = {};

User Registration

The registration controller accepts a username, password and email passed in the body of the request. It checks to see if the email and password are taken and throws an error if any of the conditions are met. Then it creates an new user and if it succeeds, sends a success response.

user.register = asyncHandler(async (req, res) => {
  const { username, email, password } = req.body;

  try {
    const emailTaken = await UserModel.findOne({ email });

    if (emailTaken) {
      res.status(400);
      throw new Error("Email is already taken.");
    }

    const usernameTaken = await UserModel.findOne({
      username: username.trim(),
    });

    if (usernameTaken) {
      res.status(400);
      throw new Error("Username is already taken.");
    }

    const newUser = await UserModel.create({
      email: email.trim(),
      username: username.trim(),
      password: password.trim(),
    });

    if (newUser) {
      successResponse(res, 201, "Account created successfully.");
    }
  } catch (error) {
    res.status(500);
    throw new Error(error);
  }
});

User Login

This controller accepts a username and password and throws an error if the username or password is invalid. it also checks uses the matchPassword method created the user.model.js file.


user.login = asyncHandler(async (req, res) => {
  const { username, password } = req.body;

  try {
    const usernameTaken = await UserModel.findOne({
      username: username.trim(),
    });

    if (
      !usernameTaken ||
      (await usernameTaken.matchPassword(password)) === false
    ) {
      res.status(400);
      throw new Error("Invalid username or password");
    }

    successResponse(res, "200", "Login success", {
      username: usernameTaken.username,
      token: tokenHandler.generateToken(username),
    });
  } catch (error) {
    res.status(500);
    throw new Error(error);
  }
});

Vote controllers

The vote.controllers.js holds all the controllers relating the voting. It has the createPoll, addParty, addLocation, publishPoll, endPoll, addVote, getPoll, getPolls and getAssociatedPolls.

It begins by importing important modules:

const axios = require("axios");
const asyncHandler = require("express-async-handler");
const VoteModel = require("../models/vote.models");
const responseHandle = require("../utilities/handleResponse");

const vote = {};

Create poll

The createPoll controller is used to create a poll. It accepts title, description, expiration, and allowVpn from the request body. There values must all be strings except for the allowVpn that must be boolean. This also checks if the the title is taken already.


vote.createPoll = asyncHandler(async (req, res) => {
  const { title, description, expiration, allowVpn } = req.body;

  try {
    const isTitleTaken = await VoteModel.findOne({
      title: title.trim(),
    }).select(["-parties.voters"]);

    if (isTitleTaken) {
      res.status(400);
      throw new Error("Title is already been used. Please try another one.");
    }
    const poll = await VoteModel.create({
      title: title.trim(),
      description: description.trim(),
      expiration,
      creator: {
        id: req.user._id,
        username: req.user.username,
      },
      allowVpn,
    });

    if (!poll) {
      res.status(500);
      throw new Error(
        "We were unable to create the poll at the moment. Please try again."
      );
    }

    responseHandle.successResponse(res, 201, "Poll created successfully", poll);
  } catch (error) {
    res.status(500);
    throw new Error(error);
  }
});

Add Party

The addParty controller is used to add the poll parties. This accepts a slug from the request parameter. It also accepts a name and a description from the request body. It makes use of the updateOne and $push operations from mongoose to add a party to the parties field in the vote model.


vote.addParty = asyncHandler(async (req, res) => {
  const slug = req.params.slug;
  const { name, description } = req.body;

  try {
    const checkPartyAdded = await VoteModel.findOne({
      slug,
      "parties.name": name,
    }).select(["-parties.voters"]);

    if (checkPartyAdded) {
      res.status(400);
      throw new Error("Added the Party already");
    }
    const poll = await VoteModel.updateOne(
      { slug },
      {
        $push: {
          parties: {
            name,
            description,
          },
        },
      }
    );

    if (!poll) {
      res.status(400);
      throw new Error("The party was not added. Please try again");
    }

    responseHandle.successResponse(res, 201, "Party was added successfully.");
  } catch (error) {
    res.status(500);
    throw new Error(error);
  }
});

Add Location

The addLocation controller works similar to the addParty controller. It accepts slug from the request parameter but only accepts a location from the request body.


vote.addLocation = asyncHandler(async (req, res) => {
  const slug = req.params.slug;
  const { location } = req.body;

  try {
    const checkLocationAdded = await VoteModel.findOne({
      slug,
      "targetLocations.location": location,
    }).select(["-parties.voters"]);

    if (checkLocationAdded) {
      res.status(400);
      throw new Error("Added the location already");
    }
    const poll = await VoteModel.updateOne(
      { slug },
      {
        $push: {
          targetLocations: {
            location,
          },
        },
      }
    );

    if (!poll) {
      res.status(400);
      throw new Error("The location was not added. Please try again");
    }

    responseHandle.successResponse(
      res,
      201,
      "Location was added successfully."
    );
  } catch (error) {
    res.status(500);
    throw new Error(error);
  }
});

Publish poll

The publishPoll controller is used to publish a poll. This accepts slug from the request parameter. It essentially sets the draft value of the poll to false.


vote.publishPoll = asyncHandler(async (req, res) => {
  const slug = req.params.slug;

  try {
    const poll = await VoteModel.updateOne(
      { slug },
      {
        draft: false,
      }
    );

    if (!poll) {
      res.status(400);
      throw new Error("An error occured. It seems like your link is broken.");
    }

    responseHandle.successResponse(
      res,
      201,
      "The poll has been published and you can now share the link."
    );
  } catch (error) {
    res.status(500);
    throw new Error(error);
  }
});

End Poll

The endPoll controller is used to end voting. It accepts a slug as request parameter. It essentially sets the endVoting field to true


vote.endPoll = asyncHandler(async (req, res) => {
  const slug = req.params.slug;

  try {
    const poll = await VoteModel.updateOne(
      { slug },
      {
        endVoting: true,
      }
    );

    if (!poll) {
      res.status(400);
      throw new Error("An error occured. It seems like your link is broken.");
    }

    responseHandle.successResponse(
      res,
      201,
      "The poll has been closed. Results can only be viewed."
    );
  } catch (error) {
    throw new Error(error);
  }
});

Add Vote

The addVote controller is used to allow the actual voting. It accepts a slug and a selectionId from the request parameter. It first checks if the slug is active, checks if the party selected exists, checks the voters details using Abstract Api with the user's ip address. It also checks to see if the user's location is allowed to vote and if the party added is up to two. Finally, it records the user's vote. The controller won't be complete without the Apstract api.


vote.addVote = asyncHandler(async (req, res) => {
  const slug = req.params.slug;
  const selectionId = req.params.selectionId;

  try {
    // Check if poll is still valid or ended or expired
    const poll = await VoteModel.findOne({
      slug,
      expiration: {
        $gte: Date.now(),
      },
      endVoting: false,
      draft: false,
    });

    if (!poll) {
      res.status(400);
      throw new Error(
        "You cannot vote now. Poll has either not been published yet or expired or has been closed."
      );
    }

    // Check if party user selected to vote exits
    const findParty = poll.parties.find(
      (x) => x._id.toString() === selectionId.toString()
    );

    if (!findParty) {
      res.status(400);
      throw new Error("Invalid party selected.");
    }

    // Check voter details
    const { data } = await axios.get(
      `${process.env.ABSTRACT_API}&ip_address=${req.ip}`
    );

    if (poll.allowVpn === false && data.security.is_vpn === true) {
      res.status(401);
      throw new Error("It seems that you use a vpn. Turn it off to vote.");
    }

    // Check if user location is allowed to vote
    if (
      poll.targetLocations.length > 0 &&
      poll.targetLocations.findIndex(
        (x) => x?.location?.toLowerCase() === data?.country?.toLowerCase()
      ) === -1
    ) {
      res.status(401);
      throw new Error(
        "People from your current location cannot vote in this poll."
      );
    }

    // Check if voters for party is greater than 0 and also check if user has voted for the party already
    if (
      findParty.voters.length > 0 &&
      findParty.voters.findIndex((x) => x === data.ip_address)
    ) {
      res.status(400);
      throw new Error("You already voted in this poll.");
    }

    // Update the vote count
    const vote = await VoteModel.updateOne(
      { slug, "parties._id": selectionId },
      {
        $push: {
          "parties.$.voters": data.ip_address,
        },
        $inc: {
          "parties.$.voteCount": 1,
        },
      }
    );

    if (!vote) {
      res.status(500);
      throw new Error(
        "An error occured. Your vote was not registered. Please try again."
      );
    }

    responseHandle.successResponse(res, 201, "Poll casted successfully");
  } catch (error) {
    throw new Error(error);
  }
});

I alse added the api to the .env file like:

ABSTRACT_API=https://ipgeolocation.abstractapi.com/v1/?api_key=api key

Get poll

This controller is used to get the details of a particular poll. It accepts a slug from the request parameters.


vote.getPoll = asyncHandler(async (req, res) => {
  const slug = req.params.slug;

  try {
    const poll = await VoteModel.findOne({ slug }).select(["-parties.voters"]);

    if (!poll) {
      res.status(400);
      throw new Error("An error occured. It seems like your link is broken.");
    }

    responseHandle.successResponse(
      res,
      201,
      "The poll was fetched successfully.",
      poll
    );
  } catch (error) {
    throw new Error(error);
  }
});

Get polls

This controllers gets all the votes that has been published.


vote.getPolls = asyncHandler(async (req, res) => {
  try {
    const poll = await VoteModel.find({ draft: false }).select([
      "-parties.voters",
    ]);

    if (poll.length === 0) {
      res.status(400);
      throw new Error(
        "An error occured. It seems like your link is broken or polls found."
      );
    }

    responseHandle.successResponse(
      res,
      201,
      "Polls has been fetched successfully.",
      poll
    );
  } catch (error) {
    throw new Error(error);
  }
});

Get Associated polls

This controller gets all the polls created by a user using their username. It accepts the username from the request parameter.


vote.getAssociatedPolls = asyncHandler(async (req, res) => {
  const username = req.params.username;
  try {
    const poll = await VoteModel.find({ "creator.username": username }).select([
      "-parties.voters",
    ]);

    if (poll.length === 0) {
      res.status(400);
      throw new Error(
        "An error occured. It seems like your link is broken or polls not found."
      );
    }

    responseHandle.successResponse(
      res,
      201,
      "Your Polls has been fetched successfully.",
      poll
    );
  } catch (error) {
    throw new Error(error);
  }
});

Middlewares

The middlewares will be used to protect some routes and allow only users with a valid session to access them. The middlewares includes the userMiddleware.js and spamCheck.js

User Middleware

This route essentially protects some routes from unauthorized access.

const jwt = require("jsonwebtoken");
const asyncHandler = require("express-async-handler");
const userModel = require("../models/user.model");
const { decodeToken } = require("../utilities/handleToken");

/**
 * @description This middleware checks the user admin token supplied as Bearer authorization
 * @required Bearer Authorization
 */

const protectUser = asyncHandler(async (req, res, next) => {
  let receivedToken = req.headers.authorization;
  let token;
  const eMessage = "You are not authorized to use this service, token failed";

  if (receivedToken && receivedToken.startsWith("Bearer")) {
    try {
      token = receivedToken.split(" ")[1];

      const decoded = decodeToken(token);

      const user = await userModel
        .findOne({
          username: decoded.fieldToSecure,
          // isVerified: true,
        })
        .select("-password");

      if (!user) {
        res.status(401);
        throw new Error("You are not authorized to use this service yet. ");
      }

      req.user = user;

      next();
    } catch (error) {
      res.status(401);
      throw new Error(error);
    }
  }

  if (!token) {
    res.status(401);
    throw new Error(
      "You are not authorized to use this service, no token provided."
    );
  }
});

module.exports = protectUser;

Spam Check middleware

This middleware is used to protect routes such as publish routes so that only the creators of those polls will be able to access them.

const jwt = require("jsonwebtoken");
const asyncHandler = require("express-async-handler");
const userModel = require("../models/user.model");
const { decodeToken } = require("../utilities/handleToken");
const VoteModel = require("../models/vote.models");

/**
 * @description This middleware checks the creator of a poll token supplied as Bearer authorization
 * @required Bearer Authorization
 */

const protectPoll = asyncHandler(async (req, res, next) => {
  const poll = await VoteModel.findOne({
    slug: req.params.slug,
    "creator.id": req.user._id,
  });

  if (!poll) {
    res.status(401);
    throw new Error("You cannot modify this poll.");
  }

  next();
});

module.exports = protectPoll;

Routes

The routes folder in the server directory holds our routes. This will define the methods and combine our middlewares and controllers with a particular endpoint.

This folder contains two files: user.routes.js and vote.routes.js.

User routes

The user.routes.js contains two routes that will allow for registration and login. Each route will have the associated middleware and controller.

const user = require("../controllers/user.controllers");
const validation = require("../utilities/form-validation/users");

const routes = require("express").Router();

// localhost:5020/api/users

routes.post("/register", validation.register, user.register);
routes.post("/login", validation.login, user.login);

module.exports = routes;

Vote route

The vote.routes.js contains all the appropriate urls, middleware and controller.

const vote = require("../controllers/vote.controllers");
const protectPoll = require("../middleware/spamCheck");
const protectUser = require("../middleware/userMiddleware");
const validation = require("../utilities/form-validation/vote");

const routes = require("express").Router();

// api/polls

routes.post("/create", validation.createPoll, protectUser, vote.createPoll);
routes.put(
  "/add-party/:slug",
  protectUser,
  protectPoll,
  validation.addParty,
  vote.addParty
);
routes.put(
  "/add-location/:slug",
  protectUser,
  protectPoll,
  validation.addLocation,
  vote.addLocation
);
routes.put("/publish/:slug", protectUser, protectPoll, vote.publishPoll);
routes.put("/end-poll/:slug", protectUser, protectPoll, vote.endPoll);
routes.put("/vote/:slug/:selectionId", vote.addVote);
routes.get("/view-poll/:slug", vote.getPoll);
routes.get("/view-polls", vote.getPolls);
routes.get("/view-polls/associated/:username", vote.getAssociatedPolls);

module.exports = routes;

Register Each route

To register each route, I opened the server.js file and added the following:

const userRoutes = require("./routes/user.routes");
const voteRoutes = require("./routes/vote.routes");

app.use(cors());
app.use(express.json());

app.use("/api/users", userRoutes);
app.use("/api/polls", voteRoutes);

// Error Middlewares
const { notFound, errorHandler } = require("./middleware/errorMiddleware");

With this final step, the server was ready and so I followed this tutorial I found on youtube to deploy the server to #Linode

Web Client Structure and technologies

Here, I opened the web-client folder and it's terminal. I ran npx create-next-app ./ to quickly generate a Next.js app. I cleaned up the defaults files and it's content. I installed the following packages: npm install axios bootstrap react-bootstrap react-icons react-redux redux redux-devtools-extension redux thunk. After that I started the dev server by running npm run dev

Setup app variables

Here I created a data folder that would hold the variable.js file which will contain the following variable:

// export const SERVER_URL = "https://clearvoter.xyz"; // This is the server live url.
 export const SERVER_URL = "http://localhost:5020";

Setup Redux

Redux is a state management library for react, it helps organize and share the app state easily accross components. It essentially prevents prop drilling.

In the root folder I created a redux subfolder and added the following subfolders to it: constants, actions and reducers. It also has a file in it: store.js

Constants

The constants will hold variables that would be used accross the app in relation to the redux. This holds two files: user.constants.js and vote.constants.js.

User constants

In the user.constants.js I added the following variables:

export const USER_REGISTER_REQUEST = "USER_REGISTER_REQUEST";
export const USER_REGISTER_SUCCESS = "USER_REGISTER_SUCCESS";
export const USER_REGISTER_FAIL = "USER_REGISTER_FAIL";

export const USER_LOGIN_REQUEST = "USER_LOGIN_REQUEST";
export const USER_LOGIN_SUCCESS = "USER_LOGIN_SUCCESS";
export const USER_LOGIN_FAIL = "USER_LOGIN_FAIL";
export const USER_LOGOUT = "USER_LOGOUT";
Vote constants

In the vote.constants.js I added the following constants:

export const CREATE_POLL_REQUEST = "CREATE_POLL_REQUEST";
export const CREATE_POLL_SUCCESS = "CREATE_POLL_SUCCESS";
export const CREATE_POLL_FAIL = "CREATE_POLL_FAIL";
export const CREATE_POLL_RESET = "CREATE_POLL_RESET";

export const GET_ALL_POLLS_REQUEST = "GET_ALL_POLLS_REQUEST";
export const GET_ALL_POLLS_SUCCESS = "GET_ALL_POLLS_SUCCESS";
export const GET_ALL_POLLS_FAIL = "GET_ALL_POLLS_FAIL";

export const GET_SINGLE_POLL_REQUEST = "GET_SINGLE_POLL_REQUEST";
export const GET_SINGLE_POLL_SUCCESS = "GET_SINGLE_POLL_SUCCESS";
export const GET_SINGLE_POLL_FAIL = "GET_SINGLE_POLL_FAIL";

export const VOTE_IN_POLL_REQUEST = "VOTE_IN_POLL_REQUEST";
export const VOTE_IN_POLL_SUCCESS = "VOTE_IN_POLL_SUCCESS";
export const VOTE_IN_POLL_FAIL = "VOTE_IN_POLL_FAIL";

export const PUBLISH_POLL_REQUEST = "PUBLISH_POLL_REQUEST";
export const PUBLISH_POLL_SUCCESS = "PUBLISH_POLL_SUCCESS";
export const PUBLISH_POLL_FAIL = "PUBLISH_POLL_FAIL";

export const END_POLL_REQUEST = "END_POLL_REQUEST";
export const END_POLL_SUCCESS = "END_POLL_SUCCESS";
export const END_POLL_FAIL = "END_POLL_FAIL";

export const GET_ALL_ASSOCIATED_REQUEST = "GET_ALL_ASSOCIATED_REQUEST";
export const GET_ALL_ASSOCIATED_SUCCESS = "GET_ALL_ASSOCIATED_SUCCESS";
export const GET_ALL_ASSOCIATED_FAIL = "GET_ALL_ASSOCIATED_FAIL";

export const ADD_POLL_PARTY_REQUEST = "ADD_POLL_PARTY_REQUEST";
export const ADD_POLL_PARTY_SUCCESS = "ADD_POLL_PARTY_SUCCESS";
export const ADD_POLL_PARTY_FAIL = "ADD_POLL_PARTY_FAIL";
export const ADD_POLL_PARTY_RESET = "ADD_POLL_PARTY_RESET";

export const ADD_POLL_LOCATION_REQUEST = "ADD_POLL_LOCATION_REQUEST";
export const ADD_POLL_LOCATION_SUCCESS = "ADD_POLL_LOCATION_SUCCESS";
export const ADD_POLL_LOCATION_FAIL = "ADD_POLL_LOCATION_FAIL";
export const ADD_POLL_LOCATION_RESET = "ADD_POLL_LOCATION_RESET";

Actions

The actions are essentially curried functions that helps us perform http requests, call reducers while passing a payload or response.

User Actions

The user actions contains a register, login and logout function. The login function also saves the user session to the local storage while the logout removes the user session.

import { SERVER_URL } from "../../data/variables";
import axios from "axios";

import {
  USER_LOGIN_FAIL,
  USER_LOGIN_REQUEST,
  USER_LOGIN_SUCCESS,
  USER_LOGOUT,
  USER_REGISTER_FAIL,
  USER_REGISTER_REQUEST,
  USER_REGISTER_SUCCESS,
} from "../constants/user.constants";

export const registerAction =
  (username, email, password) => async (dispatch) => {
    try {
      dispatch({
        type: USER_REGISTER_REQUEST,
      });
      const config = {
        headers: {
          "Content-Type": "application/json",
        },
      };
      const { data } = await axios.post(
        `${SERVER_URL}/api/users/register`,
        {
          username,
          email,
          password,
        },
        config
      );
      dispatch({
        type: USER_REGISTER_SUCCESS,
        payload: data,
      });
    } catch (error) {
      dispatch({
        type: USER_REGISTER_FAIL,
        payload:
          error?.response && error.response?.data?.message
            ? error?.response?.data?.message
            : error?.message,
      });
    }
  };

export const loginAction = (username, password) => async (dispatch) => {
  try {
    dispatch({
      type: USER_LOGIN_REQUEST,
    });
    const config = {
      headers: {
        "Content-Type": "application/json",
      },
    };
    const { data } = await axios.post(
      `${SERVER_URL}/api/users/login`,
      {
        username,
        password,
      },
      config
    );

    if (typeof window !== "undefined") {
      localStorage.setItem(
        "userInfo",
        JSON.stringify({
          token: data?.data?.token,
          username: data?.data?.username,
        })
      );
    }
    dispatch({
      type: USER_LOGIN_SUCCESS,
      payload: data,
    });
  } catch (error) {
    dispatch({
      type: USER_LOGIN_FAIL,
      payload:
        error?.response && error?.response?.data?.message
          ? error?.response?.data?.message
          : error?.message,
    });
  }
};

export const logout = () => (dispatch) => {
  dispatch({ type: USER_LOGOUT });
  localStorage.removeItem("userInfo");
  document.location.href = "/auth";
};
Vote actions

This holds all the actions and route for the actions.

import axios from "axios";
import { SERVER_URL } from "../../data/variables";
import {
  CREATE_POLL_REQUEST,
  CREATE_POLL_SUCCESS,
  CREATE_POLL_FAIL,
  GET_ALL_POLLS_REQUEST,
  GET_ALL_POLLS_SUCCESS,
  GET_ALL_POLLS_FAIL,
  GET_SINGLE_POLL_REQUEST,
  GET_SINGLE_POLL_SUCCESS,
  GET_SINGLE_POLL_FAIL,
  VOTE_IN_POLL_REQUEST,
  VOTE_IN_POLL_SUCCESS,
  VOTE_IN_POLL_FAIL,
  PUBLISH_POLL_REQUEST,
  PUBLISH_POLL_SUCCESS,
  PUBLISH_POLL_FAIL,
  END_POLL_REQUEST,
  END_POLL_SUCCESS,
  END_POLL_FAIL,
  GET_ALL_ASSOCIATED_REQUEST,
  GET_ALL_ASSOCIATED_SUCCESS,
  GET_ALL_ASSOCIATED_FAIL,
  ADD_POLL_PARTY_REQUEST,
  ADD_POLL_PARTY_SUCCESS,
  ADD_POLL_PARTY_FAIL,
  ADD_POLL_LOCATION_REQUEST,
  ADD_POLL_LOCATION_SUCCESS,
  ADD_POLL_LOCATION_FAIL,
} from "../constants/vote.constants";

export const createPollAction =
  (title, description, expiration, allowVpn) => async (dispatch, getState) => {
    try {
      dispatch({
        type: CREATE_POLL_REQUEST,
      });

      const {
        loginUser: { userInfo },
      } = getState();

      let config = {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${userInfo?.token}`,
        },
      };

      const { data } = await axios.post(
        `${SERVER_URL}/api/polls/create`,
        {
          title,
          description,
          expiration,
          allowVpn,
        },
        config
      );
      dispatch({
        type: CREATE_POLL_SUCCESS,
        payload: data,
      });
    } catch (error) {
      dispatch({
        type: CREATE_POLL_FAIL,
        payload:
          error?.response && error?.response?.data?.message
            ? error?.response?.data?.message
            : error?.message,
      });
    }
  };

export const getAllPollsAction = () => async (dispatch) => {
  try {
    dispatch({
      type: GET_ALL_POLLS_REQUEST,
    });

    let config = {
      headers: {
        "Content-Type": "application/json",
      },
    };

    const { data } = await axios.get(
      `${SERVER_URL}/api/polls/view-polls`,

      config
    );
    dispatch({
      type: GET_ALL_POLLS_SUCCESS,
      payload: data,
    });
  } catch (error) {
    dispatch({
      type: GET_ALL_POLLS_FAIL,
      payload:
        error?.response && error?.response?.data?.message
          ? error?.response?.data?.message
          : error?.message,
    });
  }
};

export const getSinglePollAction = (slug) => async (dispatch) => {
  try {
    dispatch({
      type: GET_SINGLE_POLL_REQUEST,
    });

    let config = {
      headers: {
        "Content-Type": "application/json",
      },
    };

    const { data } = await axios.get(
      `${SERVER_URL}/api/polls/view-poll/${slug}`,

      config
    );
    dispatch({
      type: GET_SINGLE_POLL_SUCCESS,
      payload: data,
    });
  } catch (error) {
    dispatch({
      type: GET_SINGLE_POLL_FAIL,
      payload:
        error?.response && error?.response?.data?.message
          ? error?.response?.data?.message
          : error?.message,
    });
  }
};
export const voteInPollAction = (slug, selectionId) => async (dispatch) => {
  try {
    dispatch({
      type: VOTE_IN_POLL_REQUEST,
    });

    let config = {
      headers: {
        "Content-Type": "application/json",
      },
    };

    const { data } = await axios.put(
      `${SERVER_URL}/api/polls/vote/${slug}/${selectionId}`,
      {},
      config
    );
    dispatch({
      type: VOTE_IN_POLL_SUCCESS,
      payload: data,
    });
  } catch (error) {
    dispatch({
      type: VOTE_IN_POLL_FAIL,
      payload:
        error?.response && error?.response?.data?.message
          ? error?.response?.data?.message
          : error?.message,
    });
  }
};
export const publishPollAction = (slug) => async (dispatch, getState) => {
  try {
    dispatch({
      type: PUBLISH_POLL_REQUEST,
    });

    const {
      loginUser: { userInfo },
    } = getState();

    let config = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${userInfo?.token}`,
      },
    };

    const { data } = await axios.put(
      `${SERVER_URL}/api/polls/publish/${slug}`,
      {},
      config
    );
    dispatch({
      type: PUBLISH_POLL_SUCCESS,
      payload: data,
    });
  } catch (error) {
    dispatch({
      type: PUBLISH_POLL_FAIL,
      payload:
        error?.response && error?.response?.data?.message
          ? error?.response?.data?.message
          : error?.message,
    });
  }
};
export const endPollAction = (slug) => async (dispatch, getState) => {
  try {
    dispatch({
      type: END_POLL_REQUEST,
    });

    const {
      loginUser: { userInfo },
    } = getState();

    let config = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${userInfo?.token}`,
      },
    };

    const { data } = await axios.put(
      `${SERVER_URL}/api/polls/end-poll/${slug}`,
      {},
      config
    );
    dispatch({
      type: END_POLL_SUCCESS,
      payload: data,
    });
  } catch (error) {
    dispatch({
      type: END_POLL_FAIL,
      payload:
        error?.response && error?.response?.data?.message
          ? error?.response?.data?.message
          : error?.message,
    });
  }
};

export const getAllAssociatedPollsAction = (username) => async (dispatch) => {
  try {
    dispatch({
      type: GET_ALL_ASSOCIATED_REQUEST,
    });

    let config = {
      headers: {
        "Content-Type": "application/json",
      },
    };

    const { data } = await axios.get(
      `${SERVER_URL}/api/polls/view-polls/associated/${username}`,

      config
    );
    dispatch({
      type: GET_ALL_ASSOCIATED_SUCCESS,
      payload: data,
    });
  } catch (error) {
    dispatch({
      type: GET_ALL_ASSOCIATED_FAIL,
      payload:
        error?.response && error?.response?.data?.message
          ? error?.response?.data?.message
          : error?.message,
    });
  }
};

export const addPollPartyAction =
  (slug, name, description) => async (dispatch, getState) => {
    try {
      dispatch({
        type: ADD_POLL_PARTY_REQUEST,
      });

      const {
        loginUser: { userInfo },
      } = getState();

      let config = {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${userInfo?.token}`,
        },
      };

      const { data } = await axios.put(
        `${SERVER_URL}/api/polls/add-party/${slug}`,
        {
          name,
          description,
        },
        config
      );
      dispatch({
        type: ADD_POLL_PARTY_SUCCESS,
        payload: data,
      });
    } catch (error) {
      dispatch({
        type: ADD_POLL_PARTY_FAIL,
        payload:
          error?.response && error?.response?.data?.message
            ? error?.response?.data?.message
            : error?.message,
      });
    }
  };

export const addPollLocationAction =
  (slug, location) => async (dispatch, getState) => {
    try {
      dispatch({
        type: ADD_POLL_LOCATION_REQUEST,
      });

      const {
        loginUser: { userInfo },
      } = getState();

      let config = {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${userInfo?.token}`,
        },
      };

      const { data } = await axios.put(
        `${SERVER_URL}/api/polls/add-location/${slug}`,
        {
          location,
        },
        config
      );
      dispatch({
        type: ADD_POLL_LOCATION_SUCCESS,
        payload: data,
      });
    } catch (error) {
      dispatch({
        type: ADD_POLL_LOCATION_FAIL,
        payload:
          error?.response && error?.response?.data?.message
            ? error?.response?.data?.message
            : error?.message,
      });
    }
  };

Reducers

The reducers is used to modify our app state.

User Reducer

This holds the user reducers.

import {
  USER_LOGIN_FAIL,
  USER_LOGIN_REQUEST,
  USER_LOGIN_SUCCESS,
  USER_LOGOUT,
  USER_REGISTER_FAIL,
  USER_REGISTER_REQUEST,
  USER_REGISTER_SUCCESS,
} from "../constants/user.constants";

export const userRegisterReducer = (state = {}, action) => {
  switch (action.type) {
    case USER_REGISTER_REQUEST:
      return { loading: true };
    case USER_REGISTER_SUCCESS:
      return { loading: false, success: true, userInfo: action.payload };
    case USER_REGISTER_FAIL:
      return { loading: false, success: false, error: action.payload };
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};
export const userLoginReducer = (state = {}, action) => {
  switch (action.type) {
    case USER_LOGIN_REQUEST:
      return { loading: true };
    case USER_LOGIN_SUCCESS:
      return { loading: false, success: true, userInfo: action.payload };
    case USER_LOGIN_FAIL:
      return { loading: false, success: false, error: action.payload };
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};
Vote Reducers

This holds the vote reducers.

import { USER_LOGOUT } from "../constants/user.constants";
import {
  ADD_POLL_LOCATION_FAIL,
  ADD_POLL_LOCATION_REQUEST,
  ADD_POLL_LOCATION_RESET,
  ADD_POLL_LOCATION_SUCCESS,
  ADD_POLL_PARTY_FAIL,
  ADD_POLL_PARTY_REQUEST,
  ADD_POLL_PARTY_RESET,
  ADD_POLL_PARTY_SUCCESS,
  CREATE_POLL_FAIL,
  CREATE_POLL_REQUEST,
  CREATE_POLL_RESET,
  CREATE_POLL_SUCCESS,
  END_POLL_FAIL,
  END_POLL_REQUEST,
  END_POLL_SUCCESS,
  GET_ALL_ASSOCIATED_FAIL,
  GET_ALL_ASSOCIATED_REQUEST,
  GET_ALL_ASSOCIATED_SUCCESS,
  GET_ALL_POLLS_FAIL,
  GET_ALL_POLLS_REQUEST,
  GET_ALL_POLLS_SUCCESS,
  GET_SINGLE_POLL_FAIL,
  GET_SINGLE_POLL_REQUEST,
  GET_SINGLE_POLL_SUCCESS,
  PUBLISH_POLL_FAIL,
  PUBLISH_POLL_REQUEST,
  PUBLISH_POLL_SUCCESS,
  VOTE_IN_POLL_FAIL,
  VOTE_IN_POLL_REQUEST,
  VOTE_IN_POLL_SUCCESS,
} from "../constants/vote.constants";

export const createPollReducer = (state = {}, action) => {
  switch (action.type) {
    case CREATE_POLL_REQUEST:
      return { loading: true };
    case CREATE_POLL_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case CREATE_POLL_FAIL:
      return { loading: false, success: false, error: action.payload };
    case CREATE_POLL_RESET:
      return {};
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

export const getAllPollsReducer = (state = {}, action) => {
  switch (action.type) {
    case GET_ALL_POLLS_REQUEST:
      return { loading: true };
    case GET_ALL_POLLS_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case GET_ALL_POLLS_FAIL:
      return { loading: false, success: false, error: action.payload };
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

export const getSinglePollReducer = (state = {}, action) => {
  switch (action.type) {
    case GET_SINGLE_POLL_REQUEST:
      return { loading: true };
    case GET_SINGLE_POLL_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case GET_SINGLE_POLL_FAIL:
      return { loading: false, success: false, error: action.payload };
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

export const voteInPollReducer = (state = {}, action) => {
  switch (action.type) {
    case VOTE_IN_POLL_REQUEST:
      return { loading: true };
    case VOTE_IN_POLL_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case VOTE_IN_POLL_FAIL:
      return { loading: false, success: false, error: action.payload };
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

export const publishPollReducer = (state = {}, action) => {
  switch (action.type) {
    case PUBLISH_POLL_REQUEST:
      return { loading: true };
    case PUBLISH_POLL_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case PUBLISH_POLL_FAIL:
      return { loading: false, success: false, error: action.payload };
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

export const endPollReducer = (state = {}, action) => {
  switch (action.type) {
    case END_POLL_REQUEST:
      return { loading: true };
    case END_POLL_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case END_POLL_FAIL:
      return { loading: false, success: false, error: action.payload };
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

export const getAllAssociatedPollsReducer = (state = {}, action) => {
  switch (action.type) {
    case GET_ALL_ASSOCIATED_REQUEST:
      return { loading: true };
    case GET_ALL_ASSOCIATED_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case GET_ALL_ASSOCIATED_FAIL:
      return { loading: false, success: false, error: action.payload };
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

export const addPollPartyReducer = (state = {}, action) => {
  switch (action.type) {
    case ADD_POLL_PARTY_REQUEST:
      return { loading: true };
    case ADD_POLL_PARTY_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case ADD_POLL_PARTY_FAIL:
      return { loading: false, success: false, error: action.payload };
    case ADD_POLL_PARTY_RESET:
      return {};
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

export const addPollLocationReducer = (state = {}, action) => {
  switch (action.type) {
    case ADD_POLL_LOCATION_REQUEST:
      return { loading: true };
    case ADD_POLL_LOCATION_SUCCESS:
      return { loading: false, success: true, pollInfo: action.payload };
    case ADD_POLL_LOCATION_FAIL:
      return { loading: false, success: false, error: action.payload };
    case ADD_POLL_LOCATION_RESET:
      return {};
    case USER_LOGOUT:
      return {};
    default:
      return state;
  }
};

Store

The store is where redux stores the app states.

import {
  legacy_createStore as createStore,
  combineReducers,
  applyMiddleware,
} from "redux";
import thunk from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";

import {
  userRegisterReducer,
  userLoginReducer,
} from "./reducers/user.reducers";

import {
  addPollLocationReducer,
  addPollPartyReducer,
  createPollReducer,
  endPollReducer,
  getAllAssociatedPollsReducer,
  getAllPollsReducer,
  getSinglePollReducer,
  publishPollReducer,
  voteInPollReducer,
} from "./reducers/vote.reducers";

const reducer = combineReducers({
  // Users
  registerUser: userRegisterReducer,
  loginUser: userLoginReducer,

  // POLLS
  pollCreate: createPollReducer,
  pollsGetAll: getAllPollsReducer,
  pollGetSingle: getSinglePollReducer,
  pollVoteIn: voteInPollReducer,
  pollPublish: publishPollReducer,
  pollEnd: endPollReducer,
  pollsGetAllAssociated: getAllAssociatedPollsReducer,
  pollPartyAdd: addPollPartyReducer,
  pollLocationAdd: addPollLocationReducer,
});

// Local storage matters
let userInfoFromStorage;
if (typeof window !== "undefined") {
  userInfoFromStorage = localStorage.getItem("userInfo")
    ? JSON.parse(localStorage.getItem("userInfo"))
    : null;
}

// initial state
const initialState = {
  loginUser: {
    userInfo: userInfoFromStorage,
  },
};

const middleware = [thunk];

const store = createStore(
  reducer,
  initialState,
  composeWithDevTools(applyMiddleware(...middleware))
);

export default store;

Setup redux provider

The provider is used to wrap the entire app so it will have access to the redux state. In the _app.js file which is in the pages folder I added:

import "bootstrap/dist/css/bootstrap.min.css";
import "../styles/globals.css";
import { Provider } from "react-redux";
import store from "../redux/store";

function MyApp({ Component, pageProps }) {
  return (
    <Provider store={store}>

        <Component {...pageProps} />

    </Provider>
  );
}

export default MyApp;

Note: I also imported the already installed bootstrap package.

Setup Message and Loader

The Message and Loader components are used as a helper component to show alerts and a pending animation. To add them, I created a components folder in the root folder and added the Message.jsx and Loader.jsx files that contains the following contents:

Message.jsx:

import React from "react";
import { Alert } from "react-bootstrap";

const Message = ({ variant, children }) => {
  return (
    <div className="px-3">
      <Alert variant={variant}>{children}</Alert>
    </div>
  );
};

Message.defaultProps = {
  variant: "info",
};

export default Message;

Loader.jsx:

import React from "react";
import { Spinner } from "react-bootstrap";

const Loader = ({
  animation = "border",
  color = "#272727",
  size = "50",
  display = "block",
}) => {
  return (
    <Spinner
      animation={animation}
      role="status"
      style={{
        width: size + "px",
        height: size + "px",
        margin: "auto",
        display,
        color,
      }}
    >
      <span className="sr-only">Loading...</span>
    </Spinner>
  );
};

export default Loader;

Setup Layout

The layout is a component that wraps the entire app inorder to provide a persistent nav bar and footer. To add it, created the Layout.jsx file that contains the following content and added it to the components folder:

import React, { useEffect, useState } from "react";
import {
  Container,
  Image,
  Nav,
  Navbar,
  Button,
  ButtonGroup,
  Row,
  Col,
} from "react-bootstrap";
import { useDispatch, useSelector } from "react-redux";
import Link from "next/link";
import { logout } from "../redux/actions/user.actions";

const Layout = ({ children }) => {
  const dispatch = useDispatch();
  const [userInfo, setUserInfo] = useState({});
  const { userInfo: userInfoReducer } = useSelector((state) => state.loginUser);

  useEffect(() => {
    if (userInfo) {
      setUserInfo(userInfoReducer);
    }
  }, []);

  return (
    <>
      <Container>
        <Navbar
          bg="dark"
          variant="dark"
          expand="lg"
          collapseOnSelect
          fixed="top"
          className="bg-black px-5"
        >
          <Navbar.Brand>
            <Link href="/" passHref>
              <a>
                <Image
                  src="/logo.png"
                  width={176.2}
                  height={62.6}
                  style={{
                    objectFit: "contain",
                    cursor: "pointer",
                  }}
                />
              </a>
            </Link>
          </Navbar.Brand>
          <Navbar.Toggle aria-controls="basic-navbar-nav" />
          <Navbar.Collapse id="basic-navbar-nav">
            <Nav className="me-auto">
              <Link href="/" passHref>
                <Nav.Link>Home</Nav.Link>
              </Link>
              <Link href="/about" passHref>
                <Nav.Link>About</Nav.Link>
              </Link>
            </Nav>
            <Nav>
              {!userInfo && (
                <Link href="/auth" passHref>
                  <Nav.Link>Login / Register</Nav.Link>
                </Link>
              )}

              {userInfo && (
                <ButtonGroup style={{ maxWidth: "20rem" }}>
                  <Link href="/dashboard" passHref>
                    <Nav.Link className="btn btn-secondary text-white">
                      Dashboard - {userInfo?.username}
                    </Nav.Link>
                  </Link>

                  <Button
                    size="sm"
                    onClick={() => dispatch(logout())}
                    variant="danger"
                  >
                    Logout
                  </Button>
                </ButtonGroup>
              )}
            </Nav>
          </Navbar.Collapse>
        </Navbar>
      </Container>
      <div className="mt-5 pt-5" style={{ minHeight: "calc(100vh - 10rem)" }}>
        {children}
      </div>
      <footer style={{ backgroundColor: "#ddd" }} className="py-5">
        <Container>
          <p>
            This <strong>open-source</strong> project was started by{" "}
            <a href="https://bonarhyme.com" target="_blank" rel="noopener">
              <strong>Onuorah Bonaventure Chukwudi</strong>
            </a>{" "}
            in reponse to the{" "}
            <a
              href="https://www.linode.com/?utm_source=hashnode&utm_medium=article&utm_campaign=hackathon_announcement"
              className="decorate"
            >
              Linode
            </a>{" "}
            and{" "}
            <a href="https://hashnode.com/" className="decorate">
              Hashnode
            </a>{" "}
            June 2022 Hackathon. Contributions are welcome. To contribute visit{" "}
            <a
              href="https://github.com/bonarhyme/clearvoter-server"
              target="_blank"
              rel="noopener"
              className="decorate"
            >
              server source code
            </a>{" "}
            and{" "}
            <a
              href="https://github.com/bonarhyme/clearvoter-web-client"
              target="_blank"
              rel="noopener"
              className="decorate"
            >
              web client source code
            </a>
          </p>
          <p className="text-center">
            Copyright &copy; ClearVoter 2022
            {new Date().getFullYear() !== 2022
              ? "-" + new Date().getFullYear()
              : ""}
          </p>
        </Container>
      </footer>
    </>
  );
};

export default Layout;

After creating the Layout.jsx file, I wrapped the entire _app.js with it:

import "bootstrap/dist/css/bootstrap.min.css";
import "../styles/globals.css";
import { Provider } from "react-redux";
import store from "../redux/store";
import Layout from "../components/Layout";

function MyApp({ Component, pageProps }) {
  return (
    <Provider store={store}>
      <Layout>
        <Component {...pageProps} />{" "}
      </Layout>
    </Provider>
  );
}

export default MyApp;

Setup Home page

Here I started by moddifying the index.js file in the pages folder:

import { useEffect } from "react";
import Head from "next/head";
import Link from "next/link";
import { Card, Container } from "react-bootstrap";
import { useDispatch, useSelector } from "react-redux";

import Loader from "../components/Loader";
import Message from "../components/Message";
import { getAllPollsAction } from "../redux/actions/vote.actions";

export default function Home() {
  const dispatch = useDispatch();

  const {
    loading: loadingAll,
    success: successAll,
    pollInfo: pollInfoAll,
    error: errorAll,
  } = useSelector((store) => store.pollsGetAll);

  useEffect(() => {
    dispatch(getAllPollsAction());
  }, []);

  return (
    <div className="pb-5 pt-3">
      <Head>
        <title>Clear Voter | Home </title>
        <meta name="description" content="The transparent poling platform" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <Container>
        <header>
          <h1
            className="text-center py-4"
            style={{ textDecoration: "overline" }}
          >
            All polls
          </h1>
        </header>
        <main>
          <>
            {loadingAll && <Loader color="black" />}
            {errorAll && <Message variant="danger">{errorAll}</Message>}
            {successAll &&
              pollInfoAll?.data.length > 0 &&
              pollInfoAll?.data.map((poll) => {
                const {
                  creator,
                  title,
                  description,
                  expiration,
                  slug,
                  createdAt,
                  _id,
                } = poll;
                return (
                  <Card key={_id} className="mb-5">
                    <Card.Header>
                      <Card.Title>{title}</Card.Title>
                      <Card.Text>
                        <small>
                          Creator: {creator?.username} | Published:{" "}
                          {new Date(createdAt).toLocaleDateString()} | expires:{" "}
                          {new Date(expiration).toLocaleString()}
                        </small>
                      </Card.Text>
                    </Card.Header>
                    <Card.Body>
                      <Card.Text>
                        {description.slice(0, 255)}
                        ...
                      </Card.Text>
                    </Card.Body>
                    <Card.Footer>
                      <Link href={`polls/${slug}`} passHref>
                        <Card.Link className="decorate">View more</Card.Link>
                      </Link>
                    </Card.Footer>
                  </Card>
                );
              })}
          </>
        </main>
      </Container>
    </div>
  );
}

Setup Auth Page

Next, I setup the authentication page. It will allow us register and login to the website. I started by creating a auth.js file inside the pages folder:

import React, { useEffect, useState } from "react";
import { Container, Form, Button } from "react-bootstrap";
import Link from "next/link";
import Message from "../components/Message";
import Loader from "../components/Loader";
import { registerAction, loginAction } from "../redux/actions/user.actions";
import { useDispatch, useSelector } from "react-redux";

const Auth = () => {
  const dispatch = useDispatch();
  const [registerScreen, setRegisterScreen] = useState(false);
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [confirmPassword, setConfirmPassword] = useState("");
  const [passwordMessage, setPasswordMessage] = useState("");

  const {
    loading: loadingReg,
    success: successReg,
    userInfo: userInfoReg,
    error: errorReg,
  } = useSelector((store) => store.registerUser);
  const {
    loading: loadingLogin,
    success: successLogin,
    userInfo: userInfoLogin,
    error: errorLogin,
  } = useSelector((store) => store.loginUser);

  const handleRegister = (e) => {
    e.preventDefault();

    if (confirmPassword !== password) {
      setPasswordMessage("Passwords do not match.");
    } else {
      dispatch(registerAction(username, email, password));
    }
  };
  const handleLogin = (e) => {
    e.preventDefault();
    dispatch(loginAction(username, password));
  };

  useEffect(() => {
    let timeOut;
    if (successReg || userInfoReg?.message) {
      timeOut = setTimeout(() => {
        setRegisterScreen(false);
        setEmail("");
        setConfirmPassword("");
        setPassword("");
      }, 5000);
    }

    return () => clearTimeout(timeOut);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [successReg]);

  useEffect(() => {
    let timeOut;
    if (successLogin) {
      setUsername("");
      setPassword("");

      timeOut = setTimeout(() => {
        if (typeof window !== "undefined") {
          document.location.href = "/";
        }
      }, 3000);
    }
    return () => clearTimeout(timeOut);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [successLogin]);

  return (
    <div>
      <h1 className="text-center pt-2">
        {registerScreen ? "Register" : "Login"}
      </h1>
      <Container className="mt-5" style={{ maxWidth: "30rem" }}>
        <Form
          className="border p-3"
          style={{ backgroundColor: "#eee" }}
          onSubmit={registerScreen ? handleRegister : handleLogin}
        >
          {loadingLogin && <Loader color="black" />}
          {loadingReg && <Loader color="black" />}
          {registerScreen && errorReg && (
            <Message variant="danger">{errorReg}</Message>
          )}

          {!registerScreen && errorLogin && (
            <Message variant="danger">{errorLogin}</Message>
          )}
          {registerScreen && successReg && (
            <Message variant="success">{userInfoReg.message}</Message>
          )}
          {!registerScreen && successLogin && (
            <Message variant="success">{userInfoLogin.message}</Message>
          )}
          {registerScreen && (
            <Form.Group controlId="formBasicEmail" className="my-3">
              <Form.Label>Email address</Form.Label>
              <Form.Control
                type="email"
                placeholder="e.g name@example.com"
                onChange={(e) => setEmail(e.target.value)}
                required
                value={email}
              />
            </Form.Group>
          )}
          <Form.Group controlId="formBasicUsername" className="my-3">
            <Form.Label>Username</Form.Label>
            <Form.Control
              type="text"
              placeholder="e.g John123"
              onChange={(e) => setUsername(e.target.value)}
              required
              value={username}
            />
          </Form.Group>
          <Form.Group controlId="formBasicPassword" className="my-3">
            <Form.Label>Password</Form.Label>
            <Form.Control
              type="password"
              placeholder="Enter password"
              onChange={(e) => {
                setPassword(e.target.value);
                setPasswordMessage("");
              }}
              required
              value={password}
            />
          </Form.Group>

          {registerScreen && (
            <>
              {passwordMessage && (
                <Message variant="danger">{passwordMessage}</Message>
              )}
              <Form.Group controlId="confirmPassword">
                <Form.Label>Confirm Password</Form.Label>

                <Form.Control
                  type="password"
                  placeholder="Confirm your password"
                  onChange={(e) => {
                    setConfirmPassword(e.target.value);
                    setPasswordMessage("");
                  }}
                  required
                  value={confirmPassword}
                />
              </Form.Group>
            </>
          )}
          <Button size="lg" type="submit" className="my-3">
            {registerScreen ? "Register" : "Login"}
          </Button>

          <p>
            {registerScreen ? (
              <span>
                Already have an account?{" "}
                <Link href="/auth" passHref>
                  <a
                    onClick={() => setRegisterScreen(false)}
                    className="decorate"
                  >
                    Login
                  </a>
                </Link>{" "}
              </span>
            ) : (
              <>
                <span>
                  Don&apos;t have an account?{" "}
                  <Link href="/auth" passHref>
                    <a
                      onClick={() => setRegisterScreen(true)}
                      className="decorate"
                    >
                      Register
                    </a>
                  </Link>{" "}
                </span>
              </>
            )}
          </p>
        </Form>
      </Container>
    </div>
  );
};

export default Auth;

Setup VoteInPoll

The VoteInPoll.jsx component lives in the components folder and will allow a poll to be voted in.

import React, { useState } from "react";
import { Button, Card } from "react-bootstrap";
import { useSelector, useDispatch } from "react-redux";
import { voteInPollAction } from "../redux/actions/vote.actions";
import Loader from "./Loader";
import Message from "./Message";

const VoteInPoll = ({ pollInfoSingle, party, index }) => {
  const dispatch = useDispatch();
  const [currentComponent, setCurrentComponent] = useState(false);

  const {
    loading: loadingVoteInPoll,
    success: successVoteInPoll,
    pollInfo: pollInfoVoteInPoll,
    error: errorVoteInPoll,
  } = useSelector((store) => store.pollVoteIn);

  const voteInPollHandler = (slug, selectionId) => {
    dispatch(voteInPollAction(slug, selectionId));
  };

  return (
    <Card>
      <Card.Header style={{ textTransform: "capitalize" }}>
        <b>
          {index + 1}. {party?.name}
        </b>
      </Card.Header>
      <Card.Body>
        <Card.Text>{party?.description}</Card.Text>
        <Card.Text>
          Vote Count: <b>{party?.voteCount}</b>
        </Card.Text>
        {currentComponent && errorVoteInPoll && (
          <Message variant="danger">{errorVoteInPoll}</Message>
        )}
        {currentComponent && successVoteInPoll && (
          <Message variant="success">{pollInfoVoteInPoll?.message}</Message>
        )}

        {pollInfoSingle?.data?.parties.length >= 2 && (
          <Card.Text>
            {currentComponent && loadingVoteInPoll && (
              <Loader color="black" size="30" />
            )}
            <Button
              size="sm"
              variant="primary"
              type="button"
              disabled={loadingVoteInPoll}
              onClick={() => {
                setCurrentComponent(true);
                voteInPollHandler(pollInfoSingle?.data?.slug, party?._id);
              }}
            >
              Vote{" "}
            </Button>
          </Card.Text>
        )}
      </Card.Body>
    </Card>
  );
};

export default VoteInPoll;

Setup AddPollParty

The AddPollParty.jsx component in the components folder allows an authorized user to add parties to the poll:

import React, { useEffect, useState } from "react";
import { Form, Button } from "react-bootstrap";
import { useDispatch, useSelector } from "react-redux";
import Loader from "./Loader";
import Message from "./Message";
import { ADD_POLL_PARTY_RESET } from "../redux/constants/vote.constants";
import { addPollPartyAction } from "../redux/actions/vote.actions";

const AddPollParty = () => {
  const dispatch = useDispatch();

  const [name, setName] = useState("");
  const [description, setDescription] = useState("");

  const {
    loading: loadingAddPollParty,
    success: successAddPollParty,
    pollInfo: voteInfoAddPollParty,
    error: errorAddPollParty,
  } = useSelector((store) => store.pollPartyAdd);

  const addPollPartyHandler = (e) => {
    e.preventDefault();
    const slug = window.location.href.split("polls/")[1];
    dispatch(addPollPartyAction(slug, name, description));
  };

  useEffect(() => {
    let timeOut;
    if (successAddPollParty) {
      setName("");
      setDescription("");
      timeOut = setTimeout(() => {
        dispatch({ type: ADD_POLL_PARTY_RESET });
      }, 5000);
    }

    return () => clearTimeout(timeOut);
  }, [successAddPollParty]);

  return (
    <Form
      className="border p-3"
      style={{ backgroundColor: "#eee" }}
      onSubmit={addPollPartyHandler}
    >
      {loadingAddPollParty && <Loader color="black" />}

      {errorAddPollParty && (
        <Message variant="danger">{errorAddPollParty}</Message>
      )}
      {successAddPollParty && (
        <Message variant="success">{voteInfoAddPollParty?.message}</Message>
      )}
      <h2 className="py-2">Add Poll Party</h2>
      <Form.Group controlId="formBasicName" className="my-3">
        <Form.Label>Name</Form.Label>
        <Form.Control
          type="text"
          placeholder="Enter Name"
          onChange={(e) => setName(e.target.value)}
          required
          value={name}
        />
      </Form.Group>
      <Form.Group controlId="formBasicDescription" className="my-3">
        <Form.Label>Description</Form.Label>
        <Form.Control
          type="text"
          as="textarea"
          placeholder="Enter Description"
          onChange={(e) => setDescription(e.target.value)}
          required
          value={description}
          style={{ maxHeight: "25rem" }}
        />
      </Form.Group>
      <Button size="lg" type="submit" className="my-3">
        Add Poll Party
      </Button>
    </Form>
  );
};

export default AddPollParty;

Setup AddPollLocation

The AddPollLocation.jsx component is used to add the limited target locations to a poll by an authorized user:

import React, { useEffect, useState } from "react";
import { Form, Button } from "react-bootstrap";
import { useDispatch, useSelector } from "react-redux";
import Loader from "./Loader";
import Message from "./Message";
import { ADD_POLL_LOCATION_RESET } from "../redux/constants/vote.constants";
import { addPollLocationAction } from "../redux/actions/vote.actions";

const AddPollLocation = () => {
  const dispatch = useDispatch();

  const [location, setLocation] = useState("");

  const {
    loading: loadingAddPollLocation,
    success: successAddPollLocation,
    pollInfo: voteInfoAddPollLocation,
    error: errorAddPollLocation,
  } = useSelector((store) => store.pollLocationAdd);

  const addPollLocationHandler = (e) => {
    e.preventDefault();
    const slug = window.location.href.split("polls/")[1];
    dispatch(addPollLocationAction(slug, location));
  };

  useEffect(() => {
    let timeOut;
    if (successAddPollLocation) {
      setLocation("");

      timeOut = setTimeout(() => {
        dispatch({ type: ADD_POLL_LOCATION_RESET });
      }, 5000);
    }

    return () => clearTimeout(timeOut);
  }, [successAddPollLocation]);

  return (
    <Form
      className="border p-3"
      style={{ backgroundColor: "#eee" }}
      onSubmit={addPollLocationHandler}
    >
      {loadingAddPollLocation && <Loader color="black" />}

      {errorAddPollLocation && (
        <Message variant="danger">{errorAddPollLocation}</Message>
      )}
      {successAddPollLocation && (
        <Message variant="success">{voteInfoAddPollLocation?.message}</Message>
      )}
      <h2 className="py-2">Add Poll Location</h2>
      <Form.Group controlId="formBasicLocation" className="my-3">
        <Form.Label>Location</Form.Label>
        <Form.Control
          type="text"
          placeholder="Enter Location"
          onChange={(e) => setLocation(e.target.value)}
          required
          value={location}
        />
      </Form.Group>

      <Button size="lg" type="submit" className="my-3" variant="secondary">
        Add Poll Location
      </Button>
    </Form>
  );
};

export default AddPollLocation;

Setup EditPoll

This EditPoll.jsx component in the components folder is used to combine the AddPollParty.jsx and AddPollLocation.jsx components.

import React, { useState } from "react";
import { Button, Card, Row, Col } from "react-bootstrap";
import { FaTimes } from "react-icons/fa";
import AddPollLocation from "./AddPollLocation";
import AddPollParty from "./AddPollParty";

const EditPoll = () => {
  const [showPartyForm, setShowPartyForm] = useState(true);
  const [showLocationForm, setShowLocationForm] = useState(true);

  return (
    <section className="py-5">
      <Row>
        <Col xs={12} md={6} style={{ position: "relative" }} className="mb-3">
          {showPartyForm ? (
            <Card className="p-2">
              <FaTimes
                size={30}
                color="#dc3545"
                onClick={() => setShowPartyForm(false)}
                style={{
                  cursor: "pointer",
                  margin: "0.5rem",
                  position: "absolute",
                  right: "0",
                }}
                title="close party form"
              />
              <AddPollParty />
            </Card>
          ) : (
            <Button
              size="lg"
              variant="primary"
              onClick={() => setShowPartyForm(true)}
            >
              Add Poll Party
            </Button>
          )}
        </Col>
        <Col xs={12} md={6} style={{ position: "relative" }}>
          {showLocationForm ? (
            <Card className="p-2">
              <FaTimes
                size={30}
                color="#dc3545"
                onClick={() => setShowLocationForm(false)}
                style={{
                  cursor: "pointer",
                  margin: "0.5rem",
                  position: "absolute",
                  right: "0",
                }}
                title="close location form"
              />
              <AddPollLocation />
            </Card>
          ) : (
            <Button
              size="lg"
              variant="secondary"
              onClick={() => setShowLocationForm(true)}
            >
              Add Target Location
            </Button>
          )}
        </Col>
      </Row>
    </section>
  );
};

export default EditPoll;

Setup the poll screen

The poll screen will contain the details of the poll. I created a polls subfolder inside the pages folder. And added [slug].js file:

import { useEffect, useState } from "react";
import Head from "next/head";
import { useRouter } from "next/router";
import { Card, Col, Container, Row, Button } from "react-bootstrap";
import { useDispatch, useSelector } from "react-redux";
import { FaInfoCircle } from "react-icons/fa";

import Loader from "../../components/Loader";
import Message from "../../components/Message";
import {
  endPollAction,
  getSinglePollAction,
  publishPollAction,
} from "../../redux/actions/vote.actions";

import VoteInPoll from "../../components/VoteInPoll";
import EditPoll from "../../components/EditPoll";

const Slug = () => {
  const dispatch = useDispatch();
  const router = useRouter();

  const [userInfo, setUserInfo] = useState({});
  const [title, setTitle] = useState("");
  const [slug, setSlug] = useState("");
  const [creator, setCreator] = useState("");
  const [createdAt, setCreatedAt] = useState("");
  const [expiration, setExpiration] = useState("");
  const [endVoting, setEndVoting] = useState(null);
  const [allowVpn, setAllowVpn] = useState(null);
  const [draft, setDraft] = useState(null);
  const [description, setDescription] = useState(null);
  const [parties, setParties] = useState(null);
  const [targetLocations, setTargetLocations] = useState(null);

  const {
    loading: loadingSingle,
    success: successSingle,
    pollInfo: pollInfoSingle,
    error: errorSingle,
  } = useSelector((store) => store.pollGetSingle);

  const { userInfo: userInfoReducer } = useSelector((state) => state.loginUser);

  const {
    loading: loadingPublishPoll,
    success: successPublishPoll,
    pollInfo: pollInfoPublishPoll,
    error: errorPublishPoll,
  } = useSelector((store) => store.pollPublish);

  const {
    loading: loadingEndPoll,
    success: successEndPoll,
    pollInfo: pollInfoEndPoll,
    error: errorEndPoll,
  } = useSelector((store) => store.pollEnd);

  const { success: successAddPollParty } = useSelector(
    (store) => store.pollPartyAdd
  );
  const { success: successAddPollLocation } = useSelector(
    (store) => store.pollLocationAdd
  );

  useEffect(() => {
    if (successSingle) {
      const {
        title,
        creator,
        createdAt,
        expiration,
        endVoting,
        allowVpn,
        draft,
        description,
        parties,
        targetLocations,
        slug,
      } = pollInfoSingle?.data;

      setAllowVpn(allowVpn);
      setCreatedAt(createdAt);
      setCreator(creator);
      setDescription(description);
      setDraft(draft);
      setEndVoting(endVoting);
      setExpiration(expiration);
      setParties(parties);
      setTargetLocations(targetLocations);
      setTitle(title);
      setSlug(slug);
    }
  }, [successSingle]);

  useEffect(() => {
    const slug = window.location.href.split("polls/")[1];
    dispatch(getSinglePollAction(slug));
  }, [
    successPublishPoll,
    successEndPoll,
    successAddPollParty,
    successAddPollLocation,
  ]);

  useEffect(() => {
    if (userInfo) {
      setUserInfo(userInfoReducer);
    }
  }, []);

  const pollPublishHandler = () => {
    if (confirm("Do you really want to publish this poll?")) {
      dispatch(publishPollAction(slug));
    }
  };
  const pollEndHandler = () => {
    if (confirm("Do you really want to end this poll?")) {
      dispatch(endPollAction(slug));
    }
  };

  return (
    <div className="pb-5 pt-3">
      <Head>
        <title>{title}</title>
        <meta name="description" content="" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Container>
        {userInfo?.username === creator?.username && <EditPoll />}
        {loadingSingle && <Loader color="black" />}

        {errorSingle && <Message variant="danger">{errorSingle}</Message>}
        {successSingle && (
          <Card>
            <Card.Header>
              <Card.Title>{title}</Card.Title>
              <Card.Text>
                Creator: {creator?.username} | Published:{" "}
                {new Date(createdAt).toLocaleDateString()} | expires:{" "}
                {new Date(expiration).toLocaleString()} | Status:{" "}
                {endVoting ? "Closed" : "Active"} | Vpn:{" "}
                {allowVpn ? "Allowed" : "Not allowed"}
              </Card.Text>
              {loadingPublishPoll && <Loader color="black" />}
              {errorPublishPoll && (
                <Message variant="danger">{errorPublishPoll}</Message>
              )}
              {successPublishPoll && (
                <Message variant="success">
                  {pollInfoPublishPoll?.message}
                </Message>
              )}
              <Card.Text>
                {parties?.length < 2 && (
                  <Message variant="info">
                    <FaInfoCircle size={30} /> Please Add atleast two poll
                    parties in order to publish poll
                  </Message>
                )}
                {parties?.length >= 2 &&
                  userInfo?.username === creator?.username &&
                  draft && (
                    <Button
                      size="sm"
                      variant="success"
                      onClick={pollPublishHandler}
                      className="mx-3"
                    >
                      Publish
                    </Button>
                  )}
                {loadingEndPoll && <Loader color="black" />}
                {errorEndPoll && (
                  <Message variant="danger">{errorEndPoll}</Message>
                )}
                {successEndPoll && (
                  <Message variant="success">
                    {pollInfoEndPoll?.message}
                  </Message>
                )}
                {parties?.length >= 2 &&
                  userInfo?.username === creator?.username &&
                  !endVoting && (
                    <Button size="sm" variant="danger" onClick={pollEndHandler}>
                      End poll
                    </Button>
                  )}
              </Card.Text>
              <Card.Text>
                Poll Link:{" "}
                <b>
                  {" "}
                  {!draft &&
                    typeof window !== "undefined" &&
                    window.location.href}
                </b>
              </Card.Text>
            </Card.Header>
            <Card.Body>
              <Card.Text>{description}</Card.Text>
              <>
                <Row>
                  {parties?.map((party, index) => {
                    return (
                      <Col xs={12} sm={6} md={4} key={index}>
                        <VoteInPoll
                          party={party}
                          pollInfoSingle={pollInfoSingle}
                          index={index}
                        />
                      </Col>
                    );
                  })}
                </Row>
              </>
            </Card.Body>
            <Card.Footer>
              <Card.Text>
                Allowed Locations:
                {targetLocations?.length > 0 &&
                  targetLocations?.map((location, index) => (
                    <span
                      className="mx-1"
                      style={{ textTransform: "capitalize" }}
                      key={index}
                    >
                      {location?.location},
                    </span>
                  ))}
              </Card.Text>
            </Card.Footer>
          </Card>
        )}
      </Container>
    </div>
  );
};

export default Slug;

SetUp CreatePoll

The CreatePoll.jsx component will eventually stay in the dashboard.js screen and will allow logged in uses to create polls.

import React, { useState, useEffect } from "react";
import { Form, Button } from "react-bootstrap";
import { useDispatch, useSelector } from "react-redux";
import { useRouter } from "next/router";
import Link from "next/link";
import { createPollAction } from "../redux/actions/vote.actions";
import Loader from "./Loader";
import Message from "./Message";
import { CREATE_POLL_RESET } from "../redux/constants/vote.constants";

const CreatePoll = () => {
  const dispatch = useDispatch();
  const router = useRouter();

  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [expiration, setExpiration] = useState("");
  const [allowVpn, setAllowVpn] = useState(true);

  const {
    loading: loadingCreatePoll,
    success: successCreatePoll,
    pollInfo: voteInfoCreatePoll,
    error: errorCreatePoll,
  } = useSelector((store) => store.pollCreate);

  const createFormHandler = (e) => {
    e.preventDefault();
    dispatch(createPollAction(title, description, expiration, allowVpn));
  };

  useEffect(() => {
    let timeOut;
    if (successCreatePoll) {
      setTitle("");
      setDescription("");
      setAllowVpn(true);
      setExpiration("");

      timeOut = setTimeout(() => {
        dispatch({ type: CREATE_POLL_RESET });
      }, 5000);
    }

    return () => clearTimeout(timeOut);
  }, [successCreatePoll]);

  return (
    <Form
      className="border p-3"
      style={{ backgroundColor: "#eee" }}
      onSubmit={createFormHandler}
    >
      {loadingCreatePoll && <Loader color="black" />}

      {errorCreatePoll && <Message variant="danger">{errorCreatePoll}</Message>}
      {successCreatePoll && (
        <p>
          <Link href={`/dashboard/#${voteInfoCreatePoll?.data?.slug}`} passHref>
            <a className="decorate">Click here</a>
          </Link>{" "}
          to publish your poll
        </p>
      )}
      {successCreatePoll && (
        <Message variant="success">{voteInfoCreatePoll?.message}</Message>
      )}
      <h2 className="py-2">Create Poll</h2>
      <Form.Group controlId="formBasicTitle" className="my-3">
        <Form.Label>Title</Form.Label>
        <Form.Control
          type="text"
          placeholder="Enter Title"
          onChange={(e) => setTitle(e.target.value)}
          required
          value={title}
        />
      </Form.Group>
      <Form.Group controlId="formBasicDescription" className="my-3">
        <Form.Label>Description</Form.Label>
        <Form.Control
          type="text"
          as="textarea"
          placeholder="Enter Description"
          onChange={(e) => setDescription(e.target.value)}
          required
          value={description}
          style={{ maxHeight: "25rem" }}
        />
      </Form.Group>
      <Form.Group controlId="formBasicExpiration" className="my-3">
        <Form.Label>Expiration</Form.Label>
        <Form.Control
          type="date"
          placeholder="Enter Expiration"
          onChange={(e) => setExpiration(e.target.value)}
          required
          value={expiration}
        />
      </Form.Group>
      <Form.Group controlId="formBasicAllowVpn" className="my-3">
        <Form.Check
          type="radio"
          required
          label="Allow"
          onChange={() => setAllowVpn(true)}
          checked={allowVpn}
        />
        <Form.Check
          type="radio"
          required
          label="Not Allowed"
          onChange={() => setAllowVpn(false)}
          checked={!allowVpn}
        />
      </Form.Group>
      <Button size="lg" type="submit" className="my-3">
        Publish
      </Button>
    </Form>
  );
};

export default CreatePoll;

Setup AssociatedPolls

The AssociatedPolls.jsx component in the components folder is used to show the polls created by a particular user.

import Link from "next/link";
import React, { useEffect, useState } from "react";
import { Button, Table } from "react-bootstrap";
import { useDispatch, useSelector } from "react-redux";
import { getAllAssociatedPollsAction } from "../redux/actions/vote.actions";
import Loader from "./Loader";
import Message from "./Message";

const AssociatedPolls = () => {
  const dispatch = useDispatch();
  const { userInfo } = useSelector((state) => state.loginUser);

  const {
    loading: loadingAssociatedPoll,
    success: successAssociatedPoll,
    pollInfo: voteInfoAssociatedPoll,
    error: errorAssociatedPoll,
  } = useSelector((store) => store.pollsGetAllAssociated);

  useEffect(() => {
    if (userInfo) {
      dispatch(getAllAssociatedPollsAction(userInfo?.username));
    }
  }, [userInfo]);

  return (
    <section>
      <h2>Your Polls</h2>
      {loadingAssociatedPoll && <Loader color="black" />}
      {voteInfoAssociatedPoll?.data.length > 0 ? (
        <Table striped bordered responsive>
          <thead>
            <tr>
              <th>Title</th>
              <th>Action</th>
            </tr>
          </thead>
          <tbody>
            {voteInfoAssociatedPoll?.data.map((poll) => {
              return (
                <tr key={poll?._id}>
                  <td>{poll?.title}</td>
                  <td>
                    <Link href={`/polls/${poll?.slug}`} passHref>
                      <a className="btn btn-secondary">Edit to publish</a>
                    </Link>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </Table>
      ) : (
        <>
          {errorAssociatedPoll && (
            <Message variant="danger">{errorAssociatedPoll}</Message>
          )}
        </>
      )}
    </section>
  );
};

export default AssociatedPolls;

Setup dashboard page

The dashboard.js file in the pages folder is used to hold the Associated.jsx and CreatePoll.jsx components.

import Head from "next/head";
import React, { useState } from "react";
import { Button, Container } from "react-bootstrap";
import CreatePoll from "../components/CreatePoll";
import { FaTimes } from "react-icons/fa";
import AssociatedPolls from "../components/AssociatedPolls";

const Dashboard = () => {
  const [create, setCreate] = useState(false);

  return (
    <div className="pb-5 pt-3">
      <Head>
        <title>Clear Voter | Dashboard </title>
        <meta name="description" content="The transparent poling platform" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Container>
        <header>
          <h1
            className="text-center py-4"
            style={{ textDecoration: "overline" }}
          >
            Dashboard
          </h1>
        </header>
        <main>
          {create ? (
            <Container className="mt-5" style={{ maxWidth: "30rem" }}>
              <FaTimes
                color="red"
                size={30}
                title="close form"
                onClick={() => setCreate(false)}
                style={{
                  float: "right",
                  cursor: "pointer",
                  margin: "0.5rem 0.3rem",
                }}
              />
              <CreatePoll />
            </Container>
          ) : (
            <Button size="lg" variant="primary" onClick={() => setCreate(true)}>
              Create Poll
            </Button>
          )}

          <Container className="py-5">
            <AssociatedPolls />
          </Container>
        </main>
      </Container>
    </div>
  );
};

export default Dashboard;

Modify styles

In the global.css file in the styles folder, I added the following codes:

html,
body {
  padding: 0;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
    Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}

a {
  color: inherit;
  text-decoration: none;
}

a.decorate {
  text-decoration: underline;
  color: blue;
}

.sr-only {
  opacity: 0;
}

* {
  box-sizing: border-box;
}

Setup About page

I added the following code to the about.js file I created in the pages folder.

import Head from "next/head";
import React from "react";
import { Container } from "react-bootstrap";

const About = () => {
  return (
    <div className="pb-5 pt-3">
      <Head>
        <title>Clear Voter | About </title>
        <meta name="description" content="The transparent poling platform" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Container>
        <header>
          <h1
            className="text-center py-4"
            style={{ textDecoration: "overline" }}
          >
            About Us
          </h1>
        </header>
        <main>
          <p>
            ClearVoter is an open-source electronic voting (e-voting) platform
            that allows individuals or brands create polls in order to get the
            choice, opinion, or will on a question; from a target audience or a
            group of persons.
          </p>
          <p>
            ClearVoter allows users or poll creators restrict the votes to a
            particular location or region.
          </p>
          <p>
            ClearVoters also allows users or poll creators decide the
            requirements for voting e.g valid emails, phone number or IP
            addresses.
          </p>
          <p>
            Clear Voter uses a number of APIs to detect if the voter's email,
            phone number or IP address is valid.
          </p>
          <p>
            Clear voter was created by{" "}
            <a
              href="https://bonarhyme.com"
              target="_blank"
              rel="noopener"
              className="decorate"
            >
              <strong>Onuorah Bonaventure Chukwudi</strong>
            </a>{" "}
            and contributions are welcome. To contribute visit{" "}
            <a
              href="https://github.com/bonarhyme/clearvoter-server"
              target="_blank"
              rel="noopener"
              className="decorate"
            >
              server source code
            </a>{" "}
            and{" "}
            <a
              href="https://github.com/bonarhyme/clearvoter-web-client"
              target="_blank"
              rel="noopener"
              className="decorate"
            >
              web client source code
            </a>
          </p>
        </main>
      </Container>
    </div>
  );
};

export default About;

Deploy Web client

I pushed the web client app to github and deployed it to vercel.

Conclusion

This are the steps I took to build the open source projects and I must say it is really a long one. I really hope you learn one or two things from it.

Please if you want to contribut visit the github repositories and make your pull requests.

I must also thank #Hashnode and #Linode for this opportunity and hope that I really win some thing and also impact knowledge.

Server live link: clearvoter.xyz

Web Client live link: https://clearvoter-web-client.vercel.app/

Server source code: ClearVoter on github

Web client source code: Web client on github

Note: You can use the server url in your own app.