What you will be building, see the live demo at Bitfinity test network and the git repo.
Introduction
Welcome to this comprehensive guide where we'll build a Web3 Decentralized Charity Platform using Next.js, Solidity, and TypeScript. By the end of this tutorial, you'll have a clear understanding of:
- Building dynamic interfaces with Next.js
- Crafting Ethereum smart contracts with Solidity
- Incorporating static type checking using TypeScript
- Deploying and interacting with your smart contracts
- Understanding the fundamentals of blockchain-based charity platforms
As a bonus for partaking in this tutorial, you can be easily win a copy of our prestigious book on becoming an in-demand solidity developer. This offer is free for the first 300 people, for instruction on how to win watch the short video below.
Prerequisites
You will need the following tools installed to build along with me:
- Node.js
- Yarn
- Git Bash
- MetaMask
- Next.js
- Solidity
- Redux Toolkit
- Tailwind CSS
To set up MetaMask for this tutorial, please watch the instructional video below:
Once you have successfully completed the setup, you are eligible to receive a free copy of our book. To claim your book, please fill out the form to submit your proof-of-work.
Watch the following instructional videos to receive up to 3-months of free premium courses on Dapp Mentors Academy, including:
Start your journey with Bitfinity today. Experience a fast, simple, and hassle-free development process as you build your first Charity Donation Tracking dApp. Deploy your smart contracts to the Bitfinity network and make a positive impact.
With that said, let’s jump into the tutorial and set up our project.
Setup
We'll start by cloning a prepared frontend repository and setting up the environment variables. Run the following commands:
git clone https://github.com/Daltonic/dappFundsX
cd dappFundsX
yarn install
git checkout no_redux_no_blockchain
Next, create a .env
file at the root of the project and include the following keys:
NEXT_PUBLIC_RPC_URL=http://127.0.0.1:8545
NEXT_PUBLIC_ALCHEMY_ID=<YOUR_ALCHEMY_PROJECT_ID>
NEXT_PUBLIC_PROJECT_ID=<WALLET_CONNECT_PROJECT_ID>
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=somereallysecretsecret
Replace <YOUR_ALCHEMY_PROJECT_ID>
and <WALLET_CONNECT_PROJECT_ID>
with your respective project IDs.
YOUR_ALCHEMY_PROJECT_ID
: Get Key Here
WALLET_CONNECT_PROJECT_ID
: Get Key Here
Finally, run yarn dev
to start the project.
Our frontend for this project is solid and ready for some smart contract integration, but we will need Reduxify our application to enable a shared data space.
Building the Redux Store
The above image represents the structure of our Redux store, it will be simple since we are not creating some overly complex project.
We'll set up Redux to manage our application's global state. Follow these steps:
- Create a
store
folder at the project root. - Inside
store
, create two folders:actions
andstates
. - Inside
states
, create aglobalStates.ts
file.
import { GlobalState } from '@/utils/type.dt' | |
export const globalStates: GlobalState = { | |
charities: [], | |
charity: null, | |
supports: [], | |
deleteModal: 'scale-0', | |
donorsModal: 'scale-0', | |
supportModal: 'scale-0', | |
} |
- Inside
actions
, create aglobalActions.ts
file.
import { CharityStruct, GlobalState, SupportStruct } from '@/utils/type.dt' | |
import { PayloadAction } from '@reduxjs/toolkit' | |
export const globalActions = { | |
setCharities: (state: GlobalState, action: PayloadAction<CharityStruct[]>) => { | |
state.charities = action.payload | |
}, | |
setCharity: (state: GlobalState, action: PayloadAction<CharityStruct | null>) => { | |
state.charity = action.payload | |
}, | |
setSupports: (state: GlobalState, action: PayloadAction<SupportStruct[]>) => { | |
state.supports = action.payload | |
}, | |
setDeleteModal: (state: GlobalState, action: PayloadAction<string>) => { | |
state.deleteModal = action.payload | |
}, | |
setDonorModal: (state: GlobalState, action: PayloadAction<string>) => { | |
state.donorsModal = action.payload | |
}, | |
setSupportModal: (state: GlobalState, action: PayloadAction<string>) => { | |
state.supportModal = action.payload | |
}, | |
} |
- Create a
globalSlices.ts
file inside thestore
folder.
import { createSlice } from '@reduxjs/toolkit' | |
import { globalStates as GlobalStates } from './states/globalStates' | |
import { globalActions as GlobalActions } from './actions/globalActions' | |
export const globalSlices = createSlice({ | |
name: 'global', | |
initialState: GlobalStates, | |
reducers: GlobalActions, | |
}) | |
export const globalActions = globalSlices.actions | |
export default globalSlices.reducer |
- Create an
index.ts
file inside thestore
folder.
import { configureStore } from '@reduxjs/toolkit' | |
import globalSlices from './globalSlices' | |
export const store = configureStore({ | |
reducer: { | |
globalStates: globalSlices, | |
}, | |
}) |
- Update the
pages/_app.ts
file with the Redux provider.
import { ToastContainer } from 'react-toastify' | |
import '@/styles/global.css' | |
import 'react-toastify/dist/ReactToastify.css' | |
import '@rainbow-me/rainbowkit/styles.css' | |
import { useEffect, useState } from 'react' | |
import Providers from '@/services/provider' | |
import type { AppProps } from 'next/app' | |
import Header from '@/components/Header' | |
import Footer from '@/components/Footer' | |
import { Provider } from 'react-redux' | |
import { store } from '@/store' | |
export default function App({ Component, pageProps }: AppProps) { | |
const [showChild, setShowChild] = useState<boolean>(false) | |
useEffect(() => { | |
setShowChild(true) | |
}, []) | |
if (!showChild || typeof window === 'undefined') { | |
return null | |
} else { | |
return ( | |
<Providers pageProps={pageProps}> | |
<Provider store={store}> | |
<div className="min-h-screen relative"> | |
<Header /> | |
<Component {...pageProps} /> | |
<Footer /> | |
<ToastContainer | |
position="bottom-center" | |
autoClose={5000} | |
hideProgressBar={false} | |
newestOnTop={false} | |
closeOnClick | |
rtl={false} | |
pauseOnFocusLoss | |
draggable | |
pauseOnHover | |
theme="dark" | |
/> | |
</div> | |
</Provider> | |
</Providers> | |
) | |
} | |
} |
Our application has been wrapped with Redux toolkit, and we will revisit Redux when integrating the backend with the frontend.
Smart Contract Development
Next, we'll develop the smart contract for our platform:
- Create a
contracts
folder at the project root. - Inside
contracts
, create aDappFund.sol
file and add the contract code below.
//SPDX-License-Identifier: MIT | |
pragma solidity >=0.7.0 <0.9.0; | |
import '@openzeppelin/contracts/access/Ownable.sol'; | |
import '@openzeppelin/contracts/utils/Counters.sol'; | |
import '@openzeppelin/contracts/access/AccessControl.sol'; | |
contract DappFund is Ownable, AccessControl { | |
using Counters for Counters.Counter; | |
Counters.Counter private _totalCharities; | |
Counters.Counter private _totalDonation; | |
uint256 public charityTax; | |
mapping(uint256 => CharityStruct) charities; | |
mapping(uint256 => SupportStruct[]) supportersOf; | |
mapping(uint256 => bool) public charityExist; | |
struct CharityStruct { | |
uint256 id; | |
address owner; | |
string name; | |
string fullname; | |
string description; | |
string image; | |
string profile; | |
uint256 amount; | |
uint256 donations; | |
uint256 raised; | |
uint256 timestamp; | |
bool deleted; | |
bool banned; | |
} | |
struct SupportStruct { | |
uint256 id; | |
uint256 cid; | |
string fullname; | |
uint256 amount; | |
uint256 timestamp; | |
string comment; | |
address supporter; | |
} | |
constructor(uint256 _charityTax) { | |
charityTax = _charityTax; | |
} | |
function createCharity( | |
string memory name, | |
string memory fullname, | |
string memory profile, | |
string memory description, | |
string memory image, | |
uint256 amount | |
) public { | |
require(bytes(name).length > 0, 'Name cannot be empty'); | |
require(bytes(fullname).length > 0, 'Fullname cannot be empty'); | |
require(bytes(description).length > 0, 'Description cannot be empty'); | |
require(bytes(profile).length > 0, 'Profile cannot be empty'); | |
require(bytes(image).length > 0, 'Image cannot be empty'); | |
require(amount > 0 ether, 'Amount cannot be zero'); | |
_totalCharities.increment(); | |
CharityStruct memory charity; | |
charity.id = _totalCharities.current(); | |
charity.owner = msg.sender; | |
charity.name = name; | |
charity.fullname = fullname; | |
charity.description = description; | |
charity.image = image; | |
charity.profile = profile; | |
charity.amount = amount; | |
charity.timestamp = currentTime(); | |
charities[charity.id] = charity; | |
charityExist[charity.id] = true; | |
} | |
function updateCharity( | |
uint256 id, | |
string memory name, | |
string memory fullname, | |
string memory profile, | |
string memory description, | |
string memory image, | |
uint256 amount | |
) public { | |
require(charityExist[id], 'Charity Not Found'); | |
require(msg.sender == charities[id].owner, 'Unauthorized Entity'); | |
require(bytes(name).length > 0, 'Name cannot be empty'); | |
require(bytes(fullname).length > 0, 'Fullname cannot be empty'); | |
require(bytes(description).length > 0, 'Description cannot be empty'); | |
require(bytes(profile).length > 0, 'Profile cannot be empty'); | |
require(bytes(image).length > 0, 'Image cannot be empty'); | |
require(amount > 0 ether, 'Amount cannot be zero'); | |
charities[id].name = name; | |
charities[id].fullname = fullname; | |
charities[id].image = image; | |
charities[id].amount = amount; | |
charities[id].profile = profile; | |
charities[id].description = description; | |
} | |
function deleteCharity(uint256 id) public { | |
require(charityExist[id], 'Charity Not Found'); | |
require(msg.sender == charities[id].owner, 'Unauthorized Entity'); | |
charities[id].deleted = true; | |
} | |
function toggleBan(uint256 id) public onlyOwner { | |
require(charityExist[id], 'Charity Not Found'); | |
charities[id].banned = !charities[id].banned; | |
} | |
function donate(uint256 id, string memory fullname, string memory comment) public payable { | |
require(charityExist[id], 'Charity Not Found'); | |
require(!charities[id].banned, 'Charity Banned, contact admin'); | |
require(msg.value > 0 ether, 'Donation cannot be zero'); | |
require(charities[id].raised < charities[id].amount, 'Charity budget fulfilled'); | |
_totalDonation.increment(); | |
SupportStruct memory support; | |
support.id = _totalDonation.current(); | |
support.cid = id; | |
support.fullname = fullname; | |
support.supporter = msg.sender; | |
support.amount = msg.value; | |
support.comment = comment; | |
support.timestamp = currentTime(); | |
supportersOf[id].push(support); | |
charities[id].raised += msg.value; | |
charities[id].donations += 1; | |
uint256 fee = (msg.value * charityTax) / 100; | |
uint256 payment = msg.value - fee; | |
payTo(charities[id].owner, payment); | |
payTo(owner(), fee); | |
} | |
function changeTax(uint256 _taxPct) public onlyOwner { | |
require(_taxPct > 0 && _taxPct <= 100, 'Percent must be between 0 - 100'); | |
charityTax = _taxPct; | |
} | |
function getCharity(uint256 id) public view returns (CharityStruct memory) { | |
return charities[id]; | |
} | |
function getCharities() public view returns (CharityStruct[] memory Charities) { | |
uint256 available; | |
for (uint i = 1; i <= _totalCharities.current(); i++) { | |
if ( | |
!charities[i].deleted && !charities[i].banned && charities[i].raised < charities[i].amount | |
) { | |
available++; | |
} | |
} | |
Charities = new CharityStruct[](available); | |
uint256 index; | |
for (uint i = 1; i <= _totalCharities.current(); i++) { | |
if ( | |
!charities[i].deleted && !charities[i].banned && charities[i].raised < charities[i].amount | |
) { | |
Charities[index++] = charities[i]; | |
} | |
} | |
} | |
function getMyCharities() public view returns (CharityStruct[] memory Charities) { | |
uint256 available; | |
for (uint i = 1; i <= _totalCharities.current(); i++) { | |
if ( | |
!charities[i].deleted && | |
charities[i].raised < charities[i].amount && | |
charities[i].owner == msg.sender | |
) { | |
available++; | |
} | |
} | |
Charities = new CharityStruct[](available); | |
uint256 index; | |
for (uint i = 1; i <= _totalCharities.current(); i++) { | |
if ( | |
!charities[i].deleted && | |
charities[i].raised < charities[i].amount && | |
charities[i].owner == msg.sender | |
) { | |
Charities[index++] = charities[i]; | |
} | |
} | |
} | |
function getSupports(uint256 id) public view returns (SupportStruct[] memory) { | |
return supportersOf[id]; | |
} | |
function payTo(address to, uint256 amount) internal { | |
(bool success, ) = payable(to).call{ value: amount }(''); | |
require(success); | |
} | |
function currentTime() internal view returns (uint256) { | |
return (block.timestamp * 1000) + 1000; | |
} | |
} |
The DappFund
contract will facilitate the creation, update, and donation to charities, as well as administrative functions like changing the charity tax and banning a charity.
Here is a function-by-function breakdown:
constructor()
: This function sets the initial charity tax when the contract is deployed. It's executed only once during the contract's deployment.createCharity()
: This function allows users to create a new charity. It requires several parameters such as the name, description, image, profile, and amount of the charity. It checks that these parameters are valid and then creates a newCharityStruct
and adds it to thecharities
mapping.updateCharity()
: This function allows the owner of a charity to update its details. It checks that the charity exists and that the sender is the owner of the charity before updating the charity's details.deleteCharity()
: This function allows the owner of a charity to mark it as deleted. It checks that the charity exists and that the sender is the owner of the charity before marking it as deleted.toggleBan()
: This function allows the contract owner to ban or unban a charity. It checks that the charity exists before toggling its banned status.donate()
: This function allows users to donate to a charity. It checks that the charity exists, is not banned, and has not yet reached its fundraising goal. It then increments the total donation count, creates a newSupportStruct
, and adds it to thesupportersOf
mapping. It also updates the raised amount and donation count of the charity.changeTax()
: This function allows the contract owner to change the charity tax. It checks that the new tax percentage is valid before updating the tax.getCharity()
: This function allows anyone to get the details of a charity. It returns theCharityStruct
associated with the given ID.getCharities()
: This function allows anyone to get the details of all active charities. It returns an array ofCharityStruct
objects.getMyCharities()
: This function allows a user to get the details of all their active charities. It returns an array ofCharityStruct
objects.getSupports()
: This function allows anyone to get the details of all supporters of a specific charity. It returns an array ofSupportStruct
objects.payTo()
: This internal function is used to transfer funds. It sends the specified amount of Ether to the given address.currentTime()
: This internal function returns the current time in seconds since the Unix Epoch. It's used to timestamp donations and charity creations.
Contract Deployment and Seeding
Now, let's deploy our smart contract and populate it with some dummy data:
- Create a
scripts
folder at the project root. - Inside
scripts
, create adeploy.js
and aseed.js
file and add the following codes.
Deploy Script
const { ethers } = require('hardhat') | |
const fs = require('fs') | |
async function deployContract() { | |
let contract | |
const taxPct = 5 | |
try { | |
contract = await ethers.deployContract('DappFund', [taxPct]) | |
await contract.waitForDeployment() | |
console.log('Contracts deployed successfully.') | |
return contract | |
} catch (error) { | |
console.error('Error deploying contracts:', error) | |
throw error | |
} | |
} | |
async function saveContractAddress(contract) { | |
try { | |
const address = JSON.stringify( | |
{ | |
dappFundContract: contract.target, | |
}, | |
null, | |
4 | |
) | |
fs.writeFile('./contracts/contractAddress.json', address, 'utf8', (error) => { | |
if (error) { | |
console.error('Error saving contract address:', err) | |
} else { | |
console.log('Deployed contract address:', address) | |
} | |
}) | |
} catch (error) { | |
console.error('Error saving contract address:', error) | |
throw error | |
} | |
} | |
async function main() { | |
let contract | |
try { | |
contract = await deployContract() | |
await saveContractAddress(contract) | |
console.log('Contract deployment completed successfully.') | |
} catch (error) { | |
console.error('Unhandled error:', error) | |
} | |
} | |
main().catch((error) => { | |
console.error('Unhandled error:', error) | |
process.exitCode = 1 | |
}) |
Seed Script
const { faker } = require('@faker-js/faker') | |
const { ethers } = require('hardhat') | |
const fs = require('fs') | |
const toWei = (num) => ethers.parseEther(num.toString()) | |
const charitiesCount = 1 | |
const generateCharities = (count) => { | |
const charities = [] | |
for (let i = 0; i < count; i++) { | |
const charity = { | |
id: i + 1, | |
fullname: faker.word.words(2), | |
name: faker.word.words(5), | |
profile: faker.internet.url(), | |
image: faker.image.urlPicsumPhotos(), | |
description: faker.lorem.paragraph(), | |
timestamp: faker.date.past().getTime(), | |
deleted: faker.datatype.boolean(), | |
banned: faker.datatype.boolean(), | |
donations: faker.number.int({ min: 1, max: 100 }), | |
raised: faker.number.float({ min: 10, max: 15 }), | |
amount: faker.number.float({ min: 10, max: 20 }), | |
owner: faker.string.hexadecimal({ | |
length: { min: 42, max: 42 }, | |
prefix: '0x', | |
}), | |
} | |
charities.push(charity) | |
} | |
return charities | |
} | |
const generateSupports = (count) => { | |
const supports = [] | |
for (let i = 0; i < count; i++) { | |
const support = { | |
id: i + 1, | |
cid: faker.number.int({ min: 1, max: 100 }), | |
fullname: faker.datatype.boolean() ? faker.person.firstName() : '', | |
comment: faker.lorem.paragraph(), | |
timestamp: faker.date.past().getTime(), | |
amount: faker.number.float({ min: 0.01, max: 4 }), | |
supporter: faker.string.hexadecimal({ | |
length: { min: 42, max: 42 }, | |
prefix: '0x', | |
}), | |
} | |
supports.push(support) | |
} | |
return supports | |
} | |
async function createCharity(contract, charity) { | |
const tx = await contract.createCharity( | |
charity.name, | |
charity.fullname, | |
charity.profile, | |
charity.description, | |
charity.image, | |
toWei(charity.amount) | |
) | |
await tx.wait() | |
} | |
async function makeDonations(contract, id, donation) { | |
const tx = await contract.donate(id, donation.fullname, donation.comment, { | |
value: toWei(donation.amount), | |
}) | |
await tx.wait() | |
} | |
async function getCharities(contract) { | |
const result = await contract.getCharities() | |
console.log('Charities:', result) | |
} | |
async function getSupports(contract, id) { | |
const result = await contract.getSupports(id) | |
console.log('Supports:', result) | |
} | |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) | |
async function main() { | |
let dappFundContract | |
try { | |
const contractAddresses = fs.readFileSync('./contracts/contractAddress.json', 'utf8') | |
const { dappFundContract: dappFundAddress } = JSON.parse(contractAddresses) | |
dappFundContract = await ethers.getContractAt('DappFund', dappFundAddress) | |
// Process #1 | |
await Promise.all( | |
generateCharities(charitiesCount).map(async (charity) => { | |
await createCharity(dappFundContract, charity) | |
}) | |
) | |
await delay(2500) // Wait for 2.5 seconds | |
// Process #2 | |
await Promise.all( | |
Array(charitiesCount) | |
.fill() | |
.map(async (_, i) => { | |
const randomCount = faker.number.int({ min: 1, max: 2 }) | |
const supports = generateSupports(randomCount) | |
await Promise.all( | |
supports.map(async (donation, i) => { | |
await makeDonations(dappFundContract, i + 1, donation, { | |
value: toWei(donation.amount), | |
}) | |
}) | |
) | |
}) | |
) | |
await delay(2500) // Wait for 2.5 seconds | |
// Process #3 | |
await getCharities(dappFundContract) | |
await getSupports(dappFundContract, 1) | |
} catch (error) { | |
console.error('Unhandled error:', error) | |
} | |
} | |
main().catch((error) => { | |
console.error('Unhandled error:', error) | |
process.exitCode = 1 | |
}) |
-
Run the following commands to deploy the contract and seed it with data:
yarn hardhat node # Run in terminal 1
yarn hardhat run scripts/deploy.js # Run in terminal 2
yarn hardhat run scripts/seed.js # Run in terminal 2
If you did that correctly, you should see a similar output like the one below:
At this point we can start the integration of our smart contract to our frontend.
Frontend Integration
First, create a services
folder at the project root, and inside it, create a blockchain.tsx
file. This file will contain functions to interact with our smart contract.
import { ethers } from 'ethers' | |
import address from '@/contracts/contractAddress.json' | |
import dappFundAbi from '@/artifacts/contracts/DappFund.sol/DappFund.json' | |
import { globalActions } from '@/store/globalSlices' | |
import { store } from '@/store' | |
import { CharityParams, CharityStruct, DonorParams, SupportStruct } from '@/utils/type.dt' | |
const toWei = (num: number) => ethers.parseEther(num.toString()) | |
const fromWei = (num: number) => ethers.formatEther(num) | |
const { setSupports, setCharity } = globalActions | |
let ethereum: any | |
let tx: any | |
if (typeof window !== 'undefined') ethereum = (window as any).ethereum | |
const getEthereumContracts = async () => { | |
const accounts = await ethereum?.request?.({ method: 'eth_accounts' }) | |
if (accounts?.length > 0) { | |
const provider = new ethers.BrowserProvider(ethereum) | |
const signer = await provider.getSigner() | |
const contracts = new ethers.Contract(address.dappFundContract, dappFundAbi.abi, signer) | |
return contracts | |
} else { | |
const provider = new ethers.JsonRpcProvider(process.env.NEXT_PUBLIC_RPC_URL) | |
const wallet = ethers.Wallet.createRandom() | |
const signer = wallet.connect(provider) | |
const contracts = new ethers.Contract(address.dappFundContract, dappFundAbi.abi, signer) | |
return contracts | |
} | |
} | |
const getAdmin = async (): Promise<string> => { | |
const contract = await getEthereumContracts() | |
const owner = await contract.owner() | |
return owner | |
} | |
const getCharities = async (): Promise<CharityStruct[]> => { | |
const contract = await getEthereumContracts() | |
const charities = await contract.getCharities() | |
return structuredCharities(charities) | |
} | |
const getMyCharities = async (): Promise<CharityStruct[]> => { | |
const contract = await getEthereumContracts() | |
const charities = await contract.getMyCharities() | |
return structuredCharities(charities) | |
} | |
const getCharity = async (id: number): Promise<CharityStruct> => { | |
const contract = await getEthereumContracts() | |
const charity = await contract.getCharity(id) | |
return structuredCharities([charity])[0] | |
} | |
const getSupporters = async (id: number): Promise<SupportStruct[]> => { | |
const contract = await getEthereumContracts() | |
const supporters = await contract.getSupports(id) | |
return structuredSupporters(supporters) | |
} | |
const createCharity = async (charity: CharityParams): Promise<void> => { | |
if (!ethereum) { | |
reportError('Please install a browser provider') | |
return Promise.reject(new Error('Browser provider not installed')) | |
} | |
try { | |
const contract = await getEthereumContracts() | |
tx = await contract.createCharity( | |
charity.name, | |
charity.fullname, | |
charity.profile, | |
charity.description, | |
charity.image, | |
toWei(Number(charity.amount)) | |
) | |
await tx.wait() | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const updateCharity = async (charity: CharityParams): Promise<void> => { | |
if (!ethereum) { | |
reportError('Please install a browser provider') | |
return Promise.reject(new Error('Browser provider not installed')) | |
} | |
try { | |
const contract = await getEthereumContracts() | |
tx = await contract.updateCharity( | |
charity.id, | |
charity.name, | |
charity.fullname, | |
charity.profile, | |
charity.description, | |
charity.image, | |
toWei(Number(charity.amount)) | |
) | |
await tx.wait() | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const makeDonation = async (donor: DonorParams): Promise<void> => { | |
if (!ethereum) { | |
reportError('Please install a browser provider') | |
return Promise.reject(new Error('Browser provider not installed')) | |
} | |
try { | |
const contract = await getEthereumContracts() | |
tx = await contract.donate(donor.id, donor.fullname, donor.comment, { | |
value: toWei(Number(donor.amount)), | |
}) | |
await tx.wait() | |
const supports = await getSupporters(Number(donor.id)) | |
store.dispatch(setSupports(supports)) | |
const charity = await getCharity(Number(donor.id)) | |
store.dispatch(setCharity(charity)) | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const deleteCharity = async (id: number): Promise<void> => { | |
if (!ethereum) { | |
reportError('Please install a browser provider') | |
return Promise.reject(new Error('Browser provider not installed')) | |
} | |
try { | |
const contract = await getEthereumContracts() | |
tx = await contract.deleteCharity(id) | |
await tx.wait() | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const banCharity = async (id: number): Promise<void> => { | |
if (!ethereum) { | |
reportError('Please install a browser provider') | |
return Promise.reject(new Error('Browser provider not installed')) | |
} | |
try { | |
const contract = await getEthereumContracts() | |
tx = await contract.toggleBan(id) | |
await tx.wait() | |
const charity = await getCharity(Number(id)) | |
store.dispatch(setCharity(charity)) | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const structuredCharities = (charities: CharityStruct[]): CharityStruct[] => | |
charities | |
.map((charity) => ({ | |
id: Number(charity.id), | |
name: charity.name, | |
fullname: charity.fullname, | |
image: charity.image, | |
profile: charity.profile, | |
donations: Number(charity.donations), | |
raised: parseFloat(fromWei(charity.raised)), | |
amount: parseFloat(fromWei(charity.amount)), | |
owner: charity.owner, | |
description: charity.description, | |
timestamp: Number(charity.timestamp), | |
deleted: charity.deleted, | |
banned: charity.banned, | |
})) | |
.sort((a, b) => b.timestamp - a.timestamp) | |
const structuredSupporters = (supports: SupportStruct[]): SupportStruct[] => | |
supports | |
.map((support) => ({ | |
id: Number(support.id), | |
cid: Number(support.cid), | |
fullname: support.fullname, | |
amount: parseFloat(fromWei(support.amount)), | |
supporter: support.supporter, | |
comment: support.comment, | |
timestamp: Number(support.timestamp), | |
})) | |
.sort((a, b) => b.timestamp - a.timestamp) | |
export { | |
getCharities, | |
getCharity, | |
getMyCharities, | |
getSupporters, | |
createCharity, | |
updateCharity, | |
makeDonation, | |
deleteCharity, | |
banCharity, | |
getAdmin, | |
} |
The provided code is a TypeScript implementation of our blockchain service for interacting with our smart contract. This service enables users to perform actions on our charity platform, including creating and updating charities, making donations, and more.
Here is a function-by-function breakdown:
getEthereumContracts()
: This function gets the Ethereum contract instances that the service will interact with. It checks if the user has a connected Ethereum account and uses that to interact with the contract, otherwise, it uses a randomly created wallet.getAdmin()
: This function returns the owner of the contract.getCharities()
,getMyCharities()
,getCharity()
: These functions fetch all charities, the charities created by the current user, and a specific charity by its ID, respectively.getSupporters()
: This function fetches the supporters of a specific charity by its ID.createCharity()
,updateCharity()
: These functions allow a user to create a new charity or update an existing charity, respectively.makeDonation()
: This function allows a user to make a donation to a specific charity.deleteCharity()
: This function allows the owner of a charity to delete it.banCharity()
: This function allows the contract owner to ban a charity.structuredCharities()
,structuredSupporters()
: These are helper functions that structure the returned data from the contract into a more usable format.
Update the provider.tsx
file inside services
to include the bitfinity
network using the following codes.
'use client' | |
import * as React from 'react' | |
import { | |
GetSiweMessageOptions, | |
RainbowKitSiweNextAuthProvider, | |
} from '@rainbow-me/rainbowkit-siwe-next-auth' | |
import { WagmiConfig, configureChains, createConfig } from 'wagmi' | |
import { Chain, RainbowKitProvider, connectorsForWallets, darkTheme } from '@rainbow-me/rainbowkit' | |
import { | |
metaMaskWallet, | |
trustWallet, | |
coinbaseWallet, | |
rainbowWallet, | |
} from '@rainbow-me/rainbowkit/wallets' | |
import { mainnet, hardhat } from 'wagmi/chains' | |
import { alchemyProvider } from 'wagmi/providers/alchemy' | |
import { publicProvider } from 'wagmi/providers/public' | |
import { Session } from 'next-auth' | |
import { SessionProvider } from 'next-auth/react' | |
const bitfinity: Chain = { | |
id: 355113, | |
name: 'Bitfinity', | |
network: 'bitfinity', | |
iconUrl: 'https://bitfinity.network/logo.png', | |
iconBackground: '#000000', | |
nativeCurrency: { | |
decimals: 18, | |
name: 'Bitfinity', | |
symbol: 'BFT', | |
}, | |
rpcUrls: { | |
public: { http: ['https://testnet.bitfinity.network'] }, | |
default: { http: ['https://testnet.bitfinity.network'] }, | |
}, | |
blockExplorers: { | |
default: { name: 'Bitfinity Block Explorer', url: 'https://explorer.bitfinity.network/' }, | |
etherscan: { name: 'Bitfinity Block Explorer', url: 'https://explorer.bitfinity.network/' }, | |
}, | |
testnet: true, | |
} | |
const { chains, publicClient } = configureChains( | |
[mainnet, bitfinity, hardhat], | |
[alchemyProvider({ apiKey: process.env.NEXT_PUBLIC_ALCHEMY_ID as string }), publicProvider()] | |
) | |
const projectId = process.env.NEXT_PUBLIC_PROJECT_ID as string | |
const connectors = connectorsForWallets([ | |
{ | |
groupName: 'Recommended', | |
wallets: [ | |
metaMaskWallet({ projectId, chains }), | |
trustWallet({ projectId, chains }), | |
coinbaseWallet({ appName: 'Coinbase', chains }), | |
rainbowWallet({ projectId, chains }), | |
], | |
}, | |
]) | |
const wagmiConfig = createConfig({ | |
autoConnect: true, | |
connectors, | |
publicClient, | |
}) | |
const demoAppInfo = { | |
appName: 'Dapp Funds dApp', | |
} | |
const getSiweMessageOptions: GetSiweMessageOptions = () => ({ | |
statement: ` | |
Once you're signed in, you'll be able to access all of our dApp's features. | |
Thank you for partnering with CrowdFunding!`, | |
}) | |
export function Providers({ | |
children, | |
pageProps, | |
}: { | |
children: React.ReactNode | |
pageProps: { | |
session: Session | |
} | |
}) { | |
const [mounted, setMounted] = React.useState(false) | |
React.useEffect(() => setMounted(true), []) | |
return ( | |
<WagmiConfig config={wagmiConfig}> | |
<SessionProvider refetchInterval={0} session={pageProps.session}> | |
<RainbowKitSiweNextAuthProvider getSiweMessageOptions={getSiweMessageOptions}> | |
<RainbowKitProvider theme={darkTheme()} chains={chains} appInfo={demoAppInfo}> | |
{mounted && children} | |
</RainbowKitProvider> | |
</RainbowKitSiweNextAuthProvider> | |
</SessionProvider> | |
</WagmiConfig> | |
) | |
} |
Page Interacting with Smart Contract
Next, we'll link the functions in the blockchain service to their respective interfaces in the frontend:
No 1: Displaying all Charities
Update pages/index.tsx
to get data from the getCharities()
function.
import Banner from '@/components/Banner' | |
import Cards from '@/components/Cards' | |
import NavBtn from '@/components/NavBtn' | |
import Quote from '@/components/Quote' | |
import Start from '@/components/Start' | |
import { getCharities } from '@/services/blockchain' | |
import { globalActions } from '@/store/globalSlices' | |
import { generateCharities } from '@/utils/fakeData' | |
import { CharityStruct, RootState } from '@/utils/type.dt' | |
import { NextPage } from 'next' | |
import Head from 'next/head' | |
import { useEffect } from 'react' | |
import { useDispatch, useSelector } from 'react-redux' | |
const Page: NextPage<{ charitiesData: CharityStruct[] }> = ({ charitiesData }) => { | |
const { charities } = useSelector((states: RootState) => states.globalStates) | |
const dispatch = useDispatch() | |
const { setCharities } = globalActions | |
useEffect(() => { | |
dispatch(setCharities(charitiesData)) | |
}, [dispatch, setCharities, charitiesData]) | |
return ( | |
<div> | |
<Head> | |
<title>Charity Tracker</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<Banner /> | |
<div className="h-10"></div> | |
<Cards charities={charities} /> | |
<div className="h-10"></div> | |
<Quote /> | |
<div className="h-10"></div> | |
<Start /> | |
<div className="h-10"></div> | |
<NavBtn /> | |
</div> | |
) | |
} | |
export default Page | |
export const getServerSideProps = async () => { | |
const charitiesData: CharityStruct[] = await getCharities() | |
return { | |
props: { charitiesData: JSON.parse(JSON.stringify(charitiesData)) }, | |
} | |
} |
Notice how Redux is used to store blockchain data before it is displayed on the screen.
No 2: Displaying User’s Charities
Update pages/projects.tsx
to use the useEffect()
hook to get the current user’s charities.
import Banner from '@/components/Banner' | |
import Cards from '@/components/Cards' | |
import NavBtn from '@/components/NavBtn' | |
import { getMyCharities } from '@/services/blockchain' | |
import { globalActions } from '@/store/globalSlices' | |
import { CharityStruct, RootState } from '@/utils/type.dt' | |
import { NextPage } from 'next' | |
import Head from 'next/head' | |
import { useEffect } from 'react' | |
import { useDispatch, useSelector } from 'react-redux' | |
const Page: NextPage = () => { | |
const { charities } = useSelector((states: RootState) => states.globalStates) | |
const dispatch = useDispatch() | |
const { setCharities } = globalActions | |
useEffect(() => { | |
const fetchCharities = async () => { | |
const charitiesData: CharityStruct[] = await getMyCharities() | |
dispatch(setCharities(charitiesData)) | |
} | |
fetchCharities() | |
}, [dispatch, setCharities]) | |
return ( | |
<div> | |
<Head> | |
<title>Charity Tracker</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<Banner mine /> | |
<div className="h-10"></div> | |
<Cards charities={charities} /> | |
<div className="h-10"></div> | |
<NavBtn /> | |
</div> | |
) | |
} | |
export default Page |
Please note the use of the useEffect()
hook to retrieve the current user's charities and how Redux is used to handle and display data from the blockchain across multiple components. This will be a repeated pattern across the pages and components.
No 3: Creating New Charities
Update pages/donations/create.tsx
to use the createCharity()
function for form submission.
import NavBtn from '@/components/NavBtn' | |
import { createCharity } from '@/services/blockchain' | |
import { CharityParams } from '@/utils/type.dt' | |
import { NextPage } from 'next' | |
import Head from 'next/head' | |
import { ChangeEvent, FormEvent, useState } from 'react' | |
import { toast } from 'react-toastify' | |
import { useAccount } from 'wagmi' | |
const Page: NextPage = () => { | |
const { address } = useAccount() | |
const [charity, setCharity] = useState<CharityParams>({ | |
name: '', | |
fullname: '', | |
profile: '', | |
amount: '', | |
description: '', | |
image: '', | |
}) | |
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
const { name, value } = e.target | |
setCharity((prevState) => ({ | |
...prevState, | |
[name]: value, | |
})) | |
} | |
const handleSubmit = async (e: FormEvent) => { | |
e.preventDefault() | |
if ( | |
!charity.name || | |
!charity.fullname || | |
!charity.profile || | |
!charity.amount || | |
!charity.description | |
) | |
return | |
if (!address) return toast.warning('Connect wallet first!') | |
await toast.promise( | |
new Promise<void>((resolve, reject) => { | |
createCharity(charity) | |
.then((tx) => { | |
console.log(tx) | |
resetForm() | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Charity created successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const resetForm = () => { | |
setCharity({ | |
name: '', | |
fullname: '', | |
profile: '', | |
amount: '', | |
description: '', | |
image: '', | |
}) | |
} | |
return ( | |
<div> | |
<Head> | |
<title>Charity Create</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<div className="flex flex-col w-full sm:w-4/5 py-4 px-4 sm:px-0 mx-auto"> | |
<div className="block justify-center items-center m-auto w-full sm:w-3/5"> | |
<form onSubmit={handleSubmit} className="flex flex-col space-y-4"> | |
<div className="flex items-center justify-center mb-4"> | |
<h2>Create Charity</h2> | |
</div> | |
<div className="flex justify-between items-center flex-col sm:flex-row sm:space-x-4 space-y-4 sm:space-y-0"> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="name" | |
placeholder="Charity Name" | |
required | |
value={charity.name} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="fullname" | |
placeholder="Your Full Name" | |
required | |
value={charity.fullname} | |
onChange={handleChange} | |
/> | |
</div> | |
</div> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
step={0.01} | |
min={0.01} | |
name="amount" | |
placeholder="Amount e.g. 0.02 ETH" | |
required | |
value={charity.amount} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="flex justify-between items-center flex-col sm:flex-row sm:space-x-4 space-y-4 sm:space-y-0"> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="image" | |
placeholder="Image URL" | |
pattern="https?://.+(\.(jpg|png|gif))?$" | |
title="Please enter a valid image URL (http(s)://...(.png|.jpg|.jpeg|.gif))" | |
required | |
value={charity.image} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="profile" | |
placeholder="Your LinkedIn Profile" | |
required | |
value={charity.profile} | |
onChange={handleChange} | |
/> | |
</div> | |
</div> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<textarea | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
placeholder="Description" | |
name="description" | |
required | |
value={charity.description} | |
onChange={handleChange} | |
></textarea> | |
</div> | |
<div className=""> | |
<button | |
className="text-white text-md bg-green-600 py-3 px-8 rounded-full | |
drop-shadow-xl border border-transparent hover:bg-transparent hover:border-green-600 | |
hover:text-green-600 focus:outline-none mt-5" | |
> | |
Create & List | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<NavBtn /> | |
</div> | |
) | |
} | |
export default Page |
No 4: Displaying Single Charity
Update pages/donations/[id].tsx
to use the getServerSideProps()
, getCharity()
and getSupporters()
functions to retrieve charity and supporters by Id.
import Delete from '@/components/Delete' | |
import Details from '@/components/Details' | |
import Supports from '@/components/Supports' | |
import NavBtn from '@/components/NavBtn' | |
import Payment from '@/components/Payment' | |
import { globalActions } from '@/store/globalSlices' | |
import { CharityStruct, RootState, SupportStruct } from '@/utils/type.dt' | |
import { GetServerSidePropsContext, NextPage } from 'next' | |
import Head from 'next/head' | |
import { useRouter } from 'next/router' | |
import { useEffect } from 'react' | |
import { useDispatch, useSelector } from 'react-redux' | |
import Donor from '@/components/Donor' | |
import { getAdmin, getCharity, getSupporters } from '@/services/blockchain' | |
import Ban from '@/components/Ban' | |
interface PageProps { | |
charityData: CharityStruct | |
supportsData: SupportStruct[] | |
owner: string | |
} | |
const Page: NextPage<PageProps> = ({ charityData, supportsData, owner }) => { | |
const { charity, supports } = useSelector((states: RootState) => states.globalStates) | |
const dispatch = useDispatch() | |
const { setCharity, setSupports, setOwner } = globalActions | |
useEffect(() => { | |
dispatch(setOwner(owner)) | |
dispatch(setCharity(charityData)) | |
dispatch(setSupports(supportsData)) | |
}, [dispatch, setCharity, charityData, setSupports, supportsData]) | |
const router = useRouter() | |
const { id } = router.query | |
return ( | |
<div> | |
<Head> | |
<title>Charity | {charity?.name}</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
{charity && ( | |
<div | |
className="flex flex-col sm:flex-row sm:justify-between items-start | |
lg:w-2/3 w-full mx-auto space-y-4 sm:space-y-0 sm:space-x-10 my-10 px-8 sm:px-0" | |
> | |
<Details supports={supports} charity={charity} /> | |
<Payment owner={owner} supports={supports.slice(0, 4)} charity={charity} /> | |
</div> | |
)} | |
{charity && ( | |
<> | |
<Delete charity={charity} /> | |
<Donor charity={charity} /> | |
<Ban charity={charity} /> | |
<Supports supports={supports} /> | |
<NavBtn owner={charity?.owner} donationId={Number(id)} /> | |
</> | |
)} | |
</div> | |
) | |
} | |
export default Page | |
export const getServerSideProps = async (context: GetServerSidePropsContext) => { | |
const { id } = context.query | |
const owner: string = await getAdmin() | |
const charityData: CharityStruct = await getCharity(Number(id)) | |
const supportsData: SupportStruct[] = await getSupporters(Number(id)) | |
return { | |
props: { | |
owner: JSON.parse(JSON.stringify(owner)), | |
charityData: JSON.parse(JSON.stringify(charityData)), | |
supportsData: JSON.parse(JSON.stringify(supportsData)), | |
}, | |
} | |
} |
No 5: Editing Existing Charities
Update pages/donations/edit/[id].tsx
to use the getCharity()
function to retrieve charity by Id and populate the form fields.
import NavBtn from '@/components/NavBtn' | |
import { getCharity, updateCharity } from '@/services/blockchain' | |
import { CharityParams, CharityStruct } from '@/utils/type.dt' | |
import { GetServerSidePropsContext, NextPage } from 'next' | |
import Head from 'next/head' | |
import { useRouter } from 'next/router' | |
import { ChangeEvent, FormEvent, useState } from 'react' | |
import { toast } from 'react-toastify' | |
import { useAccount } from 'wagmi' | |
const Page: NextPage<{ charityData: CharityStruct }> = ({ charityData }) => { | |
const { address } = useAccount() | |
const [charity, setCharity] = useState<CharityParams>(charityData) | |
const router = useRouter() | |
const { id } = router.query | |
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
const { name, value } = e.target | |
setCharity((prevState) => ({ | |
...prevState, | |
[name]: value, | |
})) | |
} | |
const handleSubmit = async (e: FormEvent) => { | |
e.preventDefault() | |
if ( | |
!charity.name || | |
!charity.fullname || | |
!charity.profile || | |
!charity.amount || | |
!charity.description | |
) | |
return | |
if (!address) return toast.warning('Connect wallet first!') | |
await toast.promise( | |
new Promise<void>((resolve, reject) => { | |
updateCharity(charity) | |
.then((tx) => { | |
console.log(tx) | |
router.push('/donations/' + id) | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Charity created successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<div> | |
<Head> | |
<title>Charity Update</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<div className="flex flex-col w-full sm:w-4/5 py-4 px-4 sm:px-0 mx-auto"> | |
<div className="block justify-center items-center m-auto w-full sm:w-3/5"> | |
<form onSubmit={handleSubmit} className="flex flex-col space-y-4"> | |
<div className="flex items-center justify-center mb-4"> | |
<h2>Edit Charity</h2> | |
</div> | |
<div className="flex justify-between items-center flex-col sm:flex-row sm:space-x-4 space-y-4 sm:space-y-0"> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="name" | |
placeholder="Charity Name" | |
required | |
value={charity.name} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="fullname" | |
placeholder="Your Full Name" | |
required | |
value={charity.fullname} | |
onChange={handleChange} | |
/> | |
</div> | |
</div> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
step={0.01} | |
min={0.01} | |
name="amount" | |
placeholder="Amount e.g. 0.02 ETH" | |
required | |
value={charity.amount} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="flex justify-between items-center flex-col sm:flex-row sm:space-x-4 space-y-4 sm:space-y-0"> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="image" | |
placeholder="Image URL" | |
pattern="https?://.+(\.(jpg|png|gif))?$" | |
title="Please enter a valid image URL (http(s)://...(.png|.jpg|.jpeg|.gif))" | |
required | |
value={charity.image} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="profile" | |
placeholder="Your LinkedIn Profile" | |
required | |
value={charity.profile} | |
onChange={handleChange} | |
/> | |
</div> | |
</div> | |
<div className="flex justify-between items-center rounded-xl p-2 w-full border border-gray-300"> | |
<textarea | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
placeholder="Description" | |
name="description" | |
required | |
value={charity.description} | |
onChange={handleChange} | |
></textarea> | |
</div> | |
<div className=""> | |
<button | |
className="text-white text-md bg-green-600 py-3 px-8 rounded-full | |
drop-shadow-xl border border-transparent hover:bg-transparent hover:border-green-600 | |
hover:text-green-600 focus:outline-none mt-5" | |
> | |
Update Data | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<div className="h-10"></div> | |
<NavBtn /> | |
</div> | |
) | |
} | |
export default Page | |
export const getServerSideProps = async (context: GetServerSidePropsContext) => { | |
const { id } = context.query | |
const charityData: CharityStruct = await getCharity(Number(id)) | |
return { | |
props: { | |
charityData: JSON.parse(JSON.stringify(charityData)), | |
}, | |
} | |
} |
Did you see how the getCharity()
function was used to retrieve charity by Id and also how it was used to populate the form fields?
Components with Smart Contract
Like we did with the pages above, let’s Update the following components to interact with the smart contract:
No 1: Handling Charity Banning
Update components/Ban.tsx
to use the handleBanning()
function to call the banCharity()
function.
import React from 'react' | |
import { TfiClose } from 'react-icons/tfi' | |
import { useDispatch, useSelector } from 'react-redux' | |
import { CharityStruct, RootState } from '@/utils/type.dt' | |
import { globalActions } from '@/store/globalSlices' | |
import { FaBan } from 'react-icons/fa' | |
import { useAccount } from 'wagmi' | |
import { toast } from 'react-toastify' | |
import { banCharity } from '@/services/blockchain' | |
const Ban: React.FC<{ charity: CharityStruct }> = ({ charity }) => { | |
const { address } = useAccount() | |
const { banModal } = useSelector((states: RootState) => states.globalStates) | |
const dispatch = useDispatch() | |
const { setBanModal } = globalActions | |
const handleBannig = async () => { | |
if (!address) return toast.warning('Connect wallet first!') | |
await toast.promise( | |
new Promise<void>((resolve, reject) => { | |
banCharity(charity.id) | |
.then((tx) => { | |
console.log(tx) | |
dispatch(setBanModal('scale-0')) | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Charity banned successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center | |
bg-black bg-opacity-50 transform z-[3000] transition-transform duration-300 ${banModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 rounded-xl w-11/12 md:w-1/5 h-7/12 p-6"> | |
<div className="flex flex-col space-y-2"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-medium text-2xl">Ban Campaign</p> | |
<button | |
onClick={() => dispatch(setBanModal('scale-0'))} | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<TfiClose className="text-black" /> | |
</button> | |
</div> | |
<div className="flex flex-col justify-center items-center rounded-xl mb-5"> | |
<FaBan size={30} className="text-red-700 " /> | |
<p className="text-center p-2"> | |
Are you sure you want to ban <br /> | |
<span className="italic font-semibold">{charity.name}</span> | |
</p> | |
</div> | |
<div className="mx-auto"> | |
<button | |
onClick={handleBannig} | |
className="bg-gray-100 text-red-600 px-4 space-x-1 | |
flex justify-center items-center rounded-full text-center py-3 | |
transition-all duration-300 ease-in-out | |
hover:bg-red-600 hover:text-white font-medium" | |
> | |
{charity.banned ? 'Unban & List' : 'Ban & Unlist'} | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default Ban |
No 2: Handling Charity Deletion
Update components/Delete.tsx
file to use the handleDelete()
and deleteCharity()
functions to execute a charity unlisting procedure.
import React from 'react' | |
import { TfiClose } from 'react-icons/tfi' | |
import { BsTrash3 } from 'react-icons/bs' | |
import { useDispatch, useSelector } from 'react-redux' | |
import { CharityStruct, RootState } from '@/utils/type.dt' | |
import { globalActions } from '@/store/globalSlices' | |
import { useAccount } from 'wagmi' | |
import { toast } from 'react-toastify' | |
import { deleteCharity } from '@/services/blockchain' | |
import { useRouter } from 'next/router' | |
const Delete: React.FC<{ charity: CharityStruct }> = ({ charity }) => { | |
const { address } = useAccount() | |
const { deleteModal } = useSelector((states: RootState) => states.globalStates) | |
const dispatch = useDispatch() | |
const { setDeleteModal } = globalActions | |
const router = useRouter() | |
const handleDelete = async () => { | |
if (!address) return toast.warning('Connect wallet first!') | |
await toast.promise( | |
new Promise<void>((resolve, reject) => { | |
deleteCharity(charity.id) | |
.then((tx) => { | |
dispatch(setDeleteModal('scale-0')) | |
router.push('/') | |
console.log(tx) | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Charity deleted successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center | |
bg-black bg-opacity-50 transform z-[3000] transition-transform duration-300 ${deleteModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 rounded-xl w-11/12 md:w-1/5 h-7/12 p-6"> | |
<div className="flex flex-col space-y-2"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-medium text-2xl">Delete</p> | |
<button | |
onClick={() => dispatch(setDeleteModal('scale-0'))} | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<TfiClose className="text-black" /> | |
</button> | |
</div> | |
<div className="flex flex-col justify-center items-center rounded-xl mb-5"> | |
<BsTrash3 size={30} className="text-red-700 " /> | |
<p className="text-center p-2"> | |
Are you sure you want to delete <br /> | |
<span className="italic font-semibold">{charity.name}</span> | |
</p> | |
</div> | |
<div className="mx-auto"> | |
<button | |
onClick={handleDelete} | |
className="bg-gray-100 text-red-600 px-4 space-x-1 | |
flex justify-center items-center rounded-full text-center py-3 | |
transition-all duration-300 ease-in-out | |
hover:bg-red-600 hover:text-white font-medium" | |
> | |
Delete & Unlist | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default Delete |
No 3: Making Donations to Charities
Update components/Donor.tsx
file to use the handleSubmit()
function to send the payment to the smart contract using the makeDonation()
function.
import React, { ChangeEvent, FormEvent, useState } from 'react' | |
import { TfiClose } from 'react-icons/tfi' | |
import { useDispatch, useSelector } from 'react-redux' | |
import { CharityStruct, DonorParams, RootState } from '@/utils/type.dt' | |
import { globalActions } from '@/store/globalSlices' | |
import { useAccount } from 'wagmi' | |
import { toast } from 'react-toastify' | |
import { makeDonation } from '@/services/blockchain' | |
const Donor: React.FC<{ charity: CharityStruct }> = ({ charity }) => { | |
const { donorsModal } = useSelector((states: RootState) => states.globalStates) | |
const dispatch = useDispatch() | |
const { setDonorModal } = globalActions | |
const { address } = useAccount() | |
const [donor, setDonor] = useState<DonorParams>({ | |
id: charity.id, | |
fullname: '', | |
comment: '', | |
amount: '', | |
}) | |
const handleSubmit = async (e: FormEvent) => { | |
e.preventDefault() | |
if (!donor.amount || !donor.comment) return | |
if (!address) return toast.warning('Connect wallet first!') | |
await toast.promise( | |
new Promise<void>((resolve, reject) => { | |
makeDonation(donor) | |
.then((tx) => { | |
dispatch(setDonorModal('scale-0')) | |
resetForm() | |
console.log(tx) | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Donotion received successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
const { name, value } = e.target | |
setDonor((prevState) => ({ | |
...prevState, | |
[name]: value, | |
})) | |
} | |
const resetForm = () => { | |
setDonor({ | |
id: charity.id, | |
fullname: '', | |
comment: '', | |
amount: '', | |
}) | |
} | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center | |
bg-black bg-opacity-50 transform z-[3000] transition-transform duration-300 ${donorsModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 rounded-xl w-11/12 md:w-1/5 h-7/12 p-6"> | |
<form onSubmit={handleSubmit} className="flex flex-col space-y-4"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-medium text-2xl">Support Us</p> | |
<button | |
onClick={() => dispatch(setDonorModal('scale-0'))} | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<TfiClose className="text-black" /> | |
</button> | |
</div> | |
<div | |
className="flex justify-between items-center rounded-xl p-2 w-full | |
border border-gray-300 mt-5" | |
> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="fullname" | |
placeholder="Your Name (Optional)" | |
value={donor.fullname} | |
onChange={handleChange} | |
/> | |
</div> | |
<div | |
className="flex justify-between items-center rounded-xl p-2 w-full | |
border border-gray-300 mt-5" | |
> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="comment" | |
placeholder="Words of support" | |
required | |
value={donor.comment} | |
onChange={handleChange} | |
/> | |
</div> | |
<div | |
className="flex justify-between items-center rounded-xl p-2 w-full | |
border border-gray-300 mt-5" | |
> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
step={0.01} | |
min={0.01} | |
name="amount" | |
placeholder="Amount e.g. 0.02 ETH" | |
required | |
value={donor.amount} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="mx-auto"> | |
<button | |
type="submit" | |
className="flex flex-row justify-center items-center w-full text-black text-md | |
bg-amber-600 py-3 px-20 rounded-full drop-shadow-xl border font-medium | |
focus:outline-none focus:ring transition-all duration-300 ease-in-out | |
hover:bg-amber-500" | |
> | |
Donate Now | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default Donor |
Other Components
Here are the rest of the components you should also update because the integration of Redux in them.
The NavBtn Component
Review and update the component that enables users to navigate, delete, and edit charity information. Utilize Redux to trigger the delete modal upon clicking.
The Payment Component
Review and update the implementation of Redux to trigger the donation, supporters, and ban modals upon clicking their respective buttons.
import React from 'react' | |
import Donation from './Donation' | |
import { FaBan, FaEthereum } from 'react-icons/fa' | |
import { CharityStruct, SupportStruct } from '@/utils/type.dt' | |
import { useDispatch } from 'react-redux' | |
import { globalActions } from '@/store/globalSlices' | |
import { useAccount } from 'wagmi' | |
interface ComponentProp { | |
charity: CharityStruct | |
supports: SupportStruct[] | |
owner: string | |
} | |
const Payment: React.FC<ComponentProp> = ({ charity, supports, owner }) => { | |
const { address } = useAccount() | |
const dispatch = useDispatch() | |
const { setSupportModal, setDonorModal, setBanModal } = globalActions | |
return ( | |
<div | |
className="w-full md:w-1/3 shadow-lg shadow-gray-300 p-6 | |
rounded-xl space-y-4 max-h-[46rem] overflow-hidden" | |
> | |
<div className="font-light"> | |
<div className="flex items-end space-x-2 mb-4"> | |
<h4 className="text-4xl flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span>{charity.raised.toFixed(2)}</span> | |
</h4> | |
<span className="text-gray-600">raised of {charity.amount.toFixed(2)} ETH target</span> | |
</div> | |
<div className="h-1 bg-gray-300 rounded-full mb-2"> | |
<div | |
className="h-full overflow-hidden bg-green-600 rounded-full" | |
style={{ width: `${(charity.raised / charity.amount) * 100}%` }} | |
/> | |
</div> | |
<span className="text-gray-600">{charity.donations} donations</span> | |
</div> | |
<div className="flex flex-col space-y-2 font-semibold"> | |
<button | |
className="bg-amber-500 py-3 px-20 rounded-xl | |
transition-all duration-300 ease-in-out | |
hover:bg-amber-600" | |
> | |
Share | |
</button> | |
{!charity.banned ? ( | |
<button | |
className="bg-amber-500 py-3 px-20 rounded-xl | |
transition-all duration-300 ease-in-out | |
hover:bg-amber-400" | |
onClick={() => dispatch(setDonorModal('scale-100'))} | |
> | |
Donate now | |
</button> | |
) : ( | |
<button | |
className="border border-amber-500 py-3 px-20 rounded-xl | |
transition-all duration-300 ease-in-out flex justify-center | |
hover:border-amber-400 hover:bg-red-100 items-center space-x-2" | |
> | |
<span>Banned</span> | |
<FaBan size={20} className="text-red-700 " /> | |
</button> | |
)} | |
</div> | |
<div className="flex flex-col space-y-10"> | |
{supports.map((support: any, i: number) => ( | |
<Donation support={support} key={i} /> | |
))} | |
</div> | |
<div className="flex justify-start items-center space-x-4"> | |
<button | |
onClick={() => dispatch(setSupportModal('scale-100'))} | |
className="border border-gray-300 py-2 px-4 rounded-lg font-medium | |
transition-all duration-300 ease-in-out | |
hover:bg-gray-100" | |
> | |
See all | |
</button> | |
<button | |
onClick={() => dispatch(setSupportModal('scale-100'))} | |
className="border border-gray-300 py-2 px-4 rounded-lg font-medium | |
transition-all duration-300 ease-in-out | |
hover:bg-gray-100" | |
> | |
See top donations | |
</button> | |
{owner === address && ( | |
<button | |
onClick={() => dispatch(setBanModal('scale-100'))} | |
className="border border-gray-300 py-2 px-4 rounded-lg font-medium | |
transition-all duration-300 ease-in-out | |
hover:bg-red-100" | |
> | |
{charity.banned ? 'Unban Campaign' : 'Ban Campaign'} | |
</button> | |
)} | |
</div> | |
</div> | |
) | |
} | |
export default Payment |
The Supports Component
Review and update the implementation of Redux to close the supporters modal when the close button is clicked.
import React from 'react' | |
import { TfiClose } from 'react-icons/tfi' | |
import Donation from './Donation' | |
import { RootState, SupportStruct } from '@/utils/type.dt' | |
import { useDispatch, useSelector } from 'react-redux' | |
import { globalActions } from '@/store/globalSlices' | |
const Supports: React.FC<{ supports: SupportStruct[] }> = ({ supports }) => { | |
const { supportModal } = useSelector((states: RootState) => states.globalStates) | |
const dispatch = useDispatch() | |
const { setSupportModal } = globalActions | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center justify-center | |
bg-black bg-opacity-50 transform z-[3000] transition-transform duration-300 ${supportModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 rounded-xl w-11/12 md:w-2/5 h-7/12 p-6"> | |
<form className="flex flex-col space-y-8"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-medium text-2xl">Donations ({supports.length})</p> | |
<button | |
onClick={() => dispatch(setSupportModal('scale-0'))} | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<TfiClose className="text-black" /> | |
</button> | |
</div> | |
<div className="flex flex-col space-y-10 max-h-[30rem] overflow-scroll"> | |
{supports.map((support: any, i: number) => ( | |
<Donation support={support} key={i} /> | |
))} | |
</div> | |
<button | |
type="submit" | |
className="flex flex-row justify-center items-center w-full text-black text-md | |
bg-amber-600 py-3 px-20 rounded-full drop-shadow-xl border font-medium | |
focus:outline-none focus:ring mt-5" | |
> | |
Donate Now | |
</button> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default Supports |
By implementing these updates, all components and pages are now connected to the smart contract, marking the completion of the project.
If your nextjs server was down all this while, you can spin it up again by running yarn dev
.
For further learning, we recommends watch the full video of this build on our YouTube channel and visiting our website for additional resources.
Conclusion
In this tutorial, we've built a Decentralized Charity Platform using Next.js, Solidity, and TypeScript. We set up the development environment, built the Redux store, and deployed the smart contract to the blockchain. By integrating the smart contract with the frontend, we created a seamless user experience.
Throughout the tutorial, you gained valuable skills in building Web3 applications, crafting smart contracts, and incorporating static type checking.
Now, you're ready to create your own Decentralized Charity Platform. Happy coding and unleash your innovation in the world of Web3!
About Author
I am a web3 developer and the founder of Dapp Mentors, a company that helps businesses and individuals build and launch decentralized applications. I have over 7 years of experience in the software industry, and I am passionate about using blockchain technology to create new and innovative applications. I run a YouTube channel called Dapp Mentors where I share tutorials and tips on web3 development, and I regularly post articles online about the latest trends in the blockchain space.
Stay connected with us, join communities on
Discord: Join
X-Twitter: Follow
LinkedIn: Connect
GitHub: Explore
Website: Visit
Top comments (0)