Make a Dream Todo app with Novu, React and Express! ✅

Sumit Saurabh - Apr 19 '23 - - Dev Community

TL;DR

In this article, you'll learn how to make a todo app that works for you. The prime focus in making this app was to boost personal productivity, get things done and stay distraction free!

Novu quotes page

todo page

Moonshine in dark mode

So, fasten your seat belts and let's begin the ride. 🚀

The why? 🤔

In the information-overload age that we live in, being productive and concentrating on one task at a time is something that not a lot of people are good at. To overcome this, one of the widely accepted approaches is to have a todo list of tasks you want to accomplish.

This was my approach too, but with time I started feeling limited with the basic functionality and found myself juggling through different apps for different functionalities.

My primary use-cases were:

  1. A place to plan my week.
  2. Daily breakdown of what I wanted to achieve that day.
  3. Knowing the current time and date.
  4. Being inspired to take action and feeling accomplished when I completed a task.
  5. Sending informal sms reminders to people I've assigned a task to.
  6. Sending formal email reminders to work colleagues.
  7. The app should be available everywhere.

To achieve these, I had to juggle between multiple apps: one cross-platform todo app (which was surprisingly difficult to find), a calendar app, a quotes app, a messenger and an email app.

Needless to say, shuffling between these many apps was defeating the very purpose I was juggling these apps: maximising personal productivity in a distraction free setting.

Seeing that no one app had all the features I needed, I decided to build my own app. But there was a problem: I could build a great todo app but my problem of sending reminders to people about a task assigned to them was still not being solved.

Enter Novu!

Novu helped me address the biggest gripe I had with my workflow - picking up my phone to send a message reminding someone about a task they were supposed to do. Remember that staying distraction-free was a big thing for me and picking up my phone was like practically inviting shiny and tempting distractions.

But with Novu, I could now build a todo app from which, I could send informal sms reminders to my friends as well as formal email reminders to my work colleagues.

Novu - Open-source notification infrastructure for developers:

Novu is an open-source notification infrastructure for developers. It helps you manage all the product notifications be it an in-app notification (a bell icon like what's there in Facebook), Emails, SMSs, Discord and what not.

Novu

I'll be super happy if you check us out on GitHub and give us a star! ❤️
https://github.com/novuhq/novu

Let's begin with our app, Moonshine:

We'll make our app in two stages - backend and front-end. Both will live in separate GitHub repositories and I'll also show you how to deploy Moonshine to the web, enabling us to access it from anywhere.
Let's start with the backend.

Basic set-up:

We'll start with an empty git repo. Create an empty git repository, enter all the relevant details and publish it to GitHub. Then open it in your IDE (I'm going to use VS Code):

Our first step here would be to install all the required packages. We'll rely on several packages from npm (Node Package Manager).

To start this process, we'll generate a package.json using the following command:

npm init -y
Enter fullscreen mode Exit fullscreen mode

This command generates the package.json file and now we can install all the packages we need. Use the following command to install them all:

npm i dotenv novu bcryptjs body-parser cors express jsonwebtoken mongoose nodemon
Enter fullscreen mode Exit fullscreen mode

Now go ahead and create a .env and a .gitignore files in the root of your project directory. We'll keep all the sensitive data like our MongoDB connection URL and our Novu api key in our .env file. To stop it from getting staged by git and being pushed on to GitHub, add the .env file to the .gitignore file, as shown below:

dot env file

Connecting to a database:

After the basic set-up, we need to connect to a database to store information. We'll be using MongoDB for this as it is really easy to get started with and serves all our needs. So go ahead, create your MongoDB account and log into your MongoDB account.

We'll need to get our database connection URL from MongoDB. To get that, go to the top-left corner and create a new project from there.

Create a new project on mongoDB

Name your project then click on 'create project', and then click on 'build a database'

create project on MongoDB

After this, choose the free option, leave everything to default

default settings of a new project

Enter the username and password you want to use to authenticate to MongoDB (note the password down, we'll need it). Then scroll to the bottom and click finish and close:

Finish and close on MongoDB

Now we need to connect to our database. Select 'Connect' from left menu and choose 'network access'. Then go to 'add ip' and choose 'allow access from anywhere' as shown below:

Choose allow access from anywhere

Now, go to database again from the left menu and click on connect button

connect button on database

Here, choose 'connect your application' as shown:

connect your application

Now, copy the connection url into your .env file, just replace the with the password you saved above.

Getting our Novu API key:

Post database connection, we need our Novu api key that you can get quite easily.
Go to Novu's web platform

Novu web platform

Create your account and sign-in to it. Then go to Settings from the navigation menu on the left.

Settings in Novu

Now, in the settings, go to the second tab called 'API Keys'. there, you'll see your Novu api key. Copy it and add it to the .env file in your project's root directory:

Add novu's api key to .env file

Now, we've laid down all the groundwork and are ready to start coding-up the back-end.

Coding our back-end:

The first part of our back-end code is controller, which contains the code for functions that handle http requests from the application. We'll create two files here:

  • One for all the http methods of our todo app like getting all todos, adding new todo, deleting a todo etc.
  • Another one for the http methods involving authentication like sign-in and sign up.

The content of our auth file is given below. It contains two functions - signUp and signIn. The 'signUp' function checks if the information entered already belongs to a registered user or not and responds accordingly. If it doesn't already belong to an existing user, a new user is created in our database, which we had created above.

import jwt from "jsonwebtoken"
import bcrypt from "bcryptjs";
import user from "../models/user.js";

export const signUp= async (req,res)=>{
    const {first_name,last_name,email,password,confirm_password}=req.body;
    try {
        const existingUser= await user.findOne({email});
        if(existingUser) return res.status(400).json({message:"User already exists.."});

        if (password!==confirm_password) return res.status(400).json({message:"Password should match.."})
        const hashedPassword= await bcrypt.hash(password,12);
        const result= await user.create({email,password:hashedPassword,name: `${first_name} ${last_name}`});
        const token= jwt.sign({email:result.email,id:result._id},'secret',{expiresIn:'5h'});
        res.status(200).json({result,token});
    } catch (error) {
        res.status(500).json({message:"Something went wrong!! try again."})
    }
}

export const signIn= async (req,res)=>{
    const {email,password}=req.body;
    try {
        const existingUser= await user.findOne({email});
        if(!existingUser) return res.status(404).json({message:"User does not exist!!"})

        const isPasswordCorrect= await bcrypt.compare(password,existingUser.password);

        if(!isPasswordCorrect) return res.status(400).json({message:"Invalid password,try again!!"});

        const token= jwt.sign({email:existingUser.email,id:existingUser._id},'secret',{expiresIn:'5h'});

        res.status(200).json({result:existingUser, token});
    } catch (error) {
        res.status(500).json({message:"Something went wrong!! please try again"});
    }
}
Enter fullscreen mode Exit fullscreen mode

Similarly, the content of our todo file is given below:

import notes from "../models/note.js";
import mongoose from "mongoose";
import {
  getNotification,
  inAppNotification,
  smsNotification,
} from "../novu/novu.js";

export const getNotes = async (req, res) => {
  try {
    const allNotes = await notes.find();
    res.status(200).json(allNotes);
  } catch (error) {
    res.status(409).json({ message: error });
  }
};

export const createNote = async (req, res) => {
  const { title, description, date, message } = req.body;
  const newNote = new notes({
    title,
    description,
    date,
    creator: req.userId,
    createdAt: new Date().toISOString(),
    checked: false,
  });
  try {
    await newNote.save();
    await inAppNotification(title, description, req.userId, message);
    res.status(201).json(newNote);
  } catch (error) {
    res.status(409).json({ message: error });
  }
};

export const deleteNote = async (req, res) => {
  const { id } = req.params;
  if (!mongoose.Types.ObjectId.isValid(id))
    return res.status(404).send(`no note is available with id:${id}`);
  await notes.findByIdAndRemove(id);
  res.json({ message: "Note deleted successfully" });
};

export const updateNote = async (req, res) => {
  const { id: _id } = req.params;
  const note = req.body;
  if (!mongoose.Types.ObjectId.isValid(_id))
    return res.status(404).send(`No note is available with id:${id}`);
  const updatedNote = await notes.findByIdAndUpdate(
    _id,
    { ...note, _id },
    { new: true }
  );
  res.json(updatedNote);
};

export const sendSmsNotification = async (req, res) => {
  try {
    const { title, description, phone, noteId } = req.body;
    await smsNotification(title, description, phone, noteId);
    res.status(200).json({ message: "SMS sent successfully" });
  } catch (error) {
    console.log("sendSmsNotification error", error);
    res.status(500).json({ message: "Failed to send SMS" });
  }
};

export const sendEmailNotification = async (req, res) => {
  try {
    const { title, description, email, noteId } = req.body;
    await getNotification(title, description, email, noteId);
    res.status(200).json({ message: "Email sent successfully" });
  } catch (error) {
    console.log("sendEmailNotification error", error);
    res.status(500).json({ message: "Failed to send Email" });
  }
};

export const deleteInAppNotification = async (req, res) => {
  try {
    const { title, description, userId, message } = req.body;
    await inAppNotification(title, description, userId, message);
    res.status(200).json({ message: "Todo delted successfully" });
  } catch (error) {
    console.log("deleteInAppNotification error", error);
    res.status(500).json({ message: "Todo deleted successfully" });
  }
};

export const toggleNoteDone = async (req, res) => {
  try {
    const noteRef = await notes.findById(req.params.id);

    const note = await notes.findOneAndUpdate(
      { _id: req.params.id },
      { done: !noteRef.done }
    );

    await note.save();

    return res.status(200).json(note);
  } catch (error) {
    return res.status(500).json(error.message);
  }
};
Enter fullscreen mode Exit fullscreen mode

It contains the following primary functions and then a few more:

  • getNotes - This function retrieves all todos from our database and sends them as a response.

  • createNote - This function creates a new todo and saves it in the database. It also sends an in-app notification to the user who created the note (this happens due to the Novu magic and our app has a lovely notification centre to house all the in-app notifications).

  • deleteNote - This function deletes a todo from the database based on the provided ID (depending on which todo's delete button was clicked).

  • updateNote - This function updates a todo in the database based on the provided ID.

  • sendSmsNotification - This function sends an SMS notification to the provided phone number (again by using the magical powers of Novu).

  • sendEmailNotification - This function sends an email notification to the provided email address (by summoning Novu's magical powers).

Now, all our http methods are done and we've exported them as well. Now we need to set-up the template (called as 'Schema') of the format in which we're expecting to send the data to the database we had created earlier.

Database Schema:

MongoDB stores data in entities called 'Collections'. Think of it like this: Our database is a room and collections are the boxes in that room within which we'll store data. Here is the schema for our auth file:

import mongoose from "mongoose";

const userSchema = mongoose.Schema(
    {
        id:{type:String},
        name:{type:String,required:true},
        email:{type:String,required:true},
        password:{type:String,required:true}
    },
    {
        collection: "user"
    }
)

export default mongoose.model("User", userSchema);
Enter fullscreen mode Exit fullscreen mode

This schema defines the fields and types for the user document - id, name, email, and password.

The required property is set to true for name, email, and password fields, which means that these fields must have a value before a new user document can be created.

Similarly, here is the schema for our todo:

import mongoose from "mongoose";

const noteSchema = mongoose.Schema(
  {
    title: { type: String, required: true },
    description: { type: String },
    date: { type: Date, required: true },
    creator: { type: String, required: true },
    createdAt: {
      type: Date,
      default: new Date(),
    },
    done: { type: Boolean, default: false },
  },
  {
    collection: "note",
  }
);

export default mongoose.model("Notes", noteSchema);
Enter fullscreen mode Exit fullscreen mode

Setting up Novu trigger functions:

We'll now need to set up Novu trigger functions for each notification type we wish our app to have, which is: in-app, email and sms.

To configure this, go the the Novu web platform and go to 'notifications' from the left navigation menu, then go to workflow editor and select a channel from the right menu. Now, click on the triple-dot menu and go to 'edit the template'

editing template on novu

Now create a template for each notification - so, we'll need one template for email, one for sms and the last one for in-app notification. We'll also need to connect to providers for our notifications to work properly.

Luckily, Novu has a built-in 'Integrations Store' with tons and tons of providers for just about every channel you could think of - from email to sms to chat to push notifications.

It has it all!

Novu integration store

Goto the integrations store from the left navigation bar and connect your providers.

We'll be using 'Sendgrid' for emails and 'Twillio' for sms.
Connect to both of them using the guide provided in Novu (see the pink 'here' link in the image below)

Connect to a provider, using Novu's guide

Once connected, you can configure each channel to your liking. Here, I'm showing how to configure the sms channel but using similar steps, you can also configure other channels.

Configuring a notification channel in Novu:

To configure a channel, go the 'Notification' from the left navigation menu then click on 'New' button on the top right to create a template. Give it a name and copy the trigger code.

Novu web platform's new workflow image

Then dismiss it and go to 'Workflow Editor'. On the right, you'll see the channels you can add to the Novu canvas. Just drag 'SMS' from the right to the Novu canvas in the middle. This is where all the magic happens! ✨

Notification channels in the right hand side menu in Novu

You can add various notifications channel and customise the template to your liking and test the notification, introduce delays and more - all from one place. In fact, I'll turn this article into an essay if I start listing all the features of Novu because that's how many of them there are:

configuring sms channel in Novu

You can customise email and in-app channels using the above steps as well, just choose the relevant channel from the workflow editor.

Once you've copied code for triggering Novu for each channel, go to your project directory and create a file 'Novu.js' inside a directory Novu and paste it all there. Just make sure to update the snippet with relevant data like Novu API key and subscriber ID and the like.

The content of our Novu.js is shared below. It contains all the code snippets for various channels that we got from Novu's web platform in the step above:

import { Novu } from '@novu/node'; 
import dotenv from "dotenv";


dotenv.config();

export const getNotification = async (title,description,email,Id) => {
    const novu = new Novu(process.env.NOVU_API_KEY);

    await novu.subscribers.identify(Id, {
        email: email,
        firstName: "Subscriber"
    });

    await novu.trigger('momentum--L67FbJvt', {
        to: {
          subscriberId: Id,
          email: email
        },
        payload: {
            title: title,
            description: description
        }
    });
}


export const smsNotification = async (title,description,phone,Id) => {
    const novu = new Novu(process.env.NOVU_API_KEY);

    novu.trigger('sms', {
        to: {
        subscriberId: Id,
        phone: `+91${phone}`
        },
        payload: {
        title: title,
        description: description
        }
    });
}

export const inAppNotification = async (title,description,Id,message) => {
    const novu = new Novu(process.env.NOVU_API_KEY);

    await novu.subscribers.identify(Id, {
        firstName: "inAppSubscriber"
    });

    await novu.trigger('in-app', {
        to: {
            subscriberId: Id
        },
        payload: {
            title: title,
            description: description,
            message: message
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Here, we are going to get Novu's api key from our '.env' file and without it, our app won't function.

Also, you'll need to enter the country code for sms as I've entered the county code for India (+91) in the sms trigger function above.

We also need to set up a utility function for authentication, which is like this:

import jwt from "jsonwebtoken";

const auth=  async (req,res,next)=>{
    try {
        const token= req.headers.authorization.split(" ")[1];
        const isCustomAuth= token.length<500;

        let decodedData;
        if(token && isCustomAuth){
            decodedData= jwt.verify(token,'secret');
            req.userId= decodedData?.id;
        }
        next();
    } catch (error) {
        console.log("auth middleware error",error);        
    }
}

export default auth;
Enter fullscreen mode Exit fullscreen mode

Lastly, we need to set up http routes and map each application route to a corresponding function (that we've set up in the controller above).

This is the code for the routes of our todo:

import express from "express";
import {
  createNote,
  deleteNote,
  updateNote,
  getNotes,
  sendEmailNotification,
  sendSmsNotification,
  deleteInAppNotification,
  toggleNoteDone,
} from "../controllers/note.js";
import auth from "../utils/auth.js";
const router = express.Router();

router.get("/", auth, getNotes);
router.post("/", auth, createNote);
router.patch("/:id", updateNote);
router.delete("/:id", deleteNote);
router.get("/:id", toggleNoteDone);

// for novu API
router.post("/send-sms", sendSmsNotification);
router.post("/send-email", sendEmailNotification);
router.post("/delete", auth, deleteInAppNotification);

export default router;
Enter fullscreen mode Exit fullscreen mode

And this the route file for authentication:

import express from "express";
import {signUp,signIn} from "../controllers/user.js"
const router= express.Router();

router.post('/signup',signUp);
router.post('/signin',signIn);

export default router;
Enter fullscreen mode Exit fullscreen mode

This completes our backend and we'll now move to front-end or the client-side of our application. Just go and deploy the backend to any service of your choice.

I'm using the free version of Render but you can use something of your choice such as Heroku.

Both of them have continuous deployment and each time you push your commits to the back-end repo, a new build is triggered automatically.

Irrespective of which service you use, note down the URL once it is deployed to the Internet. We'll need to plug in into our front-end so that it can communicate with our back-end.

Moving to Front-end:

For front-end, we'll use the 'create-react-app' tool to set up the project (and its git repo) for our app.

Just open a terminal and execute the following command:

npx create-react-app moonshine
Enter fullscreen mode Exit fullscreen mode

now go the 'Moonshine' directory and start the project by:

cd moonshine
npm start
Enter fullscreen mode Exit fullscreen mode

This will start the basic react project. Again, we'll create a '.env' file and add Novu identifier (from the same place from where we got the Novu API key above) and add it to '.gitignore' so that git doesn't track it.

We'll also need to install the following packages:

Novu, axios, dotenv, jwt-decode, moment, react-datepicker, react-icons, react-redux and react-toastify

We can install each package using the following command:

npm i X
Enter fullscreen mode Exit fullscreen mode

Where 'X' is the name of the package.

Directory structure for our front-end code:

We'll be spreading our front-end code into the following directories:

  1. Components: This will contain the individual code for each component that our app is constituted of - Header, loader, todo and quote.
  2. Pages: This will contain all the pages that our app contains. Each page is made up of one or more components. The pages in our app are - Home, login, sign up and quotes.
  3. Common: This directory contains the file that is common across our entire app. For us, it will contain the functions to enable the front-end to talk to the back-end of our app.
  4. Actions: As is evident from the name, it contains the code that dictates which action to fire when a certain task is to be performed by the user.
  5. Reducer: Reducer lets us manage how the state changes when the actions are performed in our application.

I'm sharing the code for one file from each of above directories. If you want, you can look at the entire code of front-end as well as back-end from the GitHub repos linked below.

The code for our todo component is:

import React, { useState, useEffect } from "react";
import "./note.css";
import { useDispatch } from "react-redux";
import {
  deleteNote,
  deleteTodoInApp,
  sendEmailNotification,
  sendSmsNotification,
  toggleTodo,
} from "../../actions/notes";
import { MdOutlineEmail } from "react-icons/md";
import { BsTrash3Fill } from "react-icons/bs";
import { FiEdit } from "react-icons/fi";
import { MdSms } from "react-icons/md";
import { BsReverseLayoutTextWindowReverse } from "react-icons/bs";

const Note = ({
  item,
  setCurrentId,
  setShowForm,
  setIsEditing,
  setSelectedDate,
  theme,
}) => {
  const [email, setEmail] = useState("");
  const [phone, setPhone] = useState("");
  const [isEmail, setIsEmail] = useState(false);
  const [isSms, setIsSms] = useState(false);
  const [showDescription, setShowDescription] = useState(false);

  const [user, setUser] = useState(JSON.parse(localStorage.getItem("profile")));
  const dispatch = useDispatch();

  useEffect(() => {
    setUser(JSON.parse(localStorage.getItem("profile")));
  }, []);

  const donehandler = async (event) => {
    dispatch(toggleTodo(item._id));
  };

  const deleteTodoHandler = async () => {
    const deleteInAppNote = {
      title: item.title,
      description: item.description,
      userId: user?.result?._id,
      message: "deleted",
    };
    try {
      dispatch(deleteTodoInApp(deleteInAppNote));
      dispatch(deleteNote(item._id));
    } catch (error) {
      console.log("deleteTodoHandler error", error);
    }
  };

  const smsHandler = () => {
    setIsSms((prev) => !prev);
  };

  const emailHandler = () => {
    setIsEmail((prev) => !prev);
  };

  const descriptionHandler = () => {
    setShowDescription((prev) => !prev);
  };

  const editTodoHandler = () => {
    setCurrentId(item._id);
    setSelectedDate(new Date(item.date));
    setShowForm(true);
    setIsEditing(true);
    window.scrollTo({ top: 0, behavior: "smooth" });
  };

  const handleSubmitEmail = async (e) => {
    e.preventDefault();
    const emailNote = {
      title: item.title,
      description: item.description,
      email: email,
      noteId: item._id,
    };
    try {
      dispatch(sendEmailNotification(emailNote));
    } catch (error) {
      console.log("handleSubmitEmail error", error);
    }
    setEmail("");
  };

  const handleSubmitPhone = async (e) => {
    e.preventDefault();
    const smsNote = {
      title: item.title,
      description: item.description,
      phone: phone,
      noteId: item._id,
    };
    try {
      dispatch(sendSmsNotification(smsNote));
    } catch (error) {
      console.log("handleSubmitPhone error", error);
    }
    setPhone("");
  };

  return (
    <div
      className="note"
      style={{
        backgroundColor: theme ? "#1f1f2b" : "#f2f2f2",
      }}
    >
      <div className="note_container">
        <div className="note_text_container">
          <input
            type="checkbox"
            className="note_checkbox"
            checked={item.done}
            onChange={donehandler}
            style={{
              cursor: "pointer",
            }}
          />
          <h2 className={item.done ? "note_title done" : "note_title"}>
            {item.title}
          </h2>
        </div>
        <div className="note_button_container">
          {item.description.length > 0 && (
            <div
              className="icon_container note_description"
              onClick={descriptionHandler}
            >
              <BsReverseLayoutTextWindowReverse />
            </div>
          )}
          <div className="icon_container note_email" onClick={emailHandler}>
            <MdOutlineEmail />
          </div>
          <div className="icon_container note_sms" onClick={smsHandler}>
            <MdSms />
          </div>
          <div className="icon_container note_update" onClick={editTodoHandler}>
            <FiEdit />
          </div>
          <div
            className="icon_container note_delete"
            onClick={deleteTodoHandler}
          >
            <BsTrash3Fill />
          </div>
        </div>
      </div>
      <div className="note_input_container">
        {showDescription && (
          <p
            className={item.done ? "note_description done" : "note_description"}
          >
            {item.description}
          </p>
        )}
        {isEmail && (
          <form className="note_form_container" onSubmit={handleSubmitEmail}>
            <input
              className="input_box"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="Enter Assignee email"
            />
            <button className="note_form_button">send Email</button>
          </form>
        )}
        {isSms && (
          <form className="note_form_container" onSubmit={handleSubmitPhone}>
            <input
              className="input_box"
              value={phone}
              onChange={(e) => setPhone(e.target.value)}
              type="number"
              placeholder="Enter Number"
            />
            <button className="note_form_button">Send sms</button>
          </form>
        )}
      </div>
    </div>
  );
};

export default Note;
Enter fullscreen mode Exit fullscreen mode

This component is responsible for rendering a todo card with the note's title, description (if any), and a checkbox to indicate the note's done status along with various options such as deleting the note, sending an email or SMS notification, editing the note, and marking it as done or undone.

Similarly, the code for quotes component is:

import React, { useEffect, useState } from "react";
import Quote from "../../components/Quote/Quote";
import "./landscape.css";

const numImagesAvailable = 988; //how many photos are total in the collection
const numItemsToGenerate = 1; //how many photos you want to display
const imageWidth = 1920; //image width in pixels
const imageHeight = 1080; //image height in pixels
const collectionID = 30697288; //Beach & Coastal, the collection ID from the original url

function renderGalleryItem(randomNumber) {
  fetch(
    `https://source.unsplash.com/collection/${collectionID}/${imageWidth}x${imageHeight}/?sig=${randomNumber}`
  ).then((response) => {
    let body = document.querySelector("body");
    body.style.backgroundImage = `url(${response.url})`;
  });
}

const Landscape = () => {
  const [refresh, setRefresh] = useState(false);
  useEffect(() => {
    let randomImageIndex = Math.floor(Math.random() * numImagesAvailable);
    renderGalleryItem(randomImageIndex);

    return () => {
      let body = document.querySelector("body");
      body.style.backgroundImage = "";
    };
  }, []);

  const refreshImage = () => {
    let randomImageIndex = Math.floor(Math.random() * numImagesAvailable);
    renderGalleryItem(randomImageIndex);
    setRefresh((prev) => !prev);
  };

  return (
    <>
      <Quote refresh={refresh} />
      <button
        className="refresh_button"
        style={{ position: "absolute", top: 1, right: "15rem" }}
        onClick={refreshImage}
      >
        Refresh
      </button>
    </>
  );
};

export default Landscape;
Enter fullscreen mode Exit fullscreen mode

It renders a beautiful image background (using the Unsplash API) with an inspirational quote on it. The component also includes a "Refresh" button that refreshes the image and the quote when clicked.

Making API calls to our back-end:

Moving on, since api calls are common across the entire application, we have put it into the 'common' directory. It uses axios to make API calls to the backend for functions like - user authentication, CRUD operations for todos and sending email and sms notifications.

Remember the backend URL we'd copied earlier. We'll plug it in this file to enable API calls from the front-end of our app to our back-end that we'd deployed earlier.

It contains the following code:

import axios from "axios";

const API = axios.create({ baseURL: "<Enter your back-end URL that you'd copied>" });

API.interceptors.request.use((req) => {
  if (localStorage.getItem("profile")) {
    req.headers.authorization = `Bearer ${
      JSON.parse(localStorage.getItem("profile")).token
    }`;
  }
  return req;
});

// for authentication

export const signIn = (userData) => API.post("/users/signin", userData);
export const signUP = (userData) => API.post("/users/signup", userData);

// for CRUD features

export const fetchNotes = () => API.get("/notes");
export const createNote = (newNote) => API.post("/notes", newNote);
export const updateNote = (id, updatedNote) =>
  API.patch(`/notes/${id}`, updatedNote);
export const deleteNote = (id) => API.delete(`/notes/${id}`);
export const updateNoteChecked = (id) => API.get(`/notes/${id}`);

// for novu implementation
export const sendSms = (note) => API.post("/notes/send-sms", note);
export const sendEmail = (note) => API.post("/notes/send-email", note);
export const deleteInApp = (note) => API.post("/notes/delete", note);
Enter fullscreen mode Exit fullscreen mode

On sign-in and sign-up, we run the corresponding authentication actions from our 'auth action', the code for which is:

import * as api from "../common/api"
import {toast} from "react-toastify";

export const signin=(formValue,navigate)=> async (dispatch)=>{
    try {
        const {data}= await api.signIn(formValue);
        dispatch({type:"AUTH",payload:data});
        navigate("/home");
        toast.success("loggedin successfully!!");
    } catch (error) {
        console.log("signin error",error);
        toast.error("something went wrong!! try again");
    }
}

export const signup=(formvalue,navigate)=>async (dispatch)=>{
    try {
        const {data}= await api.signUP(formvalue);
        dispatch({type:"AUTH",payload:data});
        navigate("/home");
        toast.success("user created successfully");
    } catch (error) {
        console.log("signup error",error);
        toast.error("something went wrong!! try again");
    }
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we use a reducer to manage the state of Moonshine:

const noteReducer = (state = [], action) => {
  switch (action.type) {
    case "FETCH_ALL":
      return action.payload;
    case "CREATE":
      return [...state, action.payload];
    case "UPDATE":
      return state.map((note) =>
        note._id === action.payload._id ? action.payload : note
      );
    case "TOGGLE_DONE":
      return state.map((note) =>
        note._id === action.payload._id ? { ...note, done: !note.done } : note
      );
    case "DELETE":
      return state.filter((note) => note._id !== action.payload);
    default:
      return state;
  }
};

export default noteReducer;
Enter fullscreen mode Exit fullscreen mode

Apart from files in these four directories, the root of our project directory also contains index.js and app.js.

In index.js, we've wrapped our 'App' component ( defined in the file app.js) inside 'router' and 'provider'. 'Router' allows us to define and use various app routes (as we've done above) while 'provider' lets us use the redux store of React, which basically means a database for front-end.

Wrapping the app component means that both these are available across our entire app:

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { BrowserRouter as Router } from "react-router-dom";
import { Provider } from "react-redux";
import thunk from "redux-thunk";
import { applyMiddleware, compose, createStore } from "redux";
import reducers from "./reducers";
import { Toaster } from "react-hot-toast";

const store = createStore(reducers, compose(applyMiddleware(thunk)));

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <Router>
      <Provider store={store}>
        <App />
        <Toaster />
      </Provider>
    </Router>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Finally, this is our humble app component:

import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home/Home";
import Landscape from "./pages/Landscape/Landscape";
import Login from "./pages/Login/Login";
import Signup from "./pages/Signup/Signup";

function App() {
  return (
    <div>
      <Routes>
        <Route path="/" element={<Login />} />
        <Route path="/signup" element={<Signup />} />
        <Route path="/gallery" element={<Landscape />} />
        <Route path="/home" element={<Home />} />
      </Routes>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

It renders different 'route components', each associated with a path in our app. When that path is requested, the corresponding component is rendered on the view of the client-side device.

Deploying our front-end:

We'll need to deploy our front-end separately. For this, we'll use Vercel and the process is fairly straightforward:

  1. Sign into Vercel (using your GitHub account),
  2. Point to the GitHub repo for the front end.
  3. Enter your environment variables in there.

That's it!

If you've followed this tutorial, you will have a deployed version of Moonshine up and running, with the following features:

  • All the functions of a todo app- create a todo, update a todo, mark a task as done/undone and delete it.
  • dark/light mode.
  • Send sms reminder about a task to a phone number using Novu.
  • Send email reminder using Novu.
  • A quotes page with a beautiful image and quote each time you open it.
  • An in-app notifications centre with notifications when a todo is created or deleted.
  • A sign-up/sign-in page and many, many more.

email notification from Moonshine

sms notification from Moonshine

Note: You may need to comply with local laws to send sms notification using Moonshine. In my case, Indian laws require one to undergo OTP verification (one time only) before sending sms.

You can access the code for the front-end as well as the back-end here:
Front-end
Back-end

Lastly, if you're stuck anywhere and need me, I'm always available here

This project was made possible by Novu and it takes a lot of time and effort to come up with tutorials like this, so if you'd spare a moment and star its GitHub repo, it would mean a lot to me. Thanks for your support! ⭐⭐⭐⭐⭐
https://github.com/novuhq/novu

Cat praying to get your star on Novu's github repo

Don't forget to comment down below if you liked the post.
Have a great one, bye! 👋

~ Sumit Saurabh

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .