Learn to Make an Endorsement PWA App: Beginner's Guide
This is part of a solo project from The Frontend Developer Career Path at Scrimba. I have enjoyed learning with them and would like to share what I have learnt with you. I hope you enjoy coding with me. Let's go!
An endorsement is a powerful way to acknowledge the people that you work with. This allows people to spread positivity in the work place.
A progressive web app (PWA) is a type of application software delivered through the web, built using common web technologies such as HTML, CSS and JavaScript. PWAs are intended to work on any platform that uses a standards-compliant browser, in both desktops and mobile devices.
In this post, we will go step by step through the creation of the application, starting with the HTML, then CSS for styling, JavaScript for interactivity and Firebase for data storage.
The HTML Structure
Let's start with the skeleton of the application. We will use HTML file named index.html
.
<!doctype html>
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Josefin+Sans:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="index.css">
<title>We are the Champions</title>
</head>
<body>
<div class="container">
<img src="./assets/freddie.png" alt="Freddie">
<h1>We are the Champions</h1>
<textarea id="endorsement-text" rows="10" cols="30" placeholder="Write your endorsement here"></textarea>
<button id="endorse-btn">Publish</button>
<h2>- Endorsements -</h2>
<ul id="endorsement-list">
<li>Hi Bob! Your React Router course is so good. :) π₯ Alice</li>
<li>Oscar! That transcription feature you completed for Scrimba 3.0 is amazing. πππ Really good work π From Henry</li>
<li>Marianne! Thank you so much for helping me with the March accounting. Saved so much time because of you! π Freddy</li>
</ul>
</div>
<script src="index.js"></script>
</body>
</html>
We have the absolute skeleton of the application. The entire content of the body will be housed within a div with class container
. This will later be useful in organizing the HTML elements. Within the unordered list with id endorsements
, we have three list elements as placeholders. We will later remove these and have them displayed dynamically.
Next, Styling
To begin with, let's import the necessary fonts in the head section of index.html
.
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Josefin+Sans:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="index.css">
<title>We are the Champions</title>
</head>
Then in index.css
let's add some styling:
:root {
--dark-color: #1B1924;
--gray-color: #444059;
--white-color: #FFFFFF;
--gray-text-color: #8F8F8F;
--blue-color: #28A9F1;
--button-text-color: #04131C;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--dark-color);
color: var(--white-color);
font-family: "Josefin Sans", sans-serif;;
}
.container {
max-width: 390px;
margin: 30px auto;
padding: 0 15px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
}
.container img {
width: 90px;
margin-top: 50px;
}
.container h1 {
font-size: 1.5rem;
}
.container textarea {
width: 320px;
height: 120px;
padding: 15px;
background-color: var(--gray-color);
color: var(--gray-text-color);
font-family: "Inter", sans-serif;
font-size: 1rem;
border: none;
border-radius: 8px;
}
.container button {
width: 320px;
height: 60px;
background-color: var(--blue-color);
color: var(--button-text-color);
font-family: "Josefin Sans", sans-serif;
font-weight: bold;
font-size: 1.5rem;
border: none;
border-radius: 8px;
cursor: pointer;
}
.container ul {
list-style: none;
width: 320px;
}
.container ul li {
font-size: 1.1rem;
padding: 15px;
color: var(--dark-color);
background-color: var(--white-color);
}
.container ul li:not(:last-child) {
margin-bottom: 15px;
}
.container ul .empty-list {
background-color: var(--gray-color);
color: var(--gray-text-color);
text-align: center;
font-weight: bold;
}
We are using CSS custom property variables to define the colors for the application. For the list items, we are applying 15 pixels of margin at the bottom, except for the last list item.
Let's see how the page looks with these additions:
Very good!
Adding functionality with JavaScript
In index.js
file, let's get the DOM elements we need to interact with:
// Get DOM elements
const endoresementInputEl = document.getElementById("endorsement-text");
const endorseBtn = document.getElementById("endorse-btn");
const endorsementListEl = document.getElementById("endorsement-list");
Next, let's add an event listener for clicks on the button. We will use this to get the text in the input
and update the endorsementListEl
element with the new text.
// Add event listener to the button
endorseBtn.addEventListener("click", function() {
const endorsement = endoresementInputEl.value;
const li = document.createElement("li");
li.textContent = endorsement;
endorsementListEl.appendChild(li);
});
Let's test and see the output:
Awesome work! You can see the text is appended to the end of the list. But wait a minute! We have a problem. A few actually. Have you noticed them?
When we click on the button, the input is not cleared. We are forced to manually clear it to enter new text.
When the button is pressed and the input is empty,
endorsementListEl
is updated with an empty list item. It should only be updated when there is text in the input.When the browser updates, we lose the new endorsements.
Alright, let's deal with the first two. To clear the input on submission, we can simply pass an empty value to endoresementInputEl
once the endorsement is submitted.
endoresementInputEl.value = "";
To prevent empty submissions, we can use an if
statement to check if there is an endorsement value. If so, then we add the list element to endorsementListEl
.
if (endorsement) {
const li = document.createElement("li");
li.textContent = endorsement;
endorsementListEl.appendChild(li);
}
So now the list will only be updated if the user enters some text in the input. The function is now updated:
// Get DOM elements
const endoresementInputEl = document.getElementById("endorsement-text");
const endorseBtn = document.getElementById("endorse-btn");
const endorsementListEl = document.getElementById("endorsement-list");
// Add event listener to the button
endorseBtn.addEventListener("click", function() {
const endorsement = endoresementInputEl.value;
if (endorsement) {
const li = document.createElement("li");
li.textContent = endorsement;
endorsementListEl.appendChild(li);
endoresementInputEl.value = "";
}
});
Before we move on, we need to tidy up the addEventListener
callback function. It would be better to break it up into smaller, manageable functions that handle specific tasks. In this case, we would ideally have two functions to clear the input and to update the endorsement list. Let's do that.
// Get DOM elements
const endoresementInputEl = document.getElementById("endorsement-text");
const endorseBtn = document.getElementById("endorse-btn");
const endorsementListEl = document.getElementById("endorsement-list");
// Add event listener to the button
endorseBtn.addEventListener("click", function() {
const endorsement = endoresementInputEl.value;
if (endorsement) {
updateEndorsementListEl(endorsement);
clearEndoresementInputEl();
}
});
/**
* Function to clear the input field
* @returns {void}
*/
const clearEndoresementInputEl = () => {
endoresementInputEl.value = "";
}
/**
* @param {string} endorsement
* @returns {void}
*/
const updateEndorsementListEl = (endorsement) => {
const listEl = document.createElement("li");
listEl.textContent = endorsement;
endorsementListEl.appendChild(listEl);
}
Awesome! Now let's talk about the third problem: persisting our data. For this we will use Firebase, an application development platform provided by Google.
Firebase setup
Head over to Firebase console and sign in using your Google account. Click on Add project
to create a new project.
Let's name the project Champions
, although you can choose any name you prefer.
On the next page, disable Google analytics and click on Create project
.
After a few minutes, your Firebase project is ready. Click continue.
On the left side of the page, under the Build
tab, select Realtime Database
. We are going to create a Realtime database to store our data.
On the next page, click the Create Database
button.
Next, on the pop-up prompt, select Realtime database location, preferably one closest to you and click Next
.
For the security rules, select Start in Test mode
. We will change these settings later to prevent the public from posting to it. Click Enable
.
Your Realtime database is now up and ready. Your page should look something like this:
You will have noticed the warning message in the Security rules setup that anyone with your database reference to view, delete and modify all data in your database from the time of creation up to 30 days. For now, let's set read and write modes to true
. You can later change the write mode to false
so others cannot write to your database. Click on the Rules tab. Set the read and write rules to true. Then click Publish:
We will be using the database reference URL to connect to the database from our environment. Go back to the Data tab and copy the database reference URL.
Connecting to Firebase
Back to our local environment, we need to now setup some configurations so that our local application can post and fetch data into Firebase as needed. At the top of index.js
, add these lines:
import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js";
import { getDatabase, ref } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-database.js";
const appSettings = {
databaseURL: "https://champions-e8ee8-default-rtdb.europe-west1.firebasedatabase.app/"
};
const app = initializeApp(appSettings);
const database = getDatabase(app);
const endorsementListInDB = ref(database, "endorsements");
First, we import a Firebase function called initializeApp
from a Firebase JavaScript file hosted at Firebase. Next, we import getDatabase
and ref
from a Firebase database script.
After the imports, we have a variable appSettings
that is an object used to set up databaseURL
containing the database reference URL we copied earlier from Firebase console.
Next, we have app
that uses initializeApp
with appSettings
defined earlier to connect to the database.
The getDatabase
function is then used to allow communication with our database. We pass in app
as an argument.
Finally, we create a reference for where in the database we will be storing our data. We imported ref
for this purpose. ref
takes two arguments: first, the database we are working with (in this case database
that we created earlier, and second should be what the reference should be called (in this case "endorsements").
Now, because we are importing functions from another file and using it in our JavaScript file, the app won't work as expected. To test this, console.log
the value of app
created earlier:
...
const app = initializeApp(appSettings);
const database = getDatabase(app);
const endorsementListInDB = ref(database, "endorsements");
console.log(app);
On the browser, inspect the page and open the console tab. You will see an error message saying we cannot use an import statement outside a module.
To mitigate this, we need to tell index.html
to import our JavaScript file as a module. So let's do that.
...
<script src="index.js" type="module"></script>
</body>
</html>
Save the file and refresh the page. You will notice that the error message is gone. Instead we get a Firebase implementation object.
Great! You can now remove the console.log statement.
Pushing data into our Firebase database
To push data to our Firebase database is quite simple. First, we need to import the push function from Firebase:
...
import { getDatabase, ref, push } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-database.js";
...
Then, we use this push
function to push data to Firebase database. This function takes two arguments: the reference and the value that we want to post.
if (endorsement) {
push(endorsementListInDB, endorsement);
updateEndorsementListEl(endorsement);
clearEndoresementInputEl();
}
Simple as that! Our app now posts data to the Firebase database:
You can see the reference name endorsements
. When you expand it, you will see your first endorsement with its ID. Great! Now we move to the next challenge.
Fetching our data using onValue
When the browser refreshes, the list elements appended to the unordered list (along with their data) disappear. Up to now, we have no way of displaying data within the database. This is a good time to remove the dummy data from our HTML. Let's update index.html
.
<h2>- Endorsements -</h2>
<ul id="endorsement-list"></ul>
Back in index.js
, we will import yet another function to fetch the data: onValue
:
...
import { getDatabase, ref, push, onValue } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-database.js";
...
onValue
listens for data changes at a particular location (the reference). A callback function will be triggered for the initial data and again whenever the data changes.
// Listen for changes in the database
onValue(endorsementListInDB, function(snapshot) {
const data = Object.entries(snapshot.val());
endorsementListEl.innerHTML = "";
for (let i = 0; i < data.length; i++) {
let endorsement = data[i]
updateEndorsementListEl(endorsement);
}
});
...
/**
* @param {array} endorsement
* @returns {void}
*/
const updateEndorsementListEl = (endorsement) => {
let [endorsementID, endorsementText] = endorsement;
const listEl = document.createElement("li");
listEl.id = endorsementID;
listEl.textContent = endorsementText;
endorsementListEl.appendChild(listEl);
}
onValue
receives two arguments: the reference and a callback function. The callback function receives a snapshot object of all the values in the reference at the time. Inside the callback, we are using Object.entries() to get a key-value pair of each ID and its corresponding value from the snapshot.
Back to our code, after we extract the data, we need to clear the endorsement list unordered list of any old values. Using a for loop, we get each key-value pair and pass it on to updateEndorsementListEl
function.
updateEndorsementListEl
function destructures the ID and text then creates a new list element for each endorsement. The list element is updated with the ID and value attributes. Finally the list element is appended to the unordered list.
Final touches
Now that the onValue
function calls on updateEndorsementListEl
, remove updateEndorsementListEl
from endorseBtn.addEventListener
function.
Also, we need to display a default message, in the event the database does not have any data. We use snapshot.exists()
to check if there is data present in the snapshot. Here is the updated code for index.js
:
import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js";
import { getDatabase, ref, push, onValue } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-database.js";
const appSettings = {
databaseURL: "https://champions-e8ee8-default-rtdb.europe-west1.firebasedatabase.app/"
};
const app = initializeApp(appSettings);
const database = getDatabase(app);
const endorsementListInDB = ref(database, "endorsements");
// Get DOM elements
const endoresementInputEl = document.getElementById("endorsement-text");
const endorseBtn = document.getElementById("endorse-btn");
const endorsementListEl = document.getElementById("endorsement-list");
// Add event listener to the button
endorseBtn.addEventListener("click", function() {
const endorsement = endoresementInputEl.value;
if (endorsement) {
push(endorsementListInDB, endorsement);
clearEndoresementInputEl();
}
});
// Listen for changes in the database
onValue(endorsementListInDB, function(snapshot) {
if (snapshot.exists()) {
const data = Object.entries(snapshot.val());
clearEndorsementListEl();
for (let i = 0; i < data.length; i++) {
let endorsement = data[i];
updateEndorsementListEl(endorsement);
}
} else {
endorsementListEl.innerHTML = "<li class='empty-list'>No endorsements yet</li>";
}
});
/**
* Function to clear the endorsement list
* @returns {void}
*/
function clearEndorsementListEl() {
endorsementListEl.innerHTML = "";
}
/**
* Function to clear the input field
* @returns {void}
*/
const clearEndoresementInputEl = () => {
endoresementInputEl.value = "";
}
/**
* @param {array} endorsement
* @returns {void}
*/
const updateEndorsementListEl = (endorsement) => {
let [endorsementID, endorsementText] = endorsement;
const listEl = document.createElement("li");
listEl.id = endorsementID;
listEl.textContent = endorsementText;
endorsementListEl.appendChild(listEl);
}
Now when the database is empty, we have a default message:
When new data is posted, the UI reflects automatically:
Making the app mobile friendly
To make the app display correctly on a mobile device, we need to add a meta
tag that sets the viewport of any device we are using. in index.html
, add this piece of code to the head:
<meta name="viewport" content="width=device-width, initial-scale=1.0">
This way, the app will not look shrunken on a mobile device with a smaller screen.
Adding a Favicon
A favicon is a small 16x16 pixel icon used on web browsers to represent a website. They are normally displayed on tabs at the top of browsers. Let's add on for our app. You can choose between an image, text or emoji to generate a favicon. Head over to favicon.io and select on of the three options:
Once generated, download the zip file and extract the images into your local environment. Copy the link tags provided and paste them into the head of your HTML. Make sure to update links so the href
looks into the correct location:
See example implementation:
<head>
<!-- Meta tags -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="./assets/favicon-16x16.png">
<link rel="manifest" href="./assets/site.webmanifest">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Josefin+Sans:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet">
<!-- Styles -->
<link rel="stylesheet" href="index.css">
<title>We are the Champions</title>
</head>
The favicon should show on the browser tab along with the title of the app:
You may be wondering what that site.webmanifest
file is for. This is the magic file that will be used to display the icon on your mobile phone. You can add appropriate spacing so it looks lie a JavaScript object:
{
"name":"We are the Champions",
"short_name":"We are the Champions",
"icons":[
{
"src":"/android-chrome-192x192.png",
"sizes":"192x192","type":"image/png"
},
{
"src":"/android-chrome-512x512.png",
"sizes":"512x512","type":"image/png"
}
],
"theme_color":"#1B1924",
"background_color":"#1B1924",
"display":"standalone"
}
I have changed the values "name" and "short_name" from the initial blank values.
name
is the name of the application that will be displayed to the usershort_name
is displayed if there not enough space to displayname
icons
is an array of image files that serve as application icons.theme_color
defines the default theme color for the applicationbackground_color
is a placeholder for the application page to display before its stylesheet is loaded.display
determines the preferred display mode for the website. Thestandalone
mode means the application will look and feel like a standalone application.
You can read more on MDN web docs.
Deployment
Before we can view our app on a mobile phone, we need to deploy it to the internet. There are many ways to do this, but for this demonstration, we will use Netlify.
You will need to sign up into Netlify, so choose whichever method you prefer. If you already have an account, go ahead and log in. Once logged in, click on Add new site button and then select Deploy manually. Drag and drop the entire folder containing your local files.
This should take only a few seconds. The application is now published. You can rename the application by clicking on Site configurations on the left:
Then click on Change site name button:
Save the name and access the published site. Congratulations!
Add app to home screen
For Android
Using your Chrome browser, navigate to the Netlify URL for your app. On the top right of the browser, tap the three vertical dots menu, then on the pop-up, tap on Add to Home screen.
You can choose to change the name to your preference. The app is now added to your home screen.
For iPhone
Open your default browser (Safari) and navigate to the Netlify URL you deployed to. Tap on the icon at the bottom center of the screen that looks like a box with an arrow pointing up:
Then select Add to Home Screen:
Set the display name and press Add.
Congratulations!
Next steps
Well done for getting this far! ππΎππΎππΎ
Now that you've learnt these vital concepts, I encourage you to take it further. Here are some suggestion:
Add "From" and "To" input fields. The list should show who the message is from and to whom.
Reverse the order of the list items so that the latest endorsement comes first.
Good luck! ππΎ
Let's connect: