Build a Bitcoin-Powered Charity Donation dApp on Rootstock: A Fun, Full-Stack Starter Guide

I love crafting beautiful web experiences and writing efficient smart contracts. I'm currently exploring Zero-Knowledge proofs, as well as Functional, Systems, Concurrent, and Scripting Programming Languages (Rust, Erlang, and Python).
Introduction: Why Build BitGive on Rootstock?
What if your Bitcoin could do more than just chill in a wallet? Imagine donating a handful of satoshis to a cause you love, knowing every transaction is secure, transparent, and rewarded with a unique digital badge, a Non-Fungible Token (NFT) that celebrates your generosity. That’s the magic of BitGive, a decentralized application (dApp) we’re building in this guide. It’s a charity donation platform powered by Rootstock (RSK), a Bitcoin sidechain that fuses Bitcoin’s legendary security with Ethereum’s smart contract superpowers. Whether you’re a coding newbie or a blockchain buff, this project is your ticket to mastering dApp development with a fun, world-changing twist.
Why BitGive?
BitGive isn’t your average tutorial dApp; it’s a chance to build something that matters. Users donate RBTC to verified charities, with every transaction logged transparently on Rootstock’s blockchain. As a thank-you, donors receive a dynamic NFT that reflects their contribution like a digital medal shouting, “I made a difference!” For developers, this project is a portfolio game-changer. You’ll master smart contracts, build a sleek front-end, and dive into NFTs, all while showcasing skills that scream, “I’m Web3-ready.” Employers love projects with heart, and BitGive’s social impact makes it a standout.
In this guide, we’ll walk you step-by-step through creating BitGive: coding a Solidity smart contract, designing a Next.js front-end with Tailwind CSS, and integrating with thirdweb for seamless blockchain interaction. No Rootstock experience is needed if you’ve got a bit of JavaScript know-how and maybe played with web development, you’re set. By the end, you’ll have a working dApp, a GitHub repo to flex, and a spark to keep exploring Rootstock. Ready to make Bitcoin work for good? Let’s unpack what BitGive is all about!
Project Overview: What is BitGive?
Before we roll up our sleeves and start coding, let’s get the big picture of BitGive. Imagine a digital donation jar for charities, but supercharged with blockchain tech. Users connect their wallets, choose a cause, donate RBTC (Rootstock’s Bitcoin-backed token), and receive a custom NFT as a keepsake. Every donation is tracked on Rootstock’s transparent ledger, so anyone can verify the impact. It’s straightforward, fun, and the perfect way to learn full-stack dApp development while building a project that pops on your resume.
BitGive’s Core Features
Here’s what makes BitGive shine:
Donation System: Users pick from a curated list of charities (we’ll hardcode a few for this guide) and donate any amount of RBTC. The smart contract securely logs each donation, ensuring funds are tracked and tamper-proof.
Transparent Tracking: Donations are stored in a public mapping on Rootstock’s blockchain. Curious how much a charity’s raised? Query the contract and see the totals in real time. It’s like a glass donation box open for all to check.
Dynamic NFTs: Every donor gets a unique NFT based on their contribution. Drop 0.001 RBTC? You’re a Bronze Supporter. Hit 0.005 RBTC? Welcome, Silver Donor! These NFTs, stored on IPFS, carry metadata showing your donation amount, making them collectible and brag-worthy.
Slick Interface: The front end lets users donate with a few clicks, browse their NFT collection, and view donation stats. It’s designed to be intuitive, even for folks new to crypto, with a clean, modern look inspired by platforms like GoFundMe.
Why’s this project a blast? You’re not just writing code, you’re crafting a platform that blends generosity with Web3 swagger. Those NFTs add a playful twist, like earning a badge for doing good. Picture showing off a “Gold Donor” NFT in your wallet, pretty sweet, right?
The Tech Stack: What You’ll Use
To bring BitGive to life, we’re using a beginner-friendly stack that plays to Rootstock’s strengths and keeps things familiar for anyone who’s touched Ethereum development:
Smart Contract: Code in Solidity, the go-to language for Ethereum and Rootstock smart contracts. We’ll use Hardhat to compile, test, and deploy to the Rootstock Testnet (a free sandbox with test RBTC) and optionally the Mainnet for real-world use.
Front-End: Built with Next.js, a powerful React framework that makes creating fast, SEO-friendly web apps a breeze. We’ll style it with Tailwind CSS for a polished, responsive design that’s easy to tweak and looks professional.
Blockchain Integration: Thirdweb, a Web3 development platform that simplifies connecting your front-end to Rootstock. With Thirdweb’s SDK, you’ll handle wallet connections, contract calls, and NFT minting without wrestling with low-level Web3 details.
NFT Storage: Store NFT images and metadata on IPFS (InterPlanetary File System) via Pinata, a decentralized storage service that’s perfect for Web3 projects. It’s secure, scalable, and keeps your NFTs accessible.
Why This Project Stands Out
Forget cookie-cutter dApp tutorials (yawn, another voting app). BitGive is fresh, meaningful, and tied to Rootstock’s Bitcoin roots. Users donate RBTC, essentially Bitcoin, to causes they care about, and the NFT rewards add a creative spin that teaches cutting-edge Web3 skills. But let’s talk portfolio power: a full-stack dApp with smart contracts, a Next.js front-end styled with Tailwind CSS, and thirdweb integration? That’s a project that grabs attention. Add the social impact angle, and you’ve got a conversation starter for interviews. Imagine explaining how you built a charity platform on Bitcoin’s sidechain!
What You’ll Learn
By building BitGive, you’ll get hands-on with:
Writing and deploying a Solidity smart contract on Rootstock.
Creating a responsive Next.js front-end with Tailwind CSS.
Connecting to the blockchain using Thirdweb’s SDK.
Minting and storing NFTs with IPFS.
Testing and launching a dApp that’s ready to shine.
Prerequisites: Setting Up Your Environment
Alright, future Web3 Rockstar, let’s get your toolbox ready to build BitGive! Let’s walk through what you need to start building this Bitcoin-powered charity dApp.
First, let’s talk about skills. You don’t need to be a blockchain guru, but a basic understanding of JavaScript will help since we’re using Next.js for the front end. If you’ve built a simple web page with HTML and CSS, you’re already ahead of the curve. Familiarity with React is a bonus, as Next.js builds on it, but I’ll guide you through the specifics. Ever heard of Solidity? It’s the language for our smart contract, but don’t sweat it if it’s new; we’ll keep it approachable. If you’ve followed any Ethereum tutorial or played with MetaMask, you’re in great shape.
Now, onto the tools. You’ll need Node.js and npm installed on your computer; these are the backbone for running JavaScript projects. Think of them as the engine and fuel for your coding car. Next, grab a code editor like Visual Studio Code (VS Code); it’s free, powerful, and makes writing code feel like a breeze. For blockchain magic, we’ll use MetaMask, a browser extension that acts as your crypto wallet. It’s where you’ll store test RBTC and interact with Rootstock. Make sure you have a modern browser like Chrome or Firefox to run it smoothly.
Here’s the setup game plan:
Install Node.js: Download the latest version from the official site. This gets you npm too, which we’ll use to manage project dependencies.
Set Up VS Code: Install it and add extensions like Prettier for clean formatting, it’s like a spell-checker for code.
Get MetaMask: Add the extension to your browser and create a new wallet (or use an existing one). Write down your seed phrase and keep it safe, think of it as the key to your digital vault (Don’t share this seed phrase with anyone).
Set up your Thirdweb Account: Create and set up a Thirdweb project. You can follow this guide here.
Configure Rootstock Testnet: In MetaMask, add Rootstock’s Testnet network using the official settings from Rootstock’s developer portal. This is our playground where we’ll test without spending real money. Follow this link to configure Rootstock Testnet to your Metamask.
Grab Test RBTC: Visit Rootstock’s Testnet faucet and request some test RBTC for your MetaMask wallet.
We’ll also need a few project-specific tools, but don’t worry about installing them yet; we’ll cover that when we initialize the project. For now, ensure your computer is ready with these basics. If anything feels tricky, Rootstock’s community on Discord is super helpful. By the end of this setup, you’ll have everything you need to start coding BitGive.
Building the Smart Contract: The Heart of BitGive
Now we’re diving into the core of BitGive, the smart contract. This is the engine that powers our dApp, handling donations, tracking totals, and minting those shiny NFTs.
Quickly, before writing the smart contract that powers BitGive, let’s set up our project folder structure.
So this tutorial is focused on the Overall building of the dApp for that reason we won’t be going through all the basics of setting up a Nextjs app, I have created a starter that has the basic beautifully crafted UI, Which we’ll be modifying to connect to the blockchain an to interact with our deployed smart contract.
Head to this repo here: https://github.com/michojekunle/BitGive/tree/frontend-starter
Clone the repo, you can do that this way: In your terminal, run the command.
git clone --branch frontend-starter --single-branch https://github.com/michojekunle/BitGive.git
After cloning this, you can open it up on VS Code by running these commands in your terminal.
cd BitGive
code .
And you should have this below.

Notice that this already has the initial Frontend directory; you can move around the file and folder structure to see the UI implementations if you’d love to. Now let’s dive into writing the smart contracts.
To build this, you’ll use Hardhat, a development environment that simplifies writing, testing, and deploying contracts. We’ll start by setting up a Hardhat project, which gives you a folder structure to organize your work.
To do this, you can open up your terminal in VS Code and we’ll create a new folder called smart-contract and then run this command below to initiate a new hardhat project.
mkdir smart-contract
cd smart-contract
npx hardhat init
You should see something like this below.

We’re going to be creating a typescript project, select Create a Typescript project and enter and then enter the rest of the options, you should see this.

While the dependencies are being installed, let’s get to the contract
So, for this part, I want to assume that you, the reader, are familiar with Solidity and will be able to understand the Solidity contract we’re about to write, but still, no worries, I’ll give brief explanations of what’s going on within each contract.
To start with, we’ll be installing @openzeppelin/contracts. Run the command below in your terminal, still in your smart-contract directory
npm i @openzeppelin/contracts
Next, let’s head to the contracts directory in your new hardhat project (in your smart contract folder). You’d have the Lock.sol contract in your contracts folder; you can delete that as it’s a test contract that comes with creating a new hardhat project.
We’d be writing four contracts for this project,
BitGiveRegistry.sol: This is the central registry for the BitGive platform that manages roles and platform settings. We’ll get to see why this is important as we proceed.CampaignManager.sol: This is the contract that manages all charity campaigns.DonationManager.sol: This is the contract that manages donations and NFT rewards.NFTReward.sol: This is the NFT contract that is used in this project as the reward for donations to charities.
I’ve created a gist where you can access the code for these contracts here: https://gist.github.com/michojekunle/7899b1394f4232e3395e835a04e71cef
The Nat spec comments in the code explicitly explain what each function does.
To test that the contracts work as expected, I’ve written a suite of tests for the contracts. You can access them here https://github.com/michojekunle/BitGive/tree/main/smart-contract/test. You can get the test code and run the npx hardhat test command in your terminal to ensure all functionalities work as expected.
If you got the test code and ran the test command in your terminal, you should see this.

Now You’re ready to deploy the contracts Here’s a detailed article on testing and deploying contracts guide you can follow for you to deploy your contracts.
Crafting the Front-End: Bringing BitGive to Life
Time to make BitGive look as good as it works! The front end is where users interact with our dApp, donate RBTC, check stats, and admire their NFTs. We’re building it with Next.js, a React framework that’s fast and flexible, styled with Tailwind CSS for a clean, modern vibe inspired by platforms like GoFundMe. When I started playing with Web3 front-ends, I was amazed at how a few lines of CSS could turn code into something users love. Let’s create an intuitive interface that is inviting and screams, “Donate with Bitcoin!”
By the end, you’ll have a front end that’s not just functional but delightful, turning BitGive into a dApp, that users can’t resist.
So, back to our frontend folder, you can open it up in your terminal and install the dependencies by running npm install --legacy-peer-deps or pnpm install If you have pnpm installed globally
Now, we need to add some environment variables to make our code function as expected, so we’re going to go sign up on Pinata, then head to your Api Keys section on the sidebar of your dashboard and create a new API Key

We’ll call this BitGive API Key, and make sure you toggle on the admin switch so you can make read and write functions to upload your NFT images and NFT metadata

Immediately after creating your API key, copy your secret key and your API key and add them to your new .env file in the root of your frontend directory.
PINATA_API_KEY=<your-pinata-api-key>
PINATA_API_SECRET=<your-pinata-api-secret>
‼️ Note: When pushing to a GitHub repository, never commit your .env
In your .gitignore Make sure you have specified it in there:
node_modules
.env
Now we’ll start by integrating thirdweb very quickly
If you haven’t already created and set up your thirdweb project on your thirdweb dashboard, you can head to your thirdweb dashboard, create a new project, get your client ID, and then add it to your .env
NEXT_PUBLIC_THIRDWEB_CLIENT_ID=<your-thirdweb-client-id>
Then, create a new file /lib/config.tsHere we’ll create our thirdweb client
import { createThirdwebClient, defineChain } from "thirdweb";
import { inAppWallet, createWallet } from "thirdweb/wallets";
// ‼️ Note: When pushing to a GitHub repository, never commit your .env
export const client = createThirdwebClient({
clientId: process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID!,
});
export const rootstockTestnet = defineChain({
id: 31,
name: "Rootstock Testnet",
rpc: "https://public-node.testnet.rsk.co",
nativeCurrency: {
name: "tRBTC",
symbol: "tRBTC",
decimals: 18,
},
blockExplorers: [
{ name: "RSK Testnet Explorer", url: "https://explorer.testnet.rootstock.io/" },
{ name: "Blockscout Testnet Explorer", url: "https://rootstock-testnet.blockscout.com/" },
],
testnet: true,
});
export const wallets = [
inAppWallet({
auth: {
options: [
"google",
"telegram",
"farcaster",
"email",
"x",
"passkey",
"phone",
],
},
}),
createWallet("io.metamask"),
createWallet("com.coinbase.wallet"),
createWallet("io.rabby"),
];
Here in this file, we’ve defined the wallets and rootstockTestnet chain, which we’ll be using in the connect-btn component below, but first, we’ll install Thirdweb
npm i --legacy-peer-deps thirdweb
// or
pnpm add thirdweb
Then we’ll create a new component connect-btn.tsx
import React from "react";
import { client, wallets, rootstockTestnet } from "@/lib/config";
import { ConnectButton } from "thirdweb/react";
import { darkTheme } from "thirdweb/react";
const ConnectBtn = () => {
return (
<ConnectButton
client={client}
wallets={wallets}
theme={darkTheme({
colors: { accentText: "#F7931A" },
})}
chain={rootstockTestnet}
connectButton={{
label: "Sign In",
style: {
padding: "0.6rem 1.5rem",
fontSize: "0.875rem",
backgroundColor: "#F7931A",
transition: "background-color 0.2s ease-in-out",
height: "auto",
width: "auto",
color: "white",
},
}}
connectModal={{ size: "compact" }}
/>
);
};
export default ConnectBtn;
And then wrap the <ThirdwebProvider> around your app
import type React from "react"
import "@/app/globals.css"
import { ThemeProvider } from "@/components/theme-provider"
import { ThirdwebProvider } from "thirdweb/react" // import thirdweb provider from thirdweb
import { Toaster } from "@/components/ui/sonner"
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
<ThirdwebProvider> // added Thirdweb Provider here
{children}
<Toaster/>
</ThirdwebProvider>
</ThemeProvider>
</body>
</html>
)
}
And now we can use our connect-btn component in our app
So, head to the dashboard-layout.tsx component and change the dummy connected address component to use the connect-btn component. Here, you’ll find about three places and two places, in the desktop and mobile sidebar sections, respectively.
// Replace this code in these sections with the <ConnectBtn/> component
// for the desktop and mobile sidebar sections
<div className="flex items-center gap-3 rounded-lg bg-gradient-to-br from-card/80 to-card/40 p-3 backdrop-blur-sm border border-border/20 shadow-sm">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#F7931A] to-[#F5A623] text-white shadow-glow-sm">
<Bitcoin className="h-4 w-4" />
</div>
<div className="flex-1 text-xs">
<p className="font-medium">Connected Wallet</p>
<p className="text-muted-foreground truncate">0x1a2b...3c4d</p>
</div>
</div>
and for the header section
// replace this code here for the header section with the <ConnectBtn/>
<Button variant="outline" className="hidden md:flex">
<Bitcoin className="mr-2 h-4 w-4 text-[#F7931A]" />
Connected: 0x1a2b...3c4d
</Button>
Then from this;
You should now have this below;


Now you can connect your wallet and sign in to the application, even using your socials, isn’t that interesting? Thanks to Thirdweb account abstraction.
Now, we’ll start by creating the integration of charity campaigns. For this, we’ll create a hook, and we’ll use this hook throughout the campaign, create a new hook hooks/use-create-campaign.tsx
import { useState } from "react";
import {
getContract,
prepareContractCall,
resolveMethod,
sendAndConfirmTransaction,
} from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { uploadImageToIPFS } from "@/lib/utils";
import { contracts } from "@/lib/contract";
import { client, rootstockTestnet } from "@/lib/config";
const useCreateCampaign = () => {
const account = useActiveAccount();
const address = account?.address;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createCampaign = async (campaignData: {
name: string;
description: string;
story: string;
image: File;
goal: number;
duration: number;
impacts: string[];
}) => {
setIsLoading(true);
setError(null);
try {
if (!address) {
throw new Error("No active account found.");
}
if (
!campaignData.name ||
!campaignData.description ||
!campaignData.image ||
!campaignData.goal ||
!campaignData.duration ||
!campaignData.impacts.length
)
return;
const imageUrl = await uploadImageToIPFS(
campaignData.image,
campaignData.name
);
if (!imageUrl) {
throw new Error("Failed to upload image to IPFS.");
}
const contract = getContract({
client,
address: contracts.campaignManager.address,
chain: rootstockTestnet,
});
const tx = prepareContractCall({
contract,
// @ts-ignore
method: resolveMethod("createCampaign"),
params: [
campaignData.name,
campaignData.description,
campaignData.story,
campaignData.goal * 10 ** 18,
campaignData.duration,
campaignData.impacts,
imageUrl,
],
});
await sendAndConfirmTransaction({
transaction: tx,
account,
});
setIsLoading(false);
} catch (err: any) {
setError(err.message || "An error occurred while creating the campaign.");
setIsLoading(false);
throw err;
}
};
return { createCampaign, isLoading, error };
};
export default useCreateCampaign;
Also, we’re going to create a new file to have all our contract details. That file will be called lib/contract.ts Here, you’d have the addresses and ABIs of your deployed contracts.
import campaignManagerAbi from "../abi/campaign-manager.json";
import donationManagerAbi from "../abi/donation-manager.json";
import nftRewardAbi from "../abi/nft-reward.json";
import registryAbi from "../abi/registry.json";
export const contracts = {
campaignManager: {
address: "0x57A783371456B956cfB7FaC2bAB7478fc98b85E4",
abi: campaignManagerAbi,
},
donationManager: {
address: "0x0aA1A5c7970FC6716a589da1E861E2DB7f0dbdf2",
abi: donationManagerAbi,
},
nftReward: {
address: "0x6Be0fC1FA590d4300e9C1Af59f551dFA082464ef",
abi: nftRewardAbi,
},
registry: {
address: "0x8DB607b6a6BEdF7cab97A641aF05CdA5c2558334",
abi: registryAbi,
}
}
To get the ABI of your contracts, you can head to the explorer and copy your contract ABI from there since your deployed contract is verified

You can see above in the use-create-campaign.tsx hook, we have three things that are returned: the createCampaign function the loading state and the error state; if any error occurs, we’re going to use this hook in our create-campaign-form.tsx component. There’ll be a few changes to the component, so take note below. First, we’ll take care of the handleSubmit function, we’d refactor it this way
//previous code
const account = useActiveAccount();
const { createCampaign, isLoading, error } = useCreateCampaign();
const handleSubmit = async () => {
const loadingToast = toast.loading("Creating campaign...");
await createCampaign({
name: formData.name,
description: formData.description,
story: formData.story,
image: formData.image!,
goal: formData.goal,
duration: Number(formData.duration),
impacts: formData.impacts,
});
if(!error && !isLoading) {
setIsSuccess(true);
toast.success("Campaign created successfully!");
} else {
toast.error("Error creating campaign. Please try again.");
}
toast.dismiss(loadingToast);
};
Also, we’re changing the form element that wraps this create-campaign-form component to a div element
// previously
return (
<form onSubmit={handleSubmit}>
//rest of your code
</form>
)
// now
return (
<div>
//rest of our code
</div>
)
And then in the card footer section, where we have our continue and create campaign button, we modify it this way.
{currentStep <= 2 ? (
<Button
type="button"
onClick={nextStep}
disabled={!isStepComplete(currentStep)}
className="bg-gradient-to-r from-[#F7931A] to-[#F5A623] text-white hover:from-[#F5A623] hover:to-[#F7931A] shadow-glow-sm disabled:opacity-50"
>
Continue
</Button>
) : account ? (
<Button
disabled={isLoading}
onClick={handleSubmit}
className="bg-gradient-to-r from-[#F7931A] to-[#F5A623] text-white hover:from-[#F5A623] hover:to-[#F7931A] shadow-glow-sm disabled:opacity-50"
>
{isLoading ? "Creating Campaign..." : "Create Campaign"}
</Button>
) : (
<ConnectBtn />
)}
We’re checking if an account is connected before the create campaign button is shown; if no account is connected, the sign-in button is shown instead.
Now you should be all done with this component. If you now try to fill in the campaign form and try to create a new campaign, it should trigger you to sign the transaction and create a new campaign for you.
Now, let’s head to fetching and displaying all charity campaigns. We’ll also create a hook for this, create a new hook. use-fetch-campaigns.tsx
import { useState } from "react";
import {
getContract,
readContract,
resolveMethod,
} from "thirdweb";
import { contracts } from "@/lib/contract";
import { client, rootstockTestnet } from "@/lib/config";
import { publicClient } from "@/lib/client";
import { Abi, formatUnits } from "viem";
export interface Campaign {
id: number;
owner: string;
name: string;
description: string;
story: string;
goal: number;
raisedAmount: number;
createdAt: number;
duration: number;
impacts: string[];
imageURI: string;
isActive: boolean;
verified: boolean;
featured: boolean;
donations?: number;
}
const useFetchCampaigns = () => {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const contract = getContract({
client,
address: contracts.campaignManager.address,
chain: rootstockTestnet,
});
const donationManagerContract = getContract({
client,
address: contracts.donationManager.address,
chain: rootstockTestnet,
});
const fetchCampaignDetails = async (campaignId: number): Promise<Campaign | undefined> => {
try {
setLoading(true);
setError(null);
const campaignDetails = await readContract({
contract,
method: resolveMethod("getCampaignInfo"),
params: [campaignId],
}) as any;
const donations = await readContract({
contract: donationManagerContract,
method: resolveMethod("getDonationsByCampaign"),
params: [campaignId],
}) as BigInt[]
return {
...campaignDetails,
createdAt: Number(campaignDetails.createdAt),
id: Number(campaignDetails.id),
goal: Number(formatUnits(campaignDetails.goal, 18)),
raisedAmount: Number(formatUnits(campaignDetails.raisedAmount, 18)),
donations: donations.length
};
} catch (err: any) {
setError(err.message || "Failed to fetch campaign details");
} finally {
setLoading(false);
}
};
const getAllCampaigns = async (): Promise<Campaign[]> => {
try {
setLoading(true);
setError(null);
const allCampaignsIds = await readContract({
contract,
method: resolveMethod("getAllCampaigns"),
params: [],
});
const allCampaigns = await getCampaignsDetails(
allCampaignsIds as number[]
);
return allCampaigns as Campaign[];
} catch (err: any) {
setError(err.message || "Failed to fetch all campaigns");
} finally {
setLoading(false);
}
return [];
};
const getCampaignsDetails = async (ids: number[]) => {
const rawTxs = ids.map((id) => ({
address: contracts.campaignManager.address,
abi: contracts.campaignManager.abi as Abi,
functionName: "getCampaignInfo",
args: [id],
}));
const results = await publicClient.multicall({
contracts: rawTxs,
});
return results
.filter(({ status }) => status === "success")
.map(({ result }: { result?: any; status: string; error?: Error }) => ({
...result,
createdAt: Number(result.createdAt),
id: Number(result.id),
goal: Number(formatUnits(result.goal, 18)),
raisedAmount: Number(formatUnits(result.raisedAmount, 18)),
}));
};
return {
fetchCampaignDetails,
getAllCampaigns,
getCampaignsDetails,
loading,
error,
};
};
export default useFetchCampaigns;
Here we have three major functions, getAllCampaigns, getCampaignsDetails, fetchCampaignDetails, and then the loading state and the error state.
The getAllCampaigns function is used to fetch all campaigns to be displayed, and within it uses the getCampaignsDetails function to get all campaigns’ details because, from the contract, the getAllCampaigns function returns the ids of all the campaigns. The getCampaignsDetails function fetches the campaign details for all campaign IDs. It uses multicall from Viem to batch the calls efficiently.
And the fetchCampaignDetails fetches a single campaign detail, which will be used on the charity detail page for getting the details of a specific charity campaign
Let’s set that up (viem ish);
First off, install viem pnpm add viem or npm i --legacy-peer-deps viem, and then create a new file lib/client.ts
import { createPublicClient, http } from 'viem'
import { rootstockTestnet } from 'viem/chains'
export const publicClient = createPublicClient({
chain: rootstockTestnet,
transport: http()
})
Now, we can head to the charity-explorer.tsx component, to fetch and display all campaigns
//previous imports
import { useEffect, useState } from "react";
import useFetchCampaigns, { Campaign } from "@/hooks/use-fetch-campaigns";
export default function CharityExplorer() {
const [charities, setCharities] = useState<Campaign[]>([]);
const [search, setSearch] = useState("");
const [filteredCharities, setFilteredCharities] = useState(charities);
const { getAllCampaigns, loading, error } = useFetchCampaigns();
useEffect(() => {
const timeout = setTimeout(() => {
const lowerSearch = search.toLowerCase();
setFilteredCharities(
charities.filter((charity) =>
charity.name.toLowerCase().includes(lowerSearch)
)
);
}, 400);
return () => clearTimeout(timeout);
}, [search]);
useEffect(() => {
async function run() {
const campaigns = await getAllCampaigns();
setCharities(campaigns);
setFilteredCharities(campaigns);
}
run();
}, []);
return (
<>
//previous code
{loading && (
<div className="flex items-center justify-center w-full h-full">
<Loader2 className="text-[#F5A623] w-11 h-11 text-2xl animate-spin" />
</div>
)}
{error && <div>An error occured fetching featured charities</div>}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredCharities.map((charity) => (
<Card key={charity.id} className="overflow-hidden">
<div className="relative h-40 w-full ">
<Image
src={charity.imageURI || "/placeholder.svg"} // here we changed charity.image to charity.imageURI
alt={charity.name}
fill
className="object-cover"
/>
{charity?.featured && (
<Badge className="absolute left-2 top-2 bg-[#F5A623]">
Featured
</Badge>
)}
</div>
<CardHeader className="p-4">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{charity.name}</CardTitle>
{charity?.verified && (
<div className="flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
<Bitcoin className="mr-1 h-3 w-3" />
Verified
</div>
)}
</div>
<CardDescription className="line-clamp-2">
{charity.description}
</CardDescription>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="mb-2">
<Progress
value={(charity.raisedAmount / charity.goal) * 100} // here we changed charity.raised to charity.raisedAmount
className="h-2"
/>
</div>
<div className="flex items-center justify-between text-sm">
<span>
Raised:{" "}
<span className="font-medium">
{charity.raisedAmount} RBTC
</span>
</span>
<span>
Goal: <span className="font-medium">{charity.goal} RBTC</span>
</span>
</div>
<Link href={`/charities/${charity.id}`}>
<Button className="mt-4 w-full bg-[#F5A623] text-white hover:bg-[#E09612]">
Learn More
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</>
);
}
Now, this should properly fetch all campaigns.
Here’s a little challenge for you: create a new page, “Your Campaigns,” that shows the signed-in user’s campaigns, and try to fetch and display the signed-in user’s campaigns. There’s a function in our contract that handles that; the getOwnerCampaigns function.
Now, moving on to the charity detail page. Head to the charity-detail.tsx component.
// previous imports
import useFetchCampaigns, { Campaign } from "@/hooks/use-fetch-campaigns";
import { notFound } from "next/navigation";
import { useState, useEffect } from "react";
export default function CharityDetail({ charityId }: { charityId: number }) {
const { fetchCampaignDetails, loading, error } = useFetchCampaigns();
const [charity, setCharity] = useState<Campaign | null>(null);
useEffect(() => {
async function run() {
const campaign = await fetchCampaignDetails(charityId);
if (campaign) {
setCharity(campaign);
}
}
run();
}, []);
if (error) {
return notFound();
}
if (loading || !charity) {
return (
<div className="flex items-center justify-center w-full h-full">
<Loader2 className="text-[#F5A623] w-14 h-14 text-2xl animate-spin" />
</div>
);
}
const percentage = Math.min(
Math.round((charity.raisedAmount / charity.goal) * 100),
100
);
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Link href="/charities">
<Button
variant="outline"
size="sm"
className="border-border/40 hover:border-[#F5A623]/60 hover:bg-gradient-to-br hover:from-[#F7931A]/10 hover:to-[#F5A623]/10"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Charities
</Button>
</Link>
{charity.featured && (
<Badge className="bg-gradient-to-r from-[#F7931A] to-[#F5A623] text-white">
Featured
</Badge>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="overflow-hidden border-border/40 bg-card/50 backdrop-blur-sm">
<div className="relative h-64 w-full md:h-80">
<Image
src={charity.imageURI || "/placeholder.svg"}
alt={charity.name}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-background to-transparent" />
<div className="absolute bottom-0 left-0 p-6">
<h1 className="text-3xl font-bold">{charity.name}</h1>
<p className="mt-2 max-w-2xl text-muted-foreground">
{charity.description}
</p>
</div>
</div>
</Card>
</motion.div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<motion.div
className="lg:col-span-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="overflow-hidden border-border/40 bg-card/50 backdrop-blur-sm">
<CardContent className="p-6">
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold">About This Cause</h2>
<p className="mt-2 text-muted-foreground">{charity.story}</p>
</div>
<div>
<h2 className="text-xl font-bold">Our Impact</h2>
<ul className="mt-2 space-y-2">
{charity.impacts.map((item, index) => (
<motion.li
key={index}
className="flex items-start gap-2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 + index * 0.1 }}
>
<CheckCircle className="mt-0.5 h-5 w-5 text-[#F5A623]" />
<span>{item}</span>
</motion.li>
))}
</ul>
</div>
<div className="rounded-lg border border-border/40 bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm p-4">
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-[#F7931A] to-[#F5A623] text-white shadow-glow-sm mx-auto">
<Bitcoin className="h-6 w-6" />
</div>
<div className="mt-2 text-lg font-bold">
{charity.raisedAmount} RBTC
</div>
<div className="text-xs text-muted-foreground">
Raised of {charity.goal} RBTC
</div>
</div>
<div className="text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-[#2D9CDB] to-[#56CCF2] text-white shadow-glow-sm mx-auto">
<Users className="h-6 w-6" />
</div>
<div className="mt-2 text-lg font-bold">
{charity?.donations}
</div>
<div className="text-xs text-muted-foreground">
Donations
</div>
</div>
<div className="text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-[#6FCF97] to-[#27AE60] text-white shadow-glow-sm mx-auto">
<Target className="h-6 w-6" />
</div>
<div className="mt-2 text-lg font-bold">
{percentage}%
</div>
<div className="text-xs text-muted-foreground">
Funded
</div>
</div>
</div>
<div className="mt-4">
<Progress value={percentage} className="h-2" />
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div
className="lg:col-span-1"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
// here the donation form accepts two props, the campaignId and the
// campaign verified status, because once verified, campaigns can receive donations
<DonationForm campaignId={charityId} verified={charity?.verified}/>
</motion.div>
</div>
</div>
);
}
Now, this page fetches the charity details properly and displays them.
Here’s another challenge for you: there’s a owner property in the charity campaign that is fetched from the contract, you can use that to conditionally display a withdraw funds section where the owner of the campaign can withdraw funds donated to the campaign.
Next, we’re going to implement a hook/use-donations.tsx hook that handles everything donations, donating to campaigns, and fetching a user’s donations.
import { useState } from "react";
import {
getContract,
prepareContractCall,
readContract,
resolveMethod,
sendAndConfirmTransaction,
toWei,
} from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { uploadNftMetadata } from "@/lib/utils";
import { contracts } from "@/lib/contract";
import { client, rootstockTestnet } from "@/lib/config";
import { NftBadgeTierUris } from "@/lib/constants";
import useFetchCampaigns from "./use-fetch-campaigns";
import { publicClient } from "@/lib/client";
import { Abi, formatUnits } from "viem";
export interface DonationRecord {
id: number;
donor: string;
campaignAddress: string;
campaignId: number;
campaignName?: string;
campaignImage?: string;
amount: number;
timestamp: number;
nftId: string;
tier: string;
}
const useDonations = () => {
const account = useActiveAccount();
const address = account?.address;
const { getCampaignsDetails } = useFetchCampaigns();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const contract = getContract({
client,
address: contracts.donationManager.address,
chain: rootstockTestnet,
});
const donateToCampaign = async (
campaignId: number,
donationAmount: string,
tier: string
) => {
setIsLoading(true);
setError(null);
try {
if (!address) {
throw new Error("No active account found.");
}
if (campaignId < 0 || !donationAmount) return;
let tokenURI = "";
if (tier) {
const imageUri = NftBadgeTierUris[tier];
const nextTierCount = await readContract({
contract: nftContract,
method: resolveMethod("getNextTierCount"),
params: [tier],
});
if (!imageUri) throw new Error("Can't find tier nft badge");
const tokenMetadata = await uploadNftMetadata({
fileCID: imageUri,
name: `${tier} Badge #${Number(nextTierCount)}`,
description: `This NFT represents the ${tier} tier contribution to a charity campaign. Thank you for your support!`,
charityLink: `https://bit-give.vercel.app/charities/${campaignId}`,
charityId: `${campaignId}`,
externalUrl: `https://bit-give.vercel.app`,
});
if (!tokenMetadata.tokenUri) {
throw new Error("Failed to upload image to IPFS.");
}
tokenURI = tokenMetadata.tokenUri;
}
const tx = prepareContractCall({
contract,
// @ts-ignore
method: resolveMethod("processDonation"),
params: [campaignId, tokenURI],
value: toWei(donationAmount),
});
const txReceipt = await sendAndConfirmTransaction({
transaction: tx,
account,
});
setIsLoading(false);
return txReceipt;
} catch (err: any) {
console.log(err);
setError(err.message || "An error occurred while dontating to campaign.");
setIsLoading(false);
throw err;
}
};
const getDonorDonations = async (): Promise<DonationRecord[]> => {
if (!address) return [];
try {
setIsLoading(true);
setError(null);
const donationIds = (await readContract({
contract,
method: resolveMethod("getDonationsByDonor"),
params: [address],
})) as any;
const rawTxs = donationIds.map((id: any) => ({
address: contracts.donationManager.address,
abi: contracts.donationManager.abi as Abi,
functionName: "getDonationDetails",
args: [id],
}));
const results = await publicClient.multicall({
contracts: rawTxs,
});
const campaignIds = results.map(
({ result }: { result?: any; status: string; error?: Error }) =>
result.campaignId
);
const campaigns = await getCampaignsDetails(campaignIds);
return results
.filter(({ status }) => status === "success")
.map(
(
{ result }: { result?: any; status: string; error?: Error },
idx: number
) => ({
...result,
id: Number(result.id),
campaignId: Number(result.campaignId),
timestamp: Number(result.timestamp) * 1000,
amount: Number(formatUnits(result.amount, 18)),
campaignName: campaigns[idx].name,
campaignImage: campaigns[idx].imageURI,
})
);
} catch (error: any) {
setError(
error.message || "An error occured while fetching Donor Donations"
);
} finally {
setIsLoading(false);
}
return [];
};
return { donateToCampaign, getDonorDonations, isLoading, error };
};
export default useDonations;
This hook will serve both the donation-form component and the donations page for fetching a user’s donations. First, let’s start with the donation-form.tsx component.
// previous imports
import useDonations from "@/hooks/use-donations";
import { toast } from "sonner";
import { useActiveAccount } from "thirdweb/react";
import ConnectBtn from "./connect-btn";
export default function DonationForm({
campaignId,
verified,
}: {
campaignId: number;
verified: boolean;
}) {
const [amount, setAmount] = useState<string>("");
const [status, setStatus] = useState<string | null>(null);
const [txHash, setTxHash] = useState("");
const { donateToCampaign, isLoading, error } = useDonations();
const account = useActiveAccount();
const handleDonate = async () => {
const loadingToast = toast.loading("Donating to campaign");
try {
setStatus("awaiting");
const tier = getNftTier();
const txReceipt = await donateToCampaign(
campaignId,
amount,
tier ? tier : ""
);
if (error) throw error;
if (txReceipt?.status === "success") {
setStatus("success");
toast.success("Donated to campaign successfully");
setTxHash(txReceipt.transactionHash);
}
} catch (error) {
console.error(error);
toast.error("Error donating to campaign, please try again");
setStatus(null);
} finally {
toast.dismiss(loadingToast);
}
};
// previous code
return (
<Card className="h-full overflow-hidden border-border/40 bg-card/50 backdrop-blur-sm">
// previous code (card-content)
<CardFooter className="flex flex-col gap-4">
{account ? (
<Button
className="w-full bg-gradient-to-r from-[#F7931A] to-[#F5A623] text-white hover:from-[#F5A623] hover:to-[#F7931A] shadow-glow-sm"
onClick={handleDonate}
disabled={!amount || !!status || isLoading || !verified}
>
<Bitcoin className="mr-2 h-4 w-4" />
Donate with RBTC
</Button>
) : (
<ConnectBtn />
)}
{!verified && (
<div className="rounded-lg border border-border/40 bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm p-4">
<div className="flex items-start gap-3">
<Info className="mt-0.5 h-5 w-5 text-[#F5A623]" />
<div>
<h4 className="font-medium">Campaign Not Verified</h4>
<p className="text-sm text-muted-foreground">
Campaign is still undergoing verification, Once the campaign
is verified, you'll be able to donate. Please check back in a
while.
</p>
</div>
</div>
</div>
)}
{status && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
transition={{ duration: 0.3 }}
className="w-full rounded-lg border border-border/40 bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm p-3 text-center text-sm"
>
{status === "awaiting" && (
<span className="text-muted-foreground">
Awaiting confirmation...
</span>
)}
{status === "success" && (
<span className="flex items-center justify-center gap-1 font-medium text-green-500">
Success! Donation Completed{" "}
{tier && " with NFT minted successfully"}
<a
href={`https://rootstock-testnet.blockscout.com/tx/${txHash}`}
target="_blank"
className="ml-1 inline-flex items-center text-xs text-[#F5A623] hover:text-[#F7931A] transition-colors"
>
View Transaction
<ExternalLink className="ml-0.5 h-3 w-3" />
</a>
</span>
)}
</motion.div>
)}
</CardFooter>
</Card>
);
}
With this, once you try to donate to a verified campaign now you get a pop-up from your wallet to donate to the campaign.
Let’s now move to the donations page, which displays all the signed-in user’s donations. Head to the donation-history.tsx component.
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import useDonations, { DonationRecord } from "@/hooks/use-donations";
import { formatDate, formatTime } from "@/lib/utils";
import { useActiveAccount } from "thirdweb/react";
import Image from 'next/image';
import Link from 'next/link';
export default function DonationHistory() {
const [donations, setDonations] = useState<DonationRecord[]>([]);
const { getDonorDonations, isLoading, error } = useDonations();
const account = useActiveAccount();
useEffect(() => {
async function run() {
const donations = await getDonorDonations();
setDonations(donations);
}
run();
}, [account]);
if (error) {
return <div>An error occured fetching donations</div>;
}
if (isLoading) {
return (
<div className="flex items-center justify-center w-full h-full">
<Loader2 className="text-[#F5A623] w-14 h-14 text-2xl animate-spin" />
</div>
);
}
return (
<div>
{donations.length < 1 && (
<>You don't have any donations yet, Make a donation to a charity</>
)}
<div className="space-y-4">
{donations.map((donation) => (
<div key={donation.id} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted overflow-hidden">
<Image src={donation.campaignImage || '/placeholder.jpg'} alt="campaign-image" width={20} height={20} className="w-full h-full"/>
</div>
<div>
<Link href={`/charities/${donation.campaignId}`}><h3 className="font-medium">{donation.campaignName}</h3></Link>
<p className="text-sm text-muted-foreground">
{formatDate(donation.timestamp)} at{" "}
{formatTime(donation.timestamp)}
</p>
</div>
</div>
</div>
<div className="mt-8 flex items-center justify-between sm:justify-start sm:gap-16">
<div>
<div className="text-sm text-muted-foreground">Amount</div>
<div className="font-medium">{donation.amount} RBTC</div>
</div>
<div>
<div className="text-sm text-muted-foreground">
NFT Received
</div>
<div className="font-medium">{donation.nftId}</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}
6. Adding NFTs: The Fun Part
Here comes the part that makes BitGive sparkle NFTs! These aren’t just digital trinkets; they’re personalized rewards that celebrate every donation, adding a playful, collectible vibe to our dApp. If you’ve ever earned a badge for a good deed, you’ll get why NFTs are so exciting. When I first saw NFTs in action, I was hooked on their potential to make blockchain fun. Let’s dive into how we’ll create and showcase these tokens to make donors feel like heroes.
Our NFTs are dynamic, meaning they change based on donation size. Give 0.001 RBTC, and you’re a Bronze Supporter with a bronze-themed badge. Hit 0.005 RBTC, and you snag a Silver Donor NFT, maybe with a sleek metallic shine. Go big with 0.01 RBTC, and you’re a Gold Donor, rocking a glowing, premium design. Each NFT carries metadata like the donation amount and date, making it a unique keepsake stored forever on the blockchain.
Store on IPFS: Upload the NFT images and metadata (JSON files with details like “name: Gold Donor, amount: 0.05 RBTC”) to IPFS using Pinata. IPFS is like a decentralized cloud drive, ensuring your NFTs are accessible and tamper-proof.
Mint in the Contract: The smart contract already handles minting NFTs when a donation is made. It assigns a unique ID and links to the IPFS metadata, tying the NFT to the donor’s wallet.
TLDR: Both processes above are done in the
donateToCampaignfunction in theuseDonationshook, We have some helper functions to upload the NFT metadata to IPFS for storage, and the contract call to donate to the campaign process, minting the NFTDisplay in the Front-End: Now we’re going to update the NFT gallery to fetch tokens from the smart contract via thirdweb. Show each NFT as a card with its image, title, and details. If the user’s wallet is empty, we’re going to display a cheerful prompt like “Donate to start collecting!”
First, let’s create the hook to handle fetching the NFTs. Create a new file hooks/use-get-nfts.tsx
import { useState } from "react";
import { getContract, readContract, resolveMethod } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { contracts } from "@/lib/contract";
import { client, rootstockTestnet } from "@/lib/config";
import useFetchCampaigns from "./use-fetch-campaigns";
import { publicClient } from "@/lib/client";
import { Abi } from "viem";
export interface NFTMetadata {
id: number;
title: string;
image: string;
charity: string;
date: number;
tier: string;
}
const useGetNfts = () => {
const account = useActiveAccount();
const address = account?.address;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const contract = getContract({
client,
address: contracts.nftReward.address,
chain: rootstockTestnet,
});
const getOwnerNFTs = async (): Promise<NFTMetadata[]> => {
if (!address) return [];
try {
setIsLoading(true);
const tokenIds = (await readContract({
contract,
method: resolveMethod("getTokensByOwner"),
params: [address],
})) as any;
const rawMetadataTxs = tokenIds.map((id: any) => ({
address: contracts.nftReward.address,
abi: contracts.nftReward.abi as Abi,
functionName: "getNFTMetadata",
args: [id],
}));
const rawTokenUriTxs = tokenIds.map((id: any) => ({
address: contracts.nftReward.address,
abi: contracts.nftReward.abi as Abi,
functionName: "tokenURI",
args: [id],
}));
const rawTxs = [...rawMetadataTxs, ...rawTokenUriTxs];
const results = await publicClient.multicall({
contracts: rawTxs,
});
const midIndex = results.length / 2;
const nftMetadataResults = results.slice(0, midIndex);
const tokenUriResults = results.slice(midIndex);
const tokenUris = tokenUriResults.map(
({ result }: { result?: any; status: string; error?: Error }) => result
);
const tokenDetails = await Promise.all(
tokenUris.map(async (uri) => {
const response = await fetch(uri);
return response.json();
})
);
return nftMetadataResults.map(
(
{ result }: { result?: any; status: string; error?: Error },
idx: number
) => ({
id: Number(result.tokenId),
title: tokenDetails[idx].name,
image: tokenDetails[idx].image,
date: Number(result.mintedAt) * 1000, //converting time to milliseconds
tier: result.tier,
charity: result.campaignName,
})
);
} catch (error: any) {
setError(
error.message || "An error occured while fetching Donor Donations"
);
} finally {
setIsLoading(false);
}
return [];
};
return { getOwnerNFTs, isLoading, error };
};
export default useGetNfts;
Then in the nft-gallery.tsx component
// previous imports
import { useEffect, useState } from "react";
import useGetNfts, { NFTMetadata } from "@/hooks/use-get-nfts";
import { formatDate } from "@/lib/utils";
import { useActiveAccount } from "thirdweb/react";
import { toast } from "sonner";
export default function NftGallery() {
const [nfts, setNfts] = useState<NFTMetadata[]>([]);
const { getOwnerNFTs, isLoading, error } = useGetNfts();
const account = useActiveAccount();
const handleShare = (uri: string) => {
navigator.clipboard
.writeText(uri)
.then(() => {
toast.success("NFT link copied to clipboard! 🎉", {
position: "top-center",
});
})
.catch((error) => {
console.error("Failed to copy:", error);
toast.error("Failed to copy the NFT link.", {
position: "top-center",
});
});
};
useEffect(() => {
async function run() {
const nfts = await getOwnerNFTs();
setNfts(nfts);
}
run();
}, [account]);
if (error) {
return <div>An error occured fetching nfts</div>;
}
if (isLoading) {
return (
<div className="flex items-center justify-center w-full h-full">
<Loader2 className="text-[#F5A623] w-14 h-14 text-2xl animate-spin" />
</div>
);
}
return (
<div>
{nfts.length < 1 && (
<>You don't have any nfts yet, Donate to start collecting! 🤩</>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{nfts.map((nft) => (
<Card key={nft.id} className="overflow-hidden">
<div className="relative aspect-square w-full overflow-hidden bg-gradient-to-br from-orange-100 to-blue-100">
<Image
src={nft.image || "/placeholder.svg"}
alt={nft.title}
fill
className="object-cover"
/>
<div className="absolute bottom-2 right-2 rounded-full p-1 shadow-sm backdrop-blur">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleShare(`https://rootstock-testnet.blockscout.com/token/0x6bE0fc1fa590d4300E9c1aF59F551Dfa082464EF/instance/${nft.id}`)}
>
<Share2 className="h-4 w-4" />
<span className="sr-only">Share</span>
</Button>
</div>
</div>
<CardHeader className="p-4">
<CardTitle className="text-base">{nft.title}</CardTitle>
<CardDescription>{nft.charity}</CardDescription>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="flex items-center justify-between">
<Badge
className={
nft.tier === "Gold"
? "bg-yellow-100 text-yellow-800 hover:text-white"
: nft.tier === "Silver"
? "bg-gray-100 text-gray-800 hover:text-white"
: "bg-amber-100 text-amber-800 hover:text-white"
}
>
{nft.tier}
</Badge>
<span className="text-xs text-muted-foreground">
{formatDate(nft.date)}
</span>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
Below is a sneak peek of what this NFT gallery section looks like. Here I’ve donated about 0.001 tRBTC to a cause, and I’ve earned a bronze badge NFT.


Why’s this the fun part? NFTs turn giving into a game where users collect badges while supporting causes, and you learn a hot Web3 skill. It’s a win-win that makes BitGive stand out. Ready to polish it up? Let’s talk about deployment to the web next!
That wraps it up for this article, now the last part of the puzzle for this project is the dashboard page, which shows the overview of the platform stats, I’ll leave that to you as a challenge to figure out how to implement, if you’re stuck anywhere you can always check the GitHub repo or leave comments under this article.
Gas-Cost Context Table
💸 Gas-Cost Estimates for Donations on Rootstock
| Tier | Amount Donated (RBTC) | Transaction Type | Gas Used/Transaction Fee | Transaction Hash |
| None(i.e., no NFT) | 0.000001 | processDonation() | 319,195/0.00000196 tRBTC | 0xb99642992b... |
| Bronze | 0.001 | processDonation() | 663,060/0.0000037 tRBTC | 0xa75799d9b4... |
| Silver | 0.005 | processDonation() | 618,037/0.0000037 tRBTC | 0x052ae59b03… |
| Gold | 0.01 | processDonation() | 615,020/0.0000038 tRBTC | 0x127bd21821f2… |
📝 Note: Gas usage can vary slightly based on network congestion and wallet behavior, but this should give you a helpful planning baseline.
Deployment to the web
You’re going to host your Next.js app on Vercel. Build your app <locally npm run build to ensure no errors>, push it to a GitHub repo, and connect it to your hosting service (Vercel). Test the hosted version: donate, check NFTs, and verify the UI sparkles.
Feeling bold? You can deploy to Rootstock Mainnet for real-world use, but save that for when you’re confident Mainnet uses real RBTC, and gas isn’t free. For now, a Testnet deployment is plenty to show off. Push your code to GitHub with a killer README explaining BitGive’s mission: its portfolio gold.
This phase is where you see your work come alive. A bug-free dApp that runs on Rootstock? That’s something to celebrate! Let’s wrap up why BitGive is your resume’s new best friend.
Why BitGive Rocks Your Resume
You’ve just built BitGive, a Bitcoin-powered charity dApp that’s equal parts tech and heart. But this isn’t just a cool project; it’s a portfolio powerhouse that can open doors in the Web3 world. When I started showcasing my hackathon-inspired projects, I saw how much recruiters love work that blends skills with impact. Let’s break down why BitGive is your ticket to standing out, whether you’re chasing a dev job or building your GitHub cred.
First, the skills. BitGive shows you can:
Master Blockchain: You wrote a Solidity smart contract on Rootstock, handling donations and NFTs like a pro. That’s Web3 wizardry right there.
Build Full-Stack: Your Next.js front-end, styled with Tailwind CSS, proves you can craft user-friendly apps that look sharp and scale well.
Integrate Web3: Using thirdweb to connect wallets and contracts means you work with NFTs: Creating and displaying NFTs with IPFS shows you’re up on the latest trends, from digital collectibles to decentralized storage. Understand blockchain interaction, a hot skill in the 2025 job market.
Work with NFTs: Creating and displaying NFTs with IPFS shows you’re up on the latest trends, from digital collectibles to decentralized storage.
But it’s not just tech, BitGive has soul. Its charity focus screams social impact, setting it apart from generic voting or marketplace dApps. Imagine telling an interviewer, “I built a platform that lets people donate Bitcoin to charities and rewards them with NFTs.” That’s a story that sticks. Plus, Rootstock’s Bitcoin-Ethereum hybrid gives you niche cred. Most devs stick to Ethereum, so you’re already unique.
Then there’s the polish. Your GitHub repo, with clean code and a README explaining BitGive’s mission, shows you care about presentation. Deploying to Rootstock Testnet (or even Mainnet) proves you can take a project live. And that Tailwind-styled UI? It’s proof that you think about users, not just code.
Want to level up? Add features like a charity verification system, real-time donation alerts, or integration with Chainlink for off-chain data. Each tweak makes your portfolio shine brighter. BitGive isn’t just a project; it’s a launchpad for your Web3 career. Let’s wrap this up with a final push to share your work!
Conclusion
Congratulations, you’ve done it! You’ve built BitGive, a Bitcoin-powered charity dApp that’s transparent, rewarding, and downright awesome. From coding a Solidity smart contract to styling a Next.js front-end with Tailwind CSS, you’ve brought a vision to life on Rootstock’s blockchain. Those NFTs? They’re not just tokens, they’re proof you’re making a difference while mastering Web3. I hope BitGive sparks that same fire in you.
Let’s recap what you’ve accomplished. You’ve created a platform where users donate RBTC to causes they love, tracked openly for trust. Your dynamic NFTs add a fun twist, turning generosity into collectibles. With thirdweb tying it all together, you’ve built a full-stack dApp that’s ready for the Testnet and maybe even the Mainnet if you’re feeling adventurous. This isn’t just a tutorial; it’s a project you can point to and say, “I built that.”
Now, don’t let BitGive gather dust. Push your code to GitHub and write a README that pops, explains the mission, shows off the UI, and links to your deployed dApp. Share it on Twitter or LinkedIn with hashtags like #Rootstock and #Web3; let the world see your skills. Show it to recruiters, friends, or your local crypto meetup. Better yet, keep tinkering: add a leaderboard, let charities apply to join, or spice up the NFTs with animations. The possibilities are endless.
Meanwhile, all the codes for this tutorial can be accessed in this GitHub repository https://github.com/michojekunle/BitGive/, and the live deployed link is here https://bit-give.vercel.app.
Need more fuel? Head to Rootstock’s Developer Portal for docs, tutorials, and tools. Join their Discord to connect with devs who’ll cheer you on. Rootstock hooked me with its Bitcoin-meets-Ethereum vibe, and BitGive is just the beginning. What will you build next? A DeFi app? A game? Whatever it is, you’ve got the skills now. Go make some blockchain magic!





