Table of contents
- Introduction
- Project structure and technologies
- Setup server directory
- Database schema and model
- Form Validation
- Response and token handlers
- Controllers
- Middlewares
- Routes
- Web Client Structure and technologies
- Modify styles
- Setup About page
- Deploy Web client
- Conclusion
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 © 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'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.