How to build a cloud-based loyalty rewards app in Next.js

Tosin Moronfolu - Jul 13 '23 - - Dev Community

Loyalty rewards programs are crucial in fostering customer engagement and driving customer retention. Loyalty rewards programs incentivize customers to keep coming back and promote a sense of engagement and satisfaction.

In this article, we will build a powerful loyalty rewards app using Next.js, a popular React framework, alongside the robust Appwrite Cloud backend-as-a-service platform and the visually stunning Pink Design Library.

This app will have a list of stores and the user will gain points by buying from these stores and these points would be added to the user’s account.

Prerequisites

To follow along with this article you will need:

  1. A basic understanding of JavaScript and Next.js
  2. An Appwrite Cloud account (you can create one here)

Repository

Find the complete code used in this article on GitHub.

Project setup

Node needs to be installed on our computer to set up the Next.js application. To install Node, go to the Node.js website and follow the instructions to install the specific software that is compatible with our operating system.

We can verify the Node.js installation by running the command below:

node -v
v18.15.0 //node version installed
Enter fullscreen mode Exit fullscreen mode

To create the Next.js app, run the command below. It will automatically set up a boilerplate Next.js app.

npx stands for Node Package eXecute. It executes any package from the npm registry without installing it.

npx create-next-app@latest <app-name>
# or
yarn create next-app <app-name>
Enter fullscreen mode Exit fullscreen mode

After the installation is complete, change the directory into the app we just created:

cd <app-name>
Enter fullscreen mode Exit fullscreen mode

Next, install these dependencies:

npm i @appwrite.io/pink appwrite
Enter fullscreen mode Exit fullscreen mode

Run npm run dev or yarn dev to start the development server on http://localhost:3000.

Setting up the Appwrite database

We’ll be using Appwrite Cloud database service in this app.

To set up an Appwrite Cloud database, log in to the Appwrite console and create a new project.

Appwrite personal projects dashboard

Next, click on Databases and then Create database. We can name it rewards-app.

Appwrite databases dashboard

In the database, create a collection named users and in the collection, add the attributes below with their respective types:

  1. points - integer
  2. userID - integer


Appwrite databases collection dashboard


Click the Indexes tab and add an index on the userID attribute. Next, go to the settings tab and then scroll down to Update permissions. Add a new role for Any and then give the role all permissions.

Appwrite permissions dashboard

Setting up the app

Inside the pages directory, create the following files:

  1. store.js: From this page we will list the fictional grid of stores where points can be redeemed
  2. purchase.js: This page will simulate the completion of a purchase and issue a reward to the user

Setting up Pink Design

Open the _app.js file and replace the existing code with the following:



    import '@appwrite.io/pink';
    import '@appwrite.io/pink-icons';

    function MyApp({ Component, pageProps }) {
      return <Component {...pageProps} />;
    }

    export default MyApp;


Enter fullscreen mode Exit fullscreen mode

Setting up the components

Open pages/index.js and replace the existing code with the following:



    import React, { useEffect, useState } from 'react';
    import { Client, Databases, ID, Query } from 'appwrite';

    const client = new Client()
      .setEndpoint('https://cloud.appwrite.io/v1')
      .setProject('[PROJECT-ID]');

    const databases = new Databases(client);

    const HomePage = () => {
      const [points, setPoints] = useState(0);
      const stores = [
        { name: 'Store 1', discount: '10% off', points: 10 },
        { name: 'Store 2', discount: '20% off', points: 20 },
        { name: 'Store 3', discount: '30% off', points: 30 },
        { name: 'Store 4', discount: '40% off', points: 40 },
      ];

      return (
        <div
          className='container'
          style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}
        >
          <h1
            style={{
              fontSize: '32px',
              marginBottom: '20px',
              color: 'hsl(var(--color-neutral-300))',
            }}
          >
            Rewards App
          </h1>
          <div
            style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'space-between',
              marginBottom: '20px',
            }}
          >
            <p style={{ fontSize: '24px', color: 'hsl(var(--color-neutral-300))' }}>
              Total Points:
            </p>
            <p style={{ fontSize: '32px', fontWeight: 'bold', color: '#0070f3' }}>
              {points}
            </p>
          </div>
          <div
            style={{
              gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
              gridGap: '20px',
            }}
            className='grid-box'
          >
            {stores.map((store, index) => (

            ))}
          </div>
        </div>
      );
    };

    export default HomePage;


Enter fullscreen mode Exit fullscreen mode

Let's go through the code and explain what is happening:

  1. React, useEffect, and useState are imported from the 'react' package. Client, Databases, ID, and Query are imported from the 'appwrite' package
  2. An instance of the Client class from the appwrite package is created and configured with the endpoint and project ID
  3. An instance of the Databases class is created using the Client instance, which provides access to the database-related functionalities
  4. Within the HomePage component, the points state is initialized using the useState hook, with an initial value of 0
  5. An array called stores is declared, which contains objects representing different stores, each having properties like name, discount, and points
  6. The JSX markup representing the structure and UI of the homepage is styled with Pink Design's utility classes

Open pages/stores.js and add the following code:



    import React from 'react';
    import Link from 'next/link';

    const Store = ({ name, discount, store, points }) => {
      return (
        <div
          style={{
            border: '1px solid #ddd',
            textAlign: 'center',
            backgroundColor: '#f9f9f9',
          }}
          className='u-padding-24'
        >
          <h3 style={{ fontSize: '18px', marginBottom: '10px' }}>{name}</h3>
          <p style={{ fontSize: '16px', color: '#0070f3' }}>{discount}</p>
          <Link
            style={{
              marginTop: '20px',
              textAlign: 'center',
              textDecoration: 'none',
              backgroundColor: '#0070f3',
              fontWeight: 'bold',
              fontSize: '16px',
              color: '#fff',
              border: 'none',
              padding: '10px 20px',
              cursor: 'pointer',
              transition: 'backgroundColor 0.3s ease',
            }}
            className='u-block'
            href={`/purchase?store=${encodeURIComponent(store)}&points=${points}`}
          >
            <span>Purchase</span>
          </Link>
        </div>
      );
    };

    export default Store;


Enter fullscreen mode Exit fullscreen mode

Let's go through the code and explain what is happening:

  1. React is imported from the 'react' package and the Link component is imported from ‘next/link’
  2. The Store component is defined as a functional component which receives name, discount, store, and points as props
  3. The JSX markup is returned, representing the structure and UI of a store card and includes elements styled with Pink Design
  4. The Link component from Next.js is used to create a clickable link for purchasing from the store
  5. The href prop of the Link component is set to a dynamically generated URL. The store parameter is URI encoded using encodeURIComponent()

Open pages/purchase.js and add the following code:



    import React, { useState } from 'react';
    import { useRouter } from 'next/router';
    import { Client, Databases, ID, Query } from 'appwrite';
    import Link from 'next/link';

    const client = new Client()
      .setEndpoint('https://cloud.appwrite.io/v1') // Our API Endpoint
      .setProject('[PROJECT-ID]');
    const databases = new Databases(client);

    const PurchasePage = () => {
      const router = useRouter();
      const { store, points } = router.query;
      const [rewardPoints, setRewardPoints] = useState(0);
      const [purchaseComplete, setPurchaseComplete] = useState(false);

      const buttonStyle = {
        backgroundColor: purchaseComplete ? '#ccc' : '#0070f3',
        color: '#fff',
        border: 'none',
        padding: '10px 20px',
        fontSize: '16px',
        cursor: purchaseComplete ? 'not-allowed' : 'pointer',
        transition: 'background-color 0.3s ease',
      };

      const product = { name: 'Product 1', price: 50 };

      const handlePurchase = async (price) => {
        const reward = Math.floor(price * (points / 100));
        const documentInfo = JSON.parse(localStorage.getItem('documentInfo'));
        const collectionId = documentInfo.info.$collectionId;
        const documentId = documentInfo.info.$id;
        const databaseId = documentInfo.info.$databaseId;
        const currentPoints = documentInfo.info.points;

        await databases.updateDocument(databaseId, collectionId, documentId, {
          points: currentPoints + reward,
        });

        setRewardPoints(reward);
        setPurchaseComplete(true);
      };

      return (
        <div
          className='container u-padding-24'
          style={{ maxWidth: '800px', margin: '0 auto' }}
        >
          <h1
            style={{
              fontSize: '32px',
              marginBottom: '20px',
              color: 'hsl(var(--color-neutral-300))',
              textAlign: 'center',
            }}
          >
            Complete Purchase and Earn Rewards
          </h1>
          <div
            style={{
              gridTemplateColumns: 'repeat(1, 1fr)',
              gridGap: '20px',
            }}
            className='grid-box'
          >
            <div
              style={{
                border: '1px solid #ddd',
                textAlign: 'center',
                backgroundColor: '#f9f9f9',
                alignSelf: 'center',
                width: '300px',
                justifySelf: 'center',
              }}
              className='u-padding-24'
            >
              <h3>{product.name}</h3>
              <p>Price: ${product.price}</p>
              <button
                style={buttonStyle}
                className='u-margin-32'
                onClick={() => handlePurchase(product.price)}
                disabled={purchaseComplete}
              >
                {purchaseComplete ? 'Purchased' : 'Purchase'}
              </button>
            </div>
          </div>
          {purchaseComplete && (
            <p
              style={{
                marginTop: '20px',
                fontSize: '16px',
                color: 'green',
                textAlign: 'center',
              }}
            >
              Congratulations! You earned {rewardPoints} reward points for your
              purchase at {store}.{' '}
              <Link style={{ color: 'blue', textDecoration: 'underline' }} href='/'>
                Go Home
              </Link>
            </p>
          )}
        </div>
      );
    };

    export default PurchasePage;


Enter fullscreen mode Exit fullscreen mode

Let's go through the code and explain what is happening:

  1. React, useState, and the useRouter hook from Next.js are imported from their respective packages; Client, Databases, ID, and Query are imported from the 'appwrite' package, and Link is imported from Next.js
  2. An instance of the Client class from the appwrite package is created and configured with the endpoint and project ID
  3. An instance of the Databases class is created using the Client instance, which provides access to the database-related functionalities
  4. The useRouter hook is used to access the query parameters from the URL. The store and points values are extracted from the router.query object
  5. Within the component, the rewardPoints state is initialized using the useState hook with an initial value of 0 (this state will hold the earned reward points)
  6. The purchaseComplete state is also initialized using the useState hook with an initial value of false and its state will track whether the purchase has been completed
  7. The buttonStyle object is defined with inline styles for the purchase button; it has a function set in the onClick handler, handlePurchase, which we will create later in the article
  8. A product object is defined with properties name and price
  9. The JSX markup representing the structure and UI of the purchase page is styled with Pink Design

Building the functionality

In the index.js file, we will create two functions:

  1. One is to check that the current user exists in the database, and if so, we will display their points. Since we aren't building an authentication system, we would use the user's IP address to ensure it's unique.
  2. The other function is to store the user's points in the database.

Modify the index.js file like so:



    ...
    const HomePage = () => {
      ...

      useEffect(() => {
        checkUser();
      }, []);

      const storePoints = async (uniqueID, points) => {
        await databases.createDocument(
          '646a20a583e20fd44d35',
          '646a2112a4601b39a496',
          ID.unique(),
          { userID: uniqueID, points }
        );
      };

      const checkUser = async () => {
        let userIP = await fetch('https://api.ipify.org?format=json')
          .then((response) => response.json())
          .then(async (data) => data.ip)
          .catch((error) => {
            console.error('Error fetching IP address:', error);
          });

        const user = await databases.listDocuments(
          '[DATABASE-ID]',
          '[COLLECTION-ID]',
          [Query.equal('userID', userIP)]
        );

        if (user.total < 1) {
          storePoints(userIP, 0);
        } else {
          localStorage.setItem(
            'documentInfo',
            JSON.stringify({ info: user.documents[0] })
          );
          setPoints(user.documents[0].points);
        }
      };

      return (
    ...


Enter fullscreen mode Exit fullscreen mode
  1. The useEffect hook calls the checkUser function when the component mounts; since an empty dependency array is provided as the second argument, it only runs once
  2. The storePoints function is an asynchronous function that creates a document in the database using the createDocument method from the Databases instance; it stores the unique ID of the user and the points they have
  3. The checkUser function is an asynchronous function that fetches the user's IP address using the fetch API, it then checks if the user exists in the database by querying the database with the user's IP address. If the user doesn't exist, the storePoints function creates a new document with the user's IP and 0 points. If the user does exist, the document information is stored in the local storage and the points are set using the setPoints function

Next, display the list of fictional stores by adding the Store component to the mapping of the stores array:



    ...
    return (
        ...
            {stores.map((store, index) => (
              <Store
                key={index}
                name={store.name}
                discount={store.discount}
                store={store.name}
                points={store.points}
              />
            ))}
    ...


Enter fullscreen mode Exit fullscreen mode

Handling purchases

Open the purchase.js file and add the following function:



    ...

    const PurchasePage = () => {

    ...
      const handlePurchase = async (price) => {
        const reward = Math.floor(price * (points / 100));
        const documentInfo = JSON.parse(localStorage.getItem('documentInfo'));
        const collectionId = documentInfo.info.$collectionId;
        const documentId = documentInfo.info.$id;
        const databaseId = documentInfo.info.$databaseId;
        const currentPoints = documentInfo.info.points;

        await databases.updateDocument(databaseId, collectionId, documentId, {
          points: currentPoints + reward,
        });

        setRewardPoints(reward);
        setPurchaseComplete(true);
      };

      return (
        ...


Enter fullscreen mode Exit fullscreen mode

The handlePurchase function defined above is called when the purchase button is clicked, it calculates the reward points based on the price and the points query parameter. It then retrieves the necessary information from the localStorage and updates the user's points in the database using the databases.updateDocument method. The rewardPoints state is updated with the calculated reward points, and the purchaseComplete state is set to true to blur the purchase button.

The final result

https://www.loom.com/share/87e5b4efd14e4e95aad24e49c02ebec7

Conclusion

In conclusion, we built a cloud-based loyalty rewards app in Next.js by integrating Appwrite Cloud and Pink Design. Appwrite Cloud allowed us to seamlessly manage and efficiently track user points with easy database interaction. Pink Design added a touch of elegance and user-friendliness to the app's interface, enhancing the overall visual appeal and usability.

We can extend the app's functionality by integrating authentication and a checkout payment system. The app can provide personalized experiences and secure user registration and login processes by implementing user authentication. Furthermore, integrating a checkout payment system enables users to redeem their earned loyalty points for products or services.

Resources

  1. NextJS documentation
  2. Appwrite Cloud
  3. Pink Design
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .