This project was in teams of 4, and was our first full-stack app. We used React in the front end and MongoDB and Express in the back end. We made a trivia game using an external API called OpenTriviaDB with questions and answers, and stored user information, scores and comments in our own database.
You can launch the site on Heroku here.
- Work in a team, using git to code collaboratively.
- Build a full-stack application by making the backend and the front-end
- Use an Express API to serve data from a Mongo database
- Consume the API with a separate front-end built with React
- Be a complete product which most likely means multiple relationships and CRUD functionality for at least a couple of models
- HTML5
- CSS3
- JavaScript (ES6)
- React.js
- Express
- Mongo and Mongoose
- Git and GitHub
- Google Fonts
- SASS
- Heroku
We decided to use both an external API and our own data in the backend.
It was important for us to choose an API with a structure that we could understand well.
We deceided to use a Open Source REST API to fetch our questions and answers for the quiz.
In the backed we are constructing the controllers and the models as a part of our MVC.
- Models
We had two models that followed a Mongoose Schema class.
Comment Model
const schema = new mongoose.Schema({
comment: { type: String, required: true },
user: { type: mongoose.Schema.ObjectId, ref: 'User', require: true }
})
User Model
const scoreSchema = new mongoose.Schema({
right: { type: Number, default: 0 },
wrong: { type: Number, default: 0 }
})
const schema = new mongoose.Schema({
username: { type: String, unique: true, required: true },
email: { type: String, unique: true, required: true },
password: { type: String, required: true },
score: scoreSchema
},
{
toJSON: {
transform(doc, json) {
return {
email: json.email,
username: json.username,
id: json._id,
score: json.score
}
}
}
})
schema.plugin(require('mongoose-unique-validator'))
schema
.virtual('passwordConfirmation')
.set(function setPasswordConfirmation(passwordConfirmation) {
this._passwordConfirmation = passwordConfirmation
})
schema
.pre('validate', function checkPassword(next) {
if (this.isModified('password') && this._passwordConfirmation !== this.password) {
this.invalidate('passwordConfirmation', 'should match')
}
next()
})
schema
.pre('save', function hashPassword(next) {
if (this.isModified('password')) {
this.password = bcrypt.hashSync(this.password, bcrypt.genSaltSync())
}
next()
})
schema.methods.validatePassword = function validatePassword(password) {
return bcrypt.compareSync(password, this.password)
}
The Endpoints
User:
/register
From the Register component we are posting to the register endpoint.
function register(req, res, next) {
console.log(req.body)
User
.create(req.body)
.then(user => res.status(200).send(user))
.catch(next)
}
/login
From the Login component we are posting to the login endpoint.
function login(req, res) {
User
.findOne({ email: req.body.email })
.then(user => {
if (!user.validatePassword(req.body.password)){
return res.status(401).send({ message: 'Unauthorized' })
}
const token = jwt.sign({ sub: user._id }, secret, { expiresIn: '48h' } )
res.status(202).send({ message: `Welcome back ${user.username}`, token })
})
}
/display-score
From the DisplayScore component we are putting to the addToScore endpoint.
function addToScore(req, res) {
const currentUser = req.currentUser.id
User
.findById(currentUser)
.then(user => {
const userRight = user.score.right + req.body.score.right
const userWrong = user.score.wrong + req.body.score.wrong
req.body.score.right = userRight
req.body.score.wrong = userWrong
return user.set(req.body)
})
.then(user => {
return user.save()
})
.then(user => {
res.status(202).send(user)
})
}
/profile
From the Profile component we are getting from the getUserInfo endpoint.
function getUserInfo(req, res) {
const id = req.params.id
User
.findById(id)
.then(userInfo => {
res.send(userInfo)
})
}
/leader-board
From the LeaderBoard component we are getting from the index endpoint.
function index (req, res) {
User
.find()
.then(users => {
res.send(users)
})
}
Comment:
Comment
From the Comments component we are getting all comments, posting and deleting, from their respective endpoints.
function allComments (req, res) {
Comment
.find()
.populate('user')
.then(post => {
res.send(post)
})
}
function commentCreate(req, res){
req.body.user = req.currentUser
Comment
.create(req.body)
.then(post => {
res.status(201).send(post)
})
}
function commentDelete(req, res) {
Comment
.findById(req.params.commentId)
.then(post => {
if (!post) return res.status(401).send({ message: 'Unauthorized' })
return post.remove()
})
.then(() => res.status(200).json({ message: 'comment deleted' }))
.catch(err => console.log(err))
}
SECURE ROUTE
A number of the API Endpoints need to pass through a secure route to ensure that the user is authorised. An example of how this looks in the router is below:
router.route('/user/:id')
.get(userController.getUserInfo)
.put(secureRoute, userController.addToScore)
You need to be authorized to put (edit) the user info but not to get.
We need a JSON Web Token for this. When a user logs in, they are assigned a token:
const token = jwt.sign({ sub: user._id }, secret, { expiresIn: '48h' } )
res.status(202).send({ message: `Welcome back ${user.username}`, token })
When the token is received by our front-end, it is saved to local storage:
function setToken (token) {
localStorage.setItem('token', token)
}
And we get the token and include it in the header of any of our requests to the API:
function getToken() {
return localStorage.getItem('token')
}
Below is how the SecureRoute is setup:
function secureRoute(req, res, next) {
const authToken = req.headers.authorization
if (!authToken || !authToken.startsWith('Bearer')) {
return res.status(401).send({ message: 'Unauthorized' })
}
const token = authToken.replace('Bearer ', '')
jwt.verify(token, secret, (err, payload) => {
if (err) return res.status(401).send({ message: 'Unauthorized' })
User
.findById(payload.sub)
.then(user => {
if (!user) return res.status(401).send({ message: 'Unauthorized' })
req.currentUser = user
next()
})
.catch(() => res.status(401).send({ message: 'Unauthorized' }))
})
}
We decided to design our app with mobile-first view when bulding the game. It was built using React and has 11 components.
COMPONENTS
Register.js
andLogin.js
The information entered by the user in the registration and login forms is set as state and then posted to our backed endpoints through /api/register
and /api/login
.
Register.js
class Register extends React.Component {
constructor() {
super()
this.state = {
data: {
email: '',
username: '',
password: '',
passwordConfirmation: '',
score: {
right: 0,
wrong: 0
}
},
errors: {}
}
}
handleChange(event) {
const { name, value } = event.target
const data = { ...this.state.data, [name]: value }
this.setState({ data })
}
handleSubmit(event) {
event.preventDefault()
axios.post('/api/register',
this.state.data)
.then(() => this.props.history.push('/login'))
}
Login.js
class Login extends React.Component {
constructor() {
super()
this.state = {
data: {
email: '',
password: ''
}
}
}
handleChange(event) {
const { name, value } = event.target
const data = { ...this.state.data, [name]: value }
this.setState({ data })
}
handleSubmit(event) {
event.preventDefault()
axios.post('/api/login',
this.state.data)
.then(res => {
const token = res.data.token
auth.setToken(token)
this.props.history.push('/quizzes')
})
}
MultipleChoice.js
andTrueOrFalse.js
We are fetching an array of incorrect answers and we are inserting the correct answer at a random index in that array.
So now we have an array of answers and we can just render them and the correct answer will always be at random position.
When a player clicks on an answer, the function handlePlayerClick()
will check if the innerHTML
of the selected answer matches the innerHTML
of the correct answer. If it does the button will turn green. If it doesn't the button will turn red and we are using Ref to identify the button with the correct answer and change it to green.
Everytime a player clicks on an answer, we are saving their total of right and wrong answers to localStorage
. When the user has finished the quiz (answer 10 questions), we get our totals from localStorage
to display their score.
handlePlayerClick(event) {
if (event.target.innerHTML === this.state.wholeQuestion.results.map((e) => (e.correct_answer))[0]) {
event.target.style.backgroundColor = 'green'
rightAnswers++
localStorage.setItem('right', rightAnswers)
totalAnswered = rightAnswers + wrongAnswers
} else {
event.target.style.backgroundColor = 'red'
wrongAnswers++
localStorage.setItem('wrong', wrongAnswers)
totalAnswered = rightAnswers + wrongAnswers
if (this.AnswerA.innerHTML === this.state.wholeQuestion.results.map((e) => (e.correct_answer))[0]) {
this.AnswerA.style.backgroundColor = 'green'
} else if (this.AnswerB.innerHTML === this.state.wholeQuestion.results.map((e) => (e.correct_answer))[0]) {
this.AnswerB.style.backgroundColor = 'green'
} else if (this.AnswerC.innerHTML === this.state.wholeQuestion.results.map((e) => (e.correct_answer))[0]) {
this.AnswerC.style.backgroundColor = 'green'
} else if (this.AnswerD.innerHTML === this.state.wholeQuestion.results.map((e) => (e.correct_answer))[0]) {
this.AnswerD.style.backgroundColor = 'green'
}
}
if (totalAnswered === 10) {
setTimeout(() => {
this.props.history.push('/display-score')
rightAnswers = 0
wrongAnswers = 0
totalAnswered = 0
}, 400)
} else {
axios.get('https://opentdb.com/api.php?amount=1&type=multiple')
.then(res => this.setState({ wholeQuestion: res.data }))
setTimeout(() => {
this.AnswerA.style.backgroundColor = 'buttonface'
this.AnswerB.style.backgroundColor = 'buttonface'
this.AnswerC.style.backgroundColor = 'buttonface'
this.AnswerD.style.backgroundColor = 'buttonface'
}, 400)
}
}
DisplayScore.js
After we get the player's score from localStorage
, we use a put method to add it to our user information in the backend.
class DisplayScore extends React.Component {
constructor() {
super()
this.state = {
score: {
right: parseInt(localStorage.getItem('right')),
wrong: parseInt(localStorage.getItem('wrong'))
}
}
}
componentDidMount() {
const id = auth.getUserId()
axios.put(`/api/user/${id}`,
this.state,
{ headers: { Authorization: `Bearer ${auth.getToken()}` } })
}
Comments.js
andNewComment.js
In the NewComment.js
component users write their comments in a form and then, through the handleSubmit()
function, we are posting it to our backend endpoint /api/comments
.
handleSubmit(event) {
event.preventDefault()
axios.post('/api/comments',
this.state,
{ headers: { Authorization: `Bearer ${auth.getToken()}` } })
.then(() => location.reload())
}
render() {
return <div>
<form
className='formForComments'
onSubmit={this.handleSubmit(event)}>
<textarea
className='inputComment'
onChange={this.handleChange(event)}
type="text"
>
</textarea>
<button className='submitButton'>Comment ⌨️</button>
</form>
</div>
}
In the Comment.js
component we are getting the information from our previous post, and we are adding a handleDelete()
function, which is allowing logged-in users to delete their own comments but not other users'. We check if a comment belongs to the current user with the Authorization method.
componentDidMount() {
axios.get('/api/comments')
.then(res => {
this.setState({ comments: res.data.reverse() })
})
}
handeleDelete(comment) {
const id = comment._id
axios.delete(`/api/comments/${id}`,
{ headers: { Authorization: `Bearer ${auth.getToken()}` } })
.then(() => location.reload())
}
isOwner(comment) {
return auth.getUserId() === comment.user.id
}
-
One of our main challenges was figuring out how to associate a user with their scores. We had to try a few different model structures before we finally decided to have a user model and keep the scores there.
-
Some of the game logic in the frontend was definitely mindbending. Especially displaying the right answer when the player clicks on the wrong answer was tricky. But it was interesting to learn about Refs.
-
The API we were fetching our questions from had special coded characters that were not displaying correctly in our app. We tried many ways to fix this issue but nothing was working. We ended up having to replace each character manually which was long and messy.
-
Since this was our first full-stack application, working on every part of the project from idea planning to deployment was very rewarding. Seeing that we were able to create an entire application with frontend and backend was a great satisfaction.
-
This was also our first time working as a group with Git. Learning about how to avoid conflicts and put everyone’s work together was demanding at the beginning, but proved to be very useful for group collaboration.
-
Overcoming the limits of working remotely from home.
-
Maybe a timer to answer a question in a limited time.
-
We were thinking about displaying the ranking in percentages instead of correct answers, to make it more independent from the number of games. Since that didn't satisfy us very much, we were thinking about maybe having a new point system.