Build a Loyalty Program using the Loyalty Card Contract

Build a Loyalty Program using the Loyalty Card Contract

This guide will tell you everything you need to know about the new Loyalty Card contract; later in the guide, we will also see it in action in a next.js app where users can generate and cancel their loyalty cards, and we will also build an admin dashboard for the admins to update and revoke the loyalty cards!

Let's get started!

View the source code for this guide here:

GitHub - thirdweb-example/loyalty-card
Contribute to thirdweb-example/loyalty-card development by creating an account on GitHub.

What is the Loyalty Card contract?

The Loyalty Card contract is intended for the use of launching loyalty programs. Each NFT represents a loyalty card, and the NFT's metadata contains the loyalty card's information. It is pretty similar to our prebuilt NFT Collection ERC721 contract but it has some additional functions to manage the cards better.

There is a cancel function for the owner of the NFT, so they can cancel their card, which burns the NFT.

The revoke and update functions allow admins to revoke a user's card and update the NFT metadata to make it more/less valuable based on the users' loyalty status. The cards are issued to users via signature minting.

Since the admin has some extra control over the NFTs of users, this contract is not ideal for most collections but rather collections that represent a loyalty program such as a membership or subscription.

Deploy the Loyalty Card Contract

To begin, head to the Contracts page in your thirdweb Dashboard and hit Deploy Contract:

deploy new contract

You will be taken to the thirdweb Explore page — where you can browse smart contracts built by the top protocols in Web3 and deploy them in just a few clicks!

Note: You can also use the thirdweb CLI to set up a smart contract project to create your own contracts by running the below command from your terminal:
npx thirdweb create contract
This will take you through an easy-to-follow flow of steps for you to create your contract. Learn more about this in our CLI guide.

Otherwise, let's get back to Explore:

thirdweb Explore page

Here, select the Loyalty Card contract. It will take you to the following page:

Loyalty Card contract preview from thirdweb.eth

Once you are on this page, click Deploy Now, and you should see a drawer slide in from the right. Fill in the contract parameters here:

fill in contract parameters of the loyalty contract

Finally, select the network/chain you want to deploy to and hit Deploy Now. This will trigger two transactions in your wallet that you will need to confirm:

Click on confirm to deploy your loyalty card program

Once the transactions go through, you will be able to see the dashboard for your contract:

Loyalty Card contract dashboard

We don't have to do anything else with the contract for now. Let's use the contract in a Next.js app!

Creating the Next.js App

Now, let's create a web app where users can:

  • Generate new loyalty cards
  • View their loyalty cards/cancel them

And admins can:

  • View all the loyalty cards present
  • Revoke cards from users
  • Update the metadata of the loyalty cards

Using the thirdweb CLI, create a new Next.js & TypeScript project with the React SDK preconfigured for you using the following command:

npx thirdweb create app --next --ts
💡
An API key is required to use thirdweb's infrastructure services, such as storage, RPCs, and Smart Wallet infrastructure from within the SDK. If you haven't created a key yet, you can do so for free from the thirdweb dashboard.

To use an API key with the React SDK, pass the clientId to the ThirdwebProvider. The template already comes with the client ID, so you can create a new .env.local file and add the clientId and the secretKey (which we will be using shortly) with the following names:

NEXT_PUBLIC_TEMPLATE_CLIENT_ID=
TW_SECRET_KEY=

Now, add a wallet private key as well:

WALLET_PRIVATE_KEY=<your-private-key>
IMPORTANT: Private Keys.
Using private keys as an env variable is not the best practice and is vulnerable to attacks. We are using this method in this guide for brevity, but we strongly recommend using a secret manager to store your private key.

Ensure you store and access your private key securely.

- Check if you need to use a private key for your application.
- Never directly expose your private key in your source code.
- Never commit any file that may contain your private key to your source control.
- Never use a private key for a frontend (website/dapp) application.

If you are unsure how to securely store and access your private key, please do not proceed.

Next, we need to update the chain that our app works on. Go into _app.tsx and change the activeChain variable to the chain that you deployed your contract to. In my case, it is Mumbai:

// This is the chainId your dApp will work on.
const activeChain = "mumbai";

Since we are going to need the contract address in multiple places, create a new consts.ts file and add the contract address:

export const CONTRACT_ADDRESS = "0x19B8e6c26C5d278905449bF447f98d133392bB3B";

Creating the backend

The backend of our app will generate signatures, using signature minting, with some metadata that the user's wallet can use to mint a card. So, create a new file named generate-sig.ts in the pages/api folder and the basic structure of the API. We are only going to allow post requests to this API, so if it isn't a post req, we send out an error:

import type { NextApiRequest, NextApiResponse } from "next";

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }
};

export default handler;

Below this, add a try-catch block where we initialize the thirdweb SDK using the secret key and the wallet private key:

  try {
    const sdk = ThirdwebSDK.fromPrivateKey(
      process.env.WALLET_PRIVATE_KEY!,
      "mumbai",
      {
        secretKey: process.env.TW_SECRET_KEY!,
      }
    );
  } catch (error) {
    console.error(error);
    return res.status(500).json({ error: "Internal server error" });
  }

Then, we need to access the contract:

const contract = await sdk.getContract(CONTRACT_ADDRESS);

I am using the variable from the consts.ts file we created earlier in the guide. You can import it like this:

import { CONTRACT_ADDRESS } from "../../consts";

Now, we get the address from the request body and create a signature payload using it:

const address = req.body.address;

const payload: PayloadToSign721withQuantity = {
  to: address,
  metadata: {
    name: "My loyalty card",
    description: "Some loyalty card description. Too lazy to write one.",
    image:
      "https://15065ae3c21e0bff07eaf80b713a6ef0.ipfscdn.io/ipfs/bafybeie2mhmbriq4ndtl3i7enkovlm6njycdutobw4jczixdbfoensranm/blue_square.png",
    attributes: [
      {
        trait_type: "color",
        value: "blue",
      },
      {
        trait_type: "points",
        value: 100,
      },
    ],
  },
};

The PayloadToSign721withQuantity is the type of payload that we need to pass, we will import this from the TypeScript SDK. Feel free to check the other properties that you can pass in the metadata and change the values of the ones here.

Finally, we use the contract.erc721.signature.generate function to generate the signature and return it:

const signedPayload = await contract.erc721.signature.generate(payload);

return res.status(200).json({ signedPayload });

Your final API should look similar to this:

import { PayloadToSign721withQuantity, ThirdwebSDK } from "@thirdweb-dev/sdk";
import type { NextApiRequest, NextApiResponse } from "next";
import { CONTRACT_ADDRESS } from "../../consts";

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  try {
    const sdk = ThirdwebSDK.fromPrivateKey(
      process.env.WALLET_PRIVATE_KEY!,
      "mumbai",
      {
        secretKey: process.env.TW_SECRET_KEY!,
      }
    );

    const contract = await sdk.getContract(CONTRACT_ADDRESS);

    const address = req.body.address;

    const payload: PayloadToSign721withQuantity = {
      to: address,
      metadata: {
        name: "My loyalty card",
        description: "Some loyalty card description. Too lazy to write one.",
        image:
          "https://15065ae3c21e0bff07eaf80b713a6ef0.ipfscdn.io/ipfs/bafybeie2mhmbriq4ndtl3i7enkovlm6njycdutobw4jczixdbfoensranm/blue_square.png",
        attributes: [
          {
            trait_type: "color",
            value: "blue",
          },
          {
            trait_type: "points",
            value: 100,
          },
        ],
      },
    };

    const signedPayload = await contract.erc721.signature.generate(payload);

    return res.status(200).json({ signedPayload });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ error: "Internal server error" });
  }
};

export default handler;

Creating the user frontend

Let's now build the front end for a common user! To build this head over to pages/index.tsx. Firstly, delete everything inside the main since we are not going to need it:

import styles from "../styles/Home.module.css";
import { NextPage } from "next";

const Home: NextPage = () => {
  return <main className={styles.main}></main>;
};

export default Home;

We will now add a Web3Button inside the main tag like this:

<Web3Button action={() => generate()} contractAddress={CONTRACT_ADDRESS}>
  Generate NFT
</Web3Button>

As you can see, we are using an external generate function that has not been created yet. We will create that now!

  const generate = async () => {
    try {
      const res = await fetch("/api/generate-sig", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          address,
        }),
      });

      const data = await res.json();

      await contract?.erc721.signature.mint(data.signedPayload);
      alert("NFT minted!");
    } catch (err) {
      console.error(err);
    }
  };

The generate function makes a post request to the API we created earlier and then uses the generated signature to mint the NFT, prompting the user to confirm a transaction!

We also need to add the useContract hook to access our contract:

const { contract } = useContract(CONTRACT_ADDRESS);

If you go ahead and try to mint a loyalty card, it should work!

Now, let's go ahead and display the user's cards. Firstly, we will add the hooks we need to obtain the connected user's address and their owned NFTs:

const address = useAddress();
const { data: nfts, isLoading, isError } = useOwnedNFTs(contract, address);

Now, let's map through the NFTs and render them. Since we are going to need to render the NFTs in various places, I am going to create an NFTCard component:

{nfts && (
    <div className={styles.nfts}>
      {nfts.map((nft) => (
        <NFTCard nft={nft} key={nft.metadata.id} />
      ))}
    </div>
)}

Let's then create a new file named NFTCard.tsx in the components folder and add the following:

import { NFT, ThirdwebNftMedia, Web3Button } from "@thirdweb-dev/react";
import Image from "next/image";
import { type FC } from "react";
import { CONTRACT_ADDRESS } from "../consts";
import styles from "../styles/Home.module.css";

interface NFTProps {
  nft: NFT;
}

export const NFTCard: FC<NFTProps> = ({ nft }) => {
  const id = nft.metadata.id;

  return (
    <div key={nft.metadata.id} className={styles.nft}>
      {nft.metadata.image ? (
        <ThirdwebNftMedia metadata={nft.metadata} />
      ) : (
        <Image
          src="https://t3.ftcdn.net/jpg/02/48/42/64/360_F_248426448_NVKLywWqArG2ADUxDq6QprtIzsF82dMF.jpg"
          alt=""
          width="360"
          height="200"
          style={{
            objectFit: "contain",
          }}
        />
      )}
      <h1>{nft.metadata.name}</h1>
      <p>{nft.metadata.description}</p>

      {nft.metadata.attributes && (
        <ul>
          {/* @ts-ignore */}
          {nft.metadata.attributes.map((attribute) => (
            <li key={attribute.trait_type}>
              <strong>{attribute.trait_type}:</strong> {attribute.value}
            </li>
          ))}
        </ul>
      )}

      <Web3Button
        action={(contract) => contract.erc721.cancel(id)}
        contractAddress={CONTRACT_ADDRESS}
      >
        Cancel
      </Web3Button>
    </div>
  );
};

This will create a simple NFT card that renders the metadata of the NFT, using the thirdweb ThirdwebNFTMedia renderer and has a cancel button that calls the contract.erc721.cancel function, which cancels/burns the NFT.

I have also added some basic styling for the NFTs. You can copy them/change them as you like:

.nfts {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-gap: 24px;
  width: 100%;
  margin-top: 3rem;
}

.nft {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin: 0 auto;
  padding: 1rem;
  border: 1px solid #eaeaea;
  border-radius: 10px;
  width: fit-content;
}

It will render an NFT like this:

That's it for the user side. Feel free to go above and beyond for the use case, but for the sake of simplicity of the guide, we have kept it simple!

Building the Admin Dashboard

Let's go ahead and build the admin dashboard! For that, create a new file admin.tsx in the pages folder. We will add the basic structure here as well:

import styles from "../styles/Home.module.css";
import { NextPage } from "next";

const Admin: NextPage = () => {
  return <main className={styles.main}></main>;
};

export default Admin;

Then, instead of useOwnedNFTs that we used on the home page, we will use useNFTs which gets all the NFTs of the contract:

const { contract } = useContract(CONTRACT_ADDRESS);
const { data: nfts, isLoading, isError } = useNFTs(contract);

We can then reuse the NFTCard again to render the cards on the contract:

  {nfts && (
        <div className={styles.nfts}>
          {nfts.map((nft) => (
            <NFTCard nft={nft} key={nft.metadata.id} adminView={true} />
          ))}
        </div>
      )}

If you notice, we have added another property called adminView, which also needs to be added in the NFTCard component, so let's add this logic! Firstly, we will add it to the type and accept it as a prop:

interface NFTProps {
  nft: NFT;
  adminView?: boolean;
}

export const NFTCard: FC<NFTProps> = ({ nft, adminView }) => {

Then, we will add a condition to check whether adminView is enabled. If it is, then we need to render some extra information and a revoke button; otherwise, a simple cancel button:

     {adminView ? (
        <>
          <p>
            <strong>Token ID:</strong> {nft.metadata.id}
          </p>
          <p>Owner: {nft.owner}</p>
          {nft.owner !== "0x0000000000000000000000000000000000000000" && (
            <Web3Button
              action={(contract) => contract.erc721.revoke(id)}
              contractAddress={CONTRACT_ADDRESS}
            >
              Revoke
            </Web3Button>
          )}
        </>
      ) : (
        <Web3Button
          action={(contract) => contract.erc721.cancel(id)}
          contractAddress={CONTRACT_ADDRESS}
        >
          Cancel
        </Web3Button>
      )}

Next, let's add the ability to edit the points property of a loyalty card.

For that, first, add two useState hooks to store the new points value and the editing state:

const [points, setPoints] = useState(0);
const [isEditing, setIsEditing] = useState(false);

Next, add the edit functionality below the revoke button:

  {isEditing ? (
            <>
              <input
                type="number"
                value={points}
                onChange={(e) => setPoints(Number(e.target.value))}
              />
              <Web3Button
                action={() => update()}
                contractAddress={CONTRACT_ADDRESS}
              >
                Update
              </Web3Button>
            </>
          ) : (
            <button
              onClick={() => setIsEditing(true)}
              className={styles.button}
            >
              Edit
            </button>
          )}

Here, we are checking if isEditing is true. If it is, then we show an input and an update Web3Button, which calls an update function (we will be writing that next). Otherwise, a simple button to change the editing state.

Next, let's add the update function so that the admin can update the points when in edit mode:

  const { contract } = useContract(CONTRACT_ADDRESS);

  const update = async () => {
    try {
      const metadata = {
        ...nft.metadata,
        attributes: [
          // @ts-ignore
          ...nft.metadata.attributes.filter(
            // @ts-ignore
            (attribute) => attribute.trait_type !== "points"
          ),
          {
            trait_type: "points",
            value: points,
          },
        ],
      };

      await contract?.erc721.update(nft.metadata.id, metadata);
    } catch (err) {
      console.error(err);
    } finally {
      setIsEditing(false);
    }
  };

Here, I am just changing the card points and keeping the other metadata the same as before, but you can add more inputs in the same manner to update the other metadata as well.

If you check the admin page, you will be able to see an NFT card similar to this:

Wrapping up

This guide taught us how to deploy a loyalty card contract, build a user frontend to generate and cancel the card, and an admin dashboard to revoke and edit users' cards.

You learned a lot. Now pat yourself on the back and share your amazing apps with us on the thirdweb discord!


Need help?

For support, join the official thirdweb Discord server or share your thoughts on our feedback board.