What you will be building, see our git repo for the finished work and our live demo.
Introduction
Welcome to our comprehensive guide on "Building a Web3 Events Marketplace with Next.js, TypeScript, and Solidity". In this tutorial, we'll construct a decentralized Events Marketplace that harnesses the power of blockchain technology. You'll gain a clear understanding of the following:
- 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 Events Marketplaces
By the end of this guide, you'll have a functioning decentralized platform where users can list and participate in events, with all transactions managed and secured by Ethereum smart contracts.
As an added incentive for participating in this tutorial, we're giving away a copy of our prestigious book on becoming an in-demand Solidity developer. This offer is free for the first 300 participants. For instructions on how to claim your copy, please 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:
Join the Bitfinity ecosystem and be a part of the next generation of dApps. Apply your Bitfinity knowledge to create a Blockchain-based House Rental dApp in the final module. Deploy your smart contracts to the Bitfinity network and revolutionize the rental industry
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/dappEventX
cd dappEventX
yarn install
git checkout 01_no_redux
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 user interface is prepared to incorporate smart contracts, however, we still need to integrate Redux in order to facilitate the sharing of data.
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 = { | |
event: null, | |
tickets: [], | |
ticketModal: 'scale-0', | |
} |
- Inside
actions
, create aglobalActions.ts
file.
import { EventStruct, GlobalState, TicketStruct } from '@/utils/type.dt' | |
import { PayloadAction } from '@reduxjs/toolkit' | |
export const globalActions = { | |
setTickets: (state: GlobalState, action: PayloadAction<TicketStruct[]>) => { | |
state.tickets = action.payload | |
}, | |
setEvent: (state: GlobalState, action: PayloadAction<EventStruct | null>) => { | |
state.event = action.payload | |
}, | |
setTicketModal: (state: GlobalState, action: PayloadAction<string>) => { | |
state.ticketModal = 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.tsx
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 { 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 bg-gray-100"> | |
<Header /> | |
<div className="mt-10 h-20 "></div> | |
<Component {...pageProps} /> | |
<div className="mt-10 h-20 "></div> | |
<ToastContainer | |
position="bottom-center" | |
autoClose={5000} | |
hideProgressBar={false} | |
newestOnTop={false} | |
closeOnClick | |
rtl={false} | |
pauseOnFocusLoss | |
draggable | |
pauseOnHover | |
theme="dark" | |
/> | |
</div> | |
</Provider> | |
</Providers> | |
) | |
} | |
} |
We have implemented Redux toolkit in our application and plan to revisit its usage 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 aDappEventX.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/token/ERC721/ERC721.sol'; | |
import '@openzeppelin/contracts/security/ReentrancyGuard.sol'; | |
contract DappEventX is Ownable, ReentrancyGuard, ERC721 { | |
using Counters for Counters.Counter; | |
Counters.Counter private _totalEvents; | |
Counters.Counter private _totalTokens; | |
struct EventStruct { | |
uint256 id; | |
string title; | |
string imageUrl; | |
string description; | |
address owner; | |
uint256 sales; | |
uint256 ticketCost; | |
uint256 capacity; | |
uint256 seats; | |
uint256 startsAt; | |
uint256 endsAt; | |
uint256 timestamp; | |
bool deleted; | |
bool paidOut; | |
bool refunded; | |
bool minted; | |
} | |
struct TicketStruct { | |
uint256 id; | |
uint256 eventId; | |
address owner; | |
uint256 ticketCost; | |
uint256 timestamp; | |
bool refunded; | |
bool minted; | |
} | |
uint256 public balance; | |
uint256 private servicePct; | |
mapping(uint256 => EventStruct) events; | |
mapping(uint256 => TicketStruct[]) tickets; | |
mapping(uint256 => bool) eventExists; | |
constructor(uint256 _pct) ERC721('Event X', 'EVX') { | |
servicePct = _pct; | |
} | |
function createEvent( | |
string memory title, | |
string memory description, | |
string memory imageUrl, | |
uint256 capacity, | |
uint256 ticketCost, | |
uint256 startsAt, | |
uint256 endsAt | |
) public { | |
require(ticketCost > 0 ether, 'TicketCost must be greater than zero'); | |
require(capacity > 0, 'Capacity must be greater than zero'); | |
require(bytes(title).length > 0, 'Title cannot be empty'); | |
require(bytes(description).length > 0, 'Description cannot be empty'); | |
require(bytes(imageUrl).length > 0, 'ImageUrl cannot be empty'); | |
require(startsAt > 0, 'Start date must be greater than zero'); | |
require(endsAt > startsAt, 'End date must be greater than start date'); | |
_totalEvents.increment(); | |
EventStruct memory eventX; | |
eventX.id = _totalEvents.current(); | |
eventX.title = title; | |
eventX.description = description; | |
eventX.imageUrl = imageUrl; | |
eventX.capacity = capacity; | |
eventX.ticketCost = ticketCost; | |
eventX.startsAt = startsAt; | |
eventX.endsAt = endsAt; | |
eventX.owner = msg.sender; | |
eventX.timestamp = currentTime(); | |
eventExists[eventX.id] = true; | |
events[eventX.id] = eventX; | |
} | |
function updateEvent( | |
uint256 eventId, | |
string memory title, | |
string memory description, | |
string memory imageUrl, | |
uint256 capacity, | |
uint256 ticketCost, | |
uint256 startsAt, | |
uint256 endsAt | |
) public { | |
require(eventExists[eventId], 'Event not found'); | |
require(events[eventId].owner == msg.sender, 'Unauthorized entity'); | |
require(ticketCost > 0 ether, 'TicketCost must be greater than zero'); | |
require(capacity > 0, 'capacity must be greater than zero'); | |
require(bytes(title).length > 0, 'Title cannot be empty'); | |
require(bytes(description).length > 0, 'Description cannot be empty'); | |
require(bytes(imageUrl).length > 0, 'ImageUrl cannot be empty'); | |
require(startsAt > 0, 'Start date must be greater than zero'); | |
require(endsAt > startsAt, 'End date must be greater than start date'); | |
events[eventId].title = title; | |
events[eventId].description = description; | |
events[eventId].imageUrl = imageUrl; | |
events[eventId].capacity = capacity; | |
events[eventId].ticketCost = ticketCost; | |
events[eventId].startsAt = startsAt; | |
events[eventId].endsAt = endsAt; | |
} | |
function deleteEvent(uint256 eventId) public { | |
require(eventExists[eventId], 'Event not found'); | |
require(events[eventId].owner == msg.sender || msg.sender == owner(), 'Unauthorized entity'); | |
require(!events[eventId].paidOut, 'Event already paid out'); | |
require(!events[eventId].refunded, 'Event already refunded'); | |
require(!events[eventId].deleted, 'Event already deleted'); | |
require(refundTickets(eventId), 'Event failed to refund'); | |
events[eventId].deleted = true; | |
} | |
function getEvents() public view returns (EventStruct[] memory Events) { | |
uint256 available; | |
for (uint256 i = 1; i <= _totalEvents.current(); i++) { | |
if (!events[i].deleted) { | |
available++; | |
} | |
} | |
Events = new EventStruct[](available); | |
uint256 index; | |
for (uint256 i = 1; i <= _totalEvents.current(); i++) { | |
if (!events[i].deleted) { | |
Events[index++] = events[i]; | |
} | |
} | |
} | |
function getMyEvents() public view returns (EventStruct[] memory Events) { | |
uint256 available; | |
for (uint256 i = 1; i <= _totalEvents.current(); i++) { | |
if (!events[i].deleted && events[i].owner == msg.sender) { | |
available++; | |
} | |
} | |
Events = new EventStruct[](available); | |
uint256 index; | |
for (uint256 i = 1; i <= _totalEvents.current(); i++) { | |
if (!events[i].deleted && events[i].owner == msg.sender) { | |
Events[index++] = events[i]; | |
} | |
} | |
} | |
function getSingleEvent(uint256 eventId) public view returns (EventStruct memory) { | |
return events[eventId]; | |
} | |
function buyTickets(uint256 eventId, uint256 numOfticket) public payable { | |
require(eventExists[eventId], 'Event not found'); | |
require(msg.value >= events[eventId].ticketCost * numOfticket, 'Insufficient amount'); | |
require(numOfticket > 0, 'NumOfticket must be greater than zero'); | |
require( | |
events[eventId].seats + numOfticket <= events[eventId].capacity, | |
'Out of seating capacity' | |
); | |
for (uint i = 0; i < numOfticket; i++) { | |
TicketStruct memory ticket; | |
ticket.id = tickets[eventId].length; | |
ticket.eventId = eventId; | |
ticket.owner = msg.sender; | |
ticket.ticketCost = events[eventId].ticketCost; | |
ticket.timestamp = currentTime(); | |
tickets[eventId].push(ticket); | |
} | |
events[eventId].seats += numOfticket; | |
balance += msg.value; | |
} | |
function getTickets(uint256 eventId) public view returns (TicketStruct[] memory Tickets) { | |
return tickets[eventId]; | |
} | |
function refundTickets(uint256 eventId) internal returns (bool) { | |
for (uint i = 0; i < tickets[eventId].length; i++) { | |
tickets[eventId][i].refunded = true; | |
payTo(tickets[eventId][i].owner, tickets[eventId][i].ticketCost); | |
balance -= tickets[eventId][i].ticketCost; | |
} | |
events[eventId].refunded = true; | |
return true; | |
} | |
function payout(uint256 eventId) public { | |
require(eventExists[eventId], 'Event not found'); | |
require(!events[eventId].paidOut, 'Event already paid out'); | |
require(currentTime() > events[eventId].endsAt, 'Event still ongoing'); // disable while testing | |
require(events[eventId].owner == msg.sender || msg.sender == owner(), 'Unauthorized entity'); | |
require(mintTickets(eventId), 'Event failed to mint'); | |
uint256 revenue = events[eventId].ticketCost * events[eventId].seats; | |
uint256 feePct = (revenue * servicePct) / 100; | |
payTo(events[eventId].owner, revenue - feePct); | |
payTo(owner(), feePct); | |
events[eventId].paidOut = true; | |
balance -= revenue; | |
} | |
function mintTickets(uint256 eventId) internal returns (bool) { | |
for (uint i = 0; i < tickets[eventId].length; i++) { | |
_totalTokens.increment(); | |
tickets[eventId][i].minted = true; | |
_mint(tickets[eventId][i].owner, _totalTokens.current()); | |
} | |
events[eventId].minted = true; | |
return true; | |
} | |
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 above smart contract is designed to manage an Events Marketplace on the blockchain. It allows users to create, update, and delete events, buy tickets for events, and handle payouts. Here's a detailed breakdown of its functions:
Constructor (
**constructor(uint256 _pct) ERC721('Event X', 'EVX')**
): This function initializes the contract, sets the service fee percentage, and assigns initial token parameters.createEvent Function: This function allows a user to create a new event. It requires the title, description, image URL, capacity, ticket cost, and start and end times. It validates all input data and creates a new event structure, which is stored in the events mapping.
updateEvent Function: This function allows a user to update an existing event. It requires the same parameters as the createEvent function. It checks that the event exists and that the caller is the owner of the event before updating the event data.
deleteEvent Function: This function allows the owner of an event to delete it. It checks that the event exists, that the caller is the owner of the event or the contract owner, and that the event has not been paid out or refunded. It then refunds all tickets for the event and marks the event as deleted.
getEvents Function: This function returns all existing events. It creates an array of the right size, then fills it with all events that have not been deleted.
getMyEvents Function: This function returns all events owned by the caller. It creates an array of the right size, then fills it with all events owned by the caller that have not been deleted.
getSingleEvent Function: This function returns a specific event. It simply returns the event structure from the events mapping.
buyTickets Function: This function allows a user to buy tickets for an event. It checks that the event exists, that the sent value is at least the total cost of the tickets, and that there are enough seats available. It then creates new ticket structures for each ticket and adds them to the tickets mapping.
getTickets Function: This function returns all tickets for a specific event. It simply returns the array of ticket structures from the tickets mapping.
refundTickets Function: This internal function refunds all tickets for a specific event. It marks each ticket as refunded, sends the ticket cost back to the ticket owner, and decreases the contract balance.
payout Function: This function handles payout distribution at the end of an event. It checks that the event exists, has not already been paid out, and is no longer ongoing. It then mints tickets for the event, calculates the revenue and fee, and sends the revenue minus the fee to the event owner and the fee to the contract owner. It marks the event as paid out and decreases the contract balance.
mintTickets Function: This internal function mints tickets for a specific event. It marks each ticket and the event as minted, and mints a new ERC721 token for each ticket.
payTo Function: This internal function sends funds to a specified address. It uses the low-level call function to send the funds and checks that the call was successful.
currentTime Function: This internal function returns the current timestamp. It uses the block.timestamp global variable, multiplies it by 1000, and adds 1000.
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 servicePct = 5 | |
try { | |
contract = await ethers.deployContract('DappEventX', [servicePct]) | |
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( | |
{ | |
dappEventXContract: 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
const { faker } = require('@faker-js/faker') | |
const { ethers } = require('hardhat') | |
const fs = require('fs') | |
const toWei = (num) => ethers.parseEther(num.toString()) | |
const dataCount = 1 | |
const generateEventData = (count) => { | |
const events = [] | |
for (let i = 0; i < count; i++) { | |
const startDate = new Date(Date.now() + 10 * 60 * 1000).getTime() | |
const event = { | |
id: i + 1, | |
title: faker.lorem.words(5), | |
description: faker.lorem.paragraph(), | |
imageUrl: faker.image.urlPicsumPhotos(), | |
owner: faker.string.hexadecimal({ | |
length: { min: 42, max: 42 }, | |
prefix: '0x', | |
}), | |
sales: faker.number.int({ min: 1, max: 20 }), | |
capacity: faker.number.int({ min: 20, max: 40 }), | |
ticketCost: faker.number.float({ min: 0.01, max: 0.1 }), | |
startsAt: startDate, | |
endsAt: new Date(startDate + 10 * 24 * 60 * 60 * 1000).getTime(), | |
timestamp: faker.date.past().getTime(), | |
deleted: faker.datatype.boolean(), | |
paidOut: faker.datatype.boolean(), | |
} | |
events.push(event) | |
} | |
return events | |
} | |
async function createEvent(contract, event) { | |
const tx = await contract.createEvent( | |
event.title, | |
event.description, | |
event.imageUrl, | |
event.capacity, | |
toWei(event.ticketCost), | |
event.startsAt, | |
event.endsAt | |
) | |
await tx.wait() | |
} | |
async function buyTickets(contract, eventId, numberOfTickets) { | |
const tx = await contract.buyTickets(eventId, numberOfTickets, { value: toWei(2) }) | |
await tx.wait() | |
} | |
async function getEvents(contract) { | |
const result = await contract.getEvents() | |
console.log('Events:', result) | |
} | |
async function getTickets(contract, eventId) { | |
const result = await contract.getTickets(eventId) | |
console.log('Tickets:', result) | |
} | |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) | |
async function main() { | |
let dappEventXContract | |
try { | |
const contractAddresses = fs.readFileSync('./contracts/contractAddress.json', 'utf8') | |
const { dappEventXContract: dappEventXAddress } = JSON.parse(contractAddresses) | |
dappEventXContract = await ethers.getContractAt('DappEventX', dappEventXAddress) | |
// Process #1 | |
await Promise.all( | |
generateEventData(dataCount).map(async (event) => { | |
await createEvent(dappEventXContract, event) | |
}) | |
) | |
await delay(2500) // Wait for 2.5 seconds | |
// Process #2 | |
await Promise.all( | |
Array(dataCount) | |
.fill() | |
.map(async (_, i) => { | |
const randomCount = faker.number.int({ min: 1, max: 2 }) | |
await Promise.all( | |
Array(randomCount) | |
.fill() | |
.map(async () => { | |
await buyTickets(dappEventXContract, i + 1, 1) | |
}) | |
) | |
}) | |
) | |
await delay(2500) // Wait for 2.5 seconds | |
// Process #3 | |
await getEvents(dappEventXContract) | |
await getTickets(dappEventXContract, 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 abi from '@/artifacts/contracts/DappEventX.sol/DappEventX.json' | |
import { EventParams, EventStruct, TicketStruct } from '@/utils/type.dt' | |
import { globalActions } from '@/store/globalSlices' | |
import { store } from '@/store' | |
const toWei = (num: number) => ethers.parseEther(num.toString()) | |
const fromWei = (num: number) => ethers.formatEther(num) | |
let ethereum: any | |
let tx: any | |
if (typeof window !== 'undefined') ethereum = (window as any).ethereum | |
const { setEvent, setTickets } = globalActions | |
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.dappEventXContract, abi.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.dappEventXContract, abi.abi, signer) | |
return contracts | |
} | |
} | |
const createEvent = async (event: EventParams): 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.createEvent( | |
event.title, | |
event.description, | |
event.imageUrl, | |
event.capacity, | |
toWei(Number(event.ticketCost)), | |
event.startsAt, | |
event.endsAt | |
) | |
await tx.wait() | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const updateEvent = async (event: EventParams): 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.updateEvent( | |
event.id, | |
event.title, | |
event.description, | |
event.imageUrl, | |
event.capacity, | |
toWei(Number(event.ticketCost)), | |
event.startsAt, | |
event.endsAt | |
) | |
await tx.wait() | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const deleteEvent = async (eventId: 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.deleteEvent(eventId) | |
await tx.wait() | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const payout = async (eventId: 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.payout(eventId) | |
await tx.wait() | |
const eventData: EventStruct = await getEvent(eventId) | |
store.dispatch(setEvent(eventData)) | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const buyTicket = async (event: EventStruct, tickets: 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.buyTickets(event.id, tickets, { value: toWei(tickets * event.ticketCost) }) | |
await tx.wait() | |
const eventData: EventStruct = await getEvent(event.id) | |
store.dispatch(setEvent(eventData)) | |
const ticketsData: TicketStruct[] = await getTickets(event.id) | |
store.dispatch(setTickets(ticketsData)) | |
return Promise.resolve(tx) | |
} catch (error) { | |
reportError(error) | |
return Promise.reject(error) | |
} | |
} | |
const getEvents = async (): Promise<EventStruct[]> => { | |
const contract = await getEthereumContracts() | |
const events = await contract.getEvents() | |
return structuredEvent(events) | |
} | |
const getMyEvents = async (): Promise<EventStruct[]> => { | |
const contract = await getEthereumContracts() | |
const events = await contract.getMyEvents() | |
return structuredEvent(events) | |
} | |
const getEvent = async (eventId: number): Promise<EventStruct> => { | |
const contract = await getEthereumContracts() | |
const event = await contract.getSingleEvent(eventId) | |
return structuredEvent([event])[0] | |
} | |
const getTickets = async (eventId: number): Promise<TicketStruct[]> => { | |
const contract = await getEthereumContracts() | |
const tickets = await contract.getTickets(eventId) | |
return structuredTicket(tickets) | |
} | |
const structuredEvent = (events: EventStruct[]): EventStruct[] => | |
events | |
.map((event) => ({ | |
id: Number(event.id), | |
title: event.title, | |
imageUrl: event.imageUrl, | |
description: event.description, | |
owner: event.owner, | |
sales: Number(event.sales), | |
ticketCost: parseFloat(fromWei(event.ticketCost)), | |
capacity: Number(event.capacity), | |
seats: Number(event.seats), | |
startsAt: Number(event.startsAt), | |
endsAt: Number(event.endsAt), | |
timestamp: Number(event.timestamp), | |
deleted: event.deleted, | |
paidOut: event.paidOut, | |
refunded: event.refunded, | |
minted: event.minted, | |
})) | |
.sort((a, b) => b.timestamp - a.timestamp) | |
const structuredTicket = (tickets: TicketStruct[]): TicketStruct[] => | |
tickets | |
.map((ticket) => ({ | |
id: Number(ticket.id), | |
eventId: Number(ticket.eventId), | |
owner: ticket.owner, | |
ticketCost: parseFloat(fromWei(ticket.ticketCost)), | |
timestamp: Number(ticket.timestamp), | |
refunded: ticket.refunded, | |
minted: ticket.minted, | |
})) | |
.sort((a, b) => b.timestamp - a.timestamp) | |
export { | |
getEvents, | |
getMyEvents, | |
getEvent, | |
getTickets, | |
createEvent, | |
updateEvent, | |
deleteEvent, | |
buyTicket, | |
payout, | |
} |
The above code is a service that interacts with a Events Marketplace contract. The service interacts with a smart contract deployed on the Ethereum blockchain using the ethers.js library.
Here's a detailed breakdown of its functions:
getEthereumContracts Function: This function sets up a connection to the Ethereum blockchain and the smart contract. It uses the ethers library to create a provider (to interact with the Ethereum blockchain) and a signer (to sign transactions). Then, it creates a contract instance that can be used to interact with the smart contract.
createEvent Function: This function creates a new event by calling the createEvent function of the contract.
updateEvent Function: This function updates an existing event by calling the updateEvent function of the contract.
deleteEvent Function: This function deletes an event by calling the deleteEvent function of the contract.
payout Function: This function handles payout distribution at the end of an event.
buyTicket Function: This function allows a user to buy tickets for an event.
getEvents and getMyEvents Functions: These functions retrieve information about all events or the events owned by the caller. They call the corresponding functions in the contract and structure the returned data.
getEvent Function: This function retrieves information about a specific event. It calls the corresponding function in the contract and structures the returned data.
getTickets Function: This function retrieves information about the tickets for a specific event. It calls the corresponding function in the contract and structures the returned data.
structuredEvent and structuredTicket Functions: These functions structure the data returned from the contract into a more manageable format. They convert the data types from the contract's format to JavaScript's format and sort the data.
Next, 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 Available Events
Update pages/index.tsx
to get data from the getEvents()
function.
import EventList from '@/components/EventList' | |
import Hero from '@/components/Hero' | |
import { getEvents } from '@/services/blockchain' | |
import { EventStruct } from '@/utils/type.dt' | |
import { NextPage } from 'next' | |
import Head from 'next/head' | |
import { useEffect, useState } from 'react' | |
const Page: NextPage<{ eventsData: EventStruct[] }> = ({ eventsData }) => { | |
const [end, setEnd] = useState<number>(6) | |
const [count] = useState<number>(6) | |
const [collection, setCollection] = useState<EventStruct[]>([]) | |
useEffect(() => { | |
setCollection(eventsData.slice(0, end)) | |
}, [eventsData, end]) | |
return ( | |
<div> | |
<Head> | |
<title>Event X</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<Hero /> | |
<EventList events={collection} /> | |
<div className="mt-10 h-20 "></div> | |
{collection.length > 0 && eventsData.length > collection.length && ( | |
<div className="w-full flex justify-center items-center"> | |
<button | |
className="bg-orange-500 shadow-md rounded-full py-3 px-4 | |
text-white duration-300 transition-all" | |
onClick={() => setEnd(end + count)} | |
> | |
{' '} | |
Load More | |
</button> | |
</div> | |
)} | |
</div> | |
) | |
} | |
export default Page | |
export const getServerSideProps = async () => { | |
const eventsData: EventStruct[] = await getEvents() | |
return { | |
props: { eventsData: JSON.parse(JSON.stringify(eventsData)) }, | |
} | |
} |
Take a moment and observe how we implemented the load more button, I bet you’ll find it useful.
No 2: Displaying User’s Events
Update pages/events/personal.tsx
to get data from the getMyEvents()
function.
import EventList from '@/components/EventList' | |
import { getMyEvents } from '@/services/blockchain' | |
import { EventStruct } from '@/utils/type.dt' | |
import { NextPage } from 'next' | |
import Head from 'next/head' | |
import { useEffect, useState } from 'react' | |
const Page: NextPage = () => { | |
const [end, setEnd] = useState<number>(6) | |
const [count] = useState<number>(6) | |
const [collection, setCollection] = useState<EventStruct[]>([]) | |
const [events, setEvents] = useState<EventStruct[]>([]) | |
useEffect(() => { | |
setCollection(events.slice(0, end)) | |
}, [events, end]) | |
useEffect(() => { | |
const fetchData = async () => { | |
const events: EventStruct[] = await getMyEvents() | |
setEvents(events) | |
} | |
fetchData() | |
}, []) | |
return ( | |
<div> | |
<Head> | |
<title>Event X | Personal</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<EventList events={collection} /> | |
<div className="mt-10 h-20 "></div> | |
{collection.length > 0 && events.length > collection.length && ( | |
<div className="w-full flex justify-center items-center"> | |
<button | |
className="bg-orange-500 shadow-md rounded-full py-3 px-4 | |
text-white duration-300 transition-all" | |
onClick={() => setEnd(end + count)} | |
> | |
{' '} | |
Load More | |
</button> | |
</div> | |
)} | |
</div> | |
) | |
} | |
export default Page |
No 3: Creating New Event
Update pages/events/create.tsx
to use the createEvent()
function for form submission..
import { createEvent } from '@/services/blockchain' | |
import { EventParams } 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 [event, setEvent] = useState<EventParams>({ | |
title: '', | |
imageUrl: '', | |
description: '', | |
ticketCost: '', | |
capacity: '', | |
startsAt: '', | |
endsAt: '', | |
}) | |
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
const { name, value } = e.target | |
setEvent((prevState) => ({ | |
...prevState, | |
[name]: value, | |
})) | |
} | |
const handleSubmit = async (e: FormEvent) => { | |
e.preventDefault() | |
if (!address) return toast.warn('Connect wallet first') | |
event.startsAt = new Date(event.startsAt).getTime() | |
event.endsAt = new Date(event.endsAt).getTime() | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
createEvent(event) | |
.then((tx) => { | |
console.log(tx) | |
resetForm() | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Event creation successful 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const resetForm = () => { | |
setEvent({ | |
title: '', | |
imageUrl: '', | |
description: '', | |
ticketCost: '', | |
capacity: '', | |
startsAt: '', | |
endsAt: '', | |
}) | |
} | |
return ( | |
<div> | |
<Head> | |
<title>Event X | Create</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<main className="lg:w-2/3 w-full mx-auto bg-white p-5 shadow-md"> | |
<form onSubmit={handleSubmit} className="flex flex-col text-black"> | |
<div className="flex flex-row justify-between items-center mb-5"> | |
<p className="font-semibold">Create Event</p> | |
</div> | |
{event.imageUrl && ( | |
<div className="flex flex-row justify-center items-center rounded-xl"> | |
<div className="shrink-0 rounded-xl overflow-hidden h-20 w-20 shadow-md"> | |
<img src={event.imageUrl} alt={event.title} className="h-full object-cover" /> | |
</div> | |
</div> | |
)} | |
<div className="flex flex-row justify-between items-center bg-gray-200 rounded-xl mt-5 p-2"> | |
<input | |
className="block w-full text-sm bg-transparent border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="title" | |
placeholder="Title" | |
value={event.title} | |
onChange={handleChange} | |
/> | |
</div> | |
<div | |
className="flex flex-col sm:flex-row justify-between items-center w-full | |
space-x-0 sm:space-x-2 space-y-5 sm:space-y-0 mt-5" | |
> | |
<div className="w-full bg-gray-200 rounded-xl p-2"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
step={1} | |
min={1} | |
name="capacity" | |
placeholder="Capacity" | |
value={event.capacity} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
</div> | |
<div className="w-full bg-gray-200 rounded-xl p-2"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
step="0.01" | |
min="0.01" | |
name="ticketCost" | |
placeholder="Ticket cost (ETH)" | |
value={event.ticketCost} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
</div> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-200 rounded-xl mt-5 p-2"> | |
<input | |
className="block w-full text-sm bg-transparent border-0 focus:outline-none focus:ring-0" | |
type="url" | |
name="imageUrl" | |
placeholder="ImageURL" | |
pattern="https?://.+(\.(jpg|png|gif))?$" | |
value={event.imageUrl} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
<div | |
className="flex flex-col sm:flex-row justify-between items-center w-full | |
space-x-0 sm:space-x-2 space-y-5 sm:space-y-0 mt-5" | |
> | |
<div className="w-full bg-gray-200 rounded-xl p-2"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
placeholder="Start Date" | |
className="bg-transparent outline-none w-full placeholder-[#3D3857] text-sm border-none focus:outline-none focus:ring-0 py-0" | |
name="startsAt" | |
type="datetime-local" | |
value={event.startsAt} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
</div> | |
<div className="w-full bg-gray-200 rounded-xl p-2"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
placeholder="End Date" | |
className="bg-transparent outline-none w-full placeholder-[#3D3857] text-sm border-none focus:outline-none focus:ring-0 py-0" | |
name="endsAt" | |
type="datetime-local" | |
value={event.endsAt} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
</div> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-200 rounded-xl mt-5 p-2"> | |
<textarea | |
className="block w-full text-sm resize-none | |
bg-transparent border-0 focus:outline-none focus:ring-0 h-20" | |
name="description" | |
placeholder="Description" | |
value={event.description} | |
onChange={handleChange} | |
required | |
></textarea> | |
</div> | |
<div className="mt-5"> | |
<button | |
type="submit" | |
className="bg-orange-500 p-2 rounded-full py-3 px-10 | |
text-white hover:bg-transparent border hover:text-orange-500 | |
hover:border-orange-500 duration-300 transition-all" | |
> | |
Submit | |
</button> | |
</div> | |
</form> | |
</main> | |
</div> | |
) | |
} | |
export default Page |
No 4: Displaying Single Event
Update pages/events/[id].tsx
to get data from the getEvent()
and getTickets()
functions.
import Head from 'next/head' | |
import Link from 'next/link' | |
import Moment from 'react-moment' | |
import BuyTicket from '@/components/BuyTicket' | |
import Identicon from 'react-identicons' | |
import { GetServerSidePropsContext, NextPage } from 'next' | |
import { BsDot } from 'react-icons/bs' | |
import { FaEthereum } from 'react-icons/fa' | |
import { getEvent, getTickets } from '@/services/blockchain' | |
import { EventStruct, RootState, TicketStruct } from '@/utils/type.dt' | |
import { calculateDateDifference, formatDate, getExpiryDate, truncate } from '@/utils/helper' | |
import { useAccount } from 'wagmi' | |
import { useEffect } from 'react' | |
import { useDispatch, useSelector } from 'react-redux' | |
import { globalActions } from '@/store/globalSlices' | |
import EventActions from '@/components/EventAction' | |
interface ComponentProps { | |
eventData: EventStruct | |
ticketsData: TicketStruct[] | |
} | |
const Page: NextPage<ComponentProps> = ({ eventData, ticketsData }) => { | |
const dispatch = useDispatch() | |
const { address } = useAccount() | |
const { event, tickets } = useSelector((states: RootState) => states.globalStates) | |
const { setEvent, setTickets, setTicketModal } = globalActions | |
useEffect(() => { | |
dispatch(setEvent(eventData)) | |
dispatch(setTickets(ticketsData)) | |
}, [dispatch, setEvent, eventData, setTickets, ticketsData]) | |
return event ? ( | |
<div> | |
<Head> | |
<title>Event X | {event.title}</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<section className="flex justify-center items-center flex-col flex-wrap p-6"> | |
<main | |
className="lg:w-2/3 w-full mx-auto flex justify-start items-center | |
flex-col sm:space-x-3" | |
> | |
<div className="w-full shadow-md sm:shadow-sm"> | |
<img src={event.imageUrl} alt={event.title} className="w-full h-[500px] object-cover" /> | |
</div> | |
<div className="w-full"> | |
<div className="flex flex-wrap justify-start items-center space-x-2 mt-4"> | |
<h3 className="text-gray-900 text-3xl font-bold capitalize ">{event.title}</h3> | |
{!event.minted ? ( | |
<span className="bg-orange-600 text-white rounded-xl px-4">Open</span> | |
) : ( | |
<span className="bg-cyan-600 text-white rounded-xl px-4">Minted</span> | |
)} | |
</div> | |
<div className="flex justify-start items-center space-x-1 font-medium text-sm"> | |
{Date.now() < event.endsAt && ( | |
<> | |
<span>{calculateDateDifference(event.endsAt, Date.now())} remaining</span> | |
<BsDot size={30} /> | |
</> | |
)} | |
<span>{event.capacity - event.seats} seat(s) left</span> | |
</div> | |
<p className="mt-4">{event.description}</p> | |
<div className="flex flex-col sm:flex-row justify-start sm:items-center my-4"> | |
<div className="flex justify-start items-center"> | |
<FaEthereum /> | |
<p className=" font-bold">{event.ticketCost.toFixed(2)} ETH </p> | |
</div> | |
<span className="pl-4 hidden sm:flex">|</span> | |
<div className="text-sm sm:text-lg sm:mx-4 mt-2 sm:mt-0"> | |
{event.startsAt > Date.now() && ( | |
<p className="text-gray-600">Starts on {formatDate(event.startsAt)}</p> | |
)} | |
{Date.now() > event.startsAt && getExpiryDate(event.endsAt) !== 0 && ( | |
<p className="text-orange-500">Ends in {getExpiryDate(event.endsAt)} days</p> | |
)} | |
{Date.now() > event.endsAt && <p className="text-red-500">Expired</p>} | |
</div> | |
</div> | |
<div className="flex justify-start items-center space-x-4 my-8"> | |
{event.endsAt > Date.now() && ( | |
<button | |
className="bg-orange-500 p-2 rounded-full py-3 px-10 | |
text-white hover:bg-transparent border hover:text-orange-500 | |
hover:border-orange-500 duration-300 transition-all" | |
onClick={() => dispatch(setTicketModal('scale-100'))} | |
> | |
Buy Ticket | |
</button> | |
)} | |
{address === event.owner && <EventActions event={event} />} | |
</div> | |
<h4 className="text-xl mt-10 mb-5">Recent Purchase ({tickets.length})</h4> | |
{tickets.slice(0, 4).map((ticket, i) => ( | |
<div | |
className="flex justify-start items-between space-x-4 w-full py-5 | |
border-b border-gray-200" | |
key={i} | |
> | |
<div className="flex justify-start items-center space-x-2"> | |
<Identicon | |
className="rounded-full overflow-hidden shadow-md" | |
size={30} | |
string={ticket.owner} | |
/> | |
<p className="font-semibold"> | |
{truncate({ | |
text: ticket.owner, | |
startChars: 4, | |
endChars: 4, | |
maxLength: 11, | |
})} | |
</p> | |
</div> | |
<div className="flex justify-end items-center w-full"> | |
<div className="flex justify-start items-center"> | |
<span className="flex items-center"> | |
<FaEthereum /> <span>{ticket.ticketCost.toFixed(2)}</span> | |
</span> | |
<BsDot size={30} /> | |
<Moment className="text-gray-500" fromNow> | |
{ticket.timestamp} | |
</Moment> | |
</div> | |
</div> | |
</div> | |
))} | |
<div className="flex justify-start items-center space-x-4 my-8"> | |
<Link | |
href={'/events/tickets/' + event.id} | |
className="bg-[#010125] p-2 rounded-full py-3 px-10 | |
text-white border hover:bg-transparent hover:text-[#010125] | |
hover:border-[#010125] duration-300 transition-all" | |
> | |
All Sales | |
</Link> | |
</div> | |
</div> | |
</main> | |
</section> | |
<BuyTicket event={event} /> | |
</div> | |
) : ( | |
<p>Loading...</p> | |
) | |
} | |
export default Page | |
export const getServerSideProps = async (context: GetServerSidePropsContext) => { | |
const { id } = context.query | |
const eventData: EventStruct = await getEvent(Number(id)) | |
const ticketsData: TicketStruct[] = await getTickets(Number(id)) | |
return { | |
props: { | |
eventData: JSON.parse(JSON.stringify(eventData)), | |
ticketsData: JSON.parse(JSON.stringify(ticketsData)), | |
}, | |
} | |
} |
No 5: Editing a Single Event
Change pages/events/edit/[id].tsx
to retrieve data from the getEvent()
and update by calling updateEvent()
.
import { getEvent, updateEvent } from '@/services/blockchain' | |
import { timestampToDatetimeLocal } from '@/utils/helper' | |
import { EventParams, EventStruct } from '@/utils/type.dt' | |
import { GetServerSidePropsContext, NextPage } from 'next' | |
import Head from 'next/head' | |
import Link from 'next/link' | |
import { ChangeEvent, FormEvent, useState } from 'react' | |
import { toast } from 'react-toastify' | |
import { useAccount } from 'wagmi' | |
const Page: NextPage<{ eventData: EventStruct }> = ({ eventData }) => { | |
const { address } = useAccount() | |
const [event, setEvent] = useState<EventParams>({ | |
...eventData, | |
startsAt: timestampToDatetimeLocal(eventData.startsAt), | |
endsAt: timestampToDatetimeLocal(eventData.endsAt), | |
}) | |
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
const { name, value } = e.target | |
setEvent((prevState) => ({ | |
...prevState, | |
[name]: value, | |
})) | |
} | |
const handleSubmit = async (e: FormEvent) => { | |
e.preventDefault() | |
if (!address) return toast.warn('Connect wallet first') | |
event.startsAt = new Date(event.startsAt).getTime() | |
event.endsAt = new Date(event.endsAt).getTime() | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
updateEvent(event) | |
.then((tx) => { | |
console.log(tx) | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Event updated successful 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<div> | |
<Head> | |
<title>Event X | Edit</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<main className="lg:w-2/3 w-full mx-auto bg-white p-5 shadow-md"> | |
<form onSubmit={handleSubmit} className="flex flex-col text-black"> | |
<div className="flex flex-row justify-between items-center mb-5"> | |
<p className="font-semibold">Edit Event</p> | |
</div> | |
{event.imageUrl && ( | |
<div className="flex flex-row justify-center items-center rounded-xl"> | |
<div className="shrink-0 rounded-xl overflow-hidden h-20 w-20 shadow-md"> | |
<img src={event.imageUrl} alt={event.title} className="h-full object-cover" /> | |
</div> | |
</div> | |
)} | |
<div className="flex flex-row justify-between items-center bg-gray-200 rounded-xl mt-5 p-2"> | |
<input | |
className="block w-full text-sm bg-transparent border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="title" | |
placeholder="Title" | |
value={event.title} | |
onChange={handleChange} | |
/> | |
</div> | |
<div | |
className="flex flex-col sm:flex-row justify-between items-center w-full | |
space-x-0 sm:space-x-2 space-y-5 sm:space-y-0 mt-5" | |
> | |
<div className="w-full bg-gray-200 rounded-xl p-2"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
step={1} | |
min={1} | |
name="capacity" | |
placeholder="Capacity" | |
value={event.capacity} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
</div> | |
<div className="w-full bg-gray-200 rounded-xl p-2"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
step="0.01" | |
min="0.01" | |
name="ticketCost" | |
placeholder="Ticket cost (ETH)" | |
value={event.ticketCost} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
</div> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-200 rounded-xl mt-5 p-2"> | |
<input | |
className="block w-full text-sm bg-transparent border-0 focus:outline-none focus:ring-0" | |
type="url" | |
name="imageUrl" | |
placeholder="ImageURL" | |
pattern="https?://.+(\.(jpg|png|gif))?$" | |
value={event.imageUrl} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
<div | |
className="flex flex-col sm:flex-row justify-between items-center w-full | |
space-x-0 sm:space-x-2 space-y-5 sm:space-y-0 mt-5" | |
> | |
<div className="w-full bg-gray-200 rounded-xl p-2"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
placeholder="Start Date" | |
className="bg-transparent outline-none w-full placeholder-[#3D3857] text-sm border-none focus:outline-none focus:ring-0 py-0" | |
name="startsAt" | |
type="datetime-local" | |
value={event.startsAt} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
</div> | |
<div className="w-full bg-gray-200 rounded-xl p-2"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
placeholder="End Date" | |
className="bg-transparent outline-none w-full placeholder-[#3D3857] text-sm border-none focus:outline-none focus:ring-0 py-0" | |
name="endsAt" | |
type="datetime-local" | |
value={event.endsAt} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
</div> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-200 rounded-xl mt-5 p-2"> | |
<textarea | |
className="block w-full text-sm resize-none | |
bg-transparent border-0 focus:outline-none focus:ring-0 h-20" | |
name="description" | |
placeholder="Description" | |
value={event.description} | |
onChange={handleChange} | |
required | |
></textarea> | |
</div> | |
<div className="flex space-x-2 mt-5"> | |
<button | |
type="submit" | |
className="bg-orange-500 p-2 rounded-full py-3 px-10 | |
text-white hover:bg-transparent border hover:text-orange-500 | |
hover:border-orange-500 duration-300 transition-all" | |
> | |
Update | |
</button> | |
<Link | |
href={'/events/' + event.id} | |
type="button" | |
className="bg-transparent p-2 rounded-full py-3 px-5 | |
text-black hover:bg-orange-500 hover:text-white | |
duration-300 transition-all flex justify-start items-center | |
space-x-2 border border-black hover:border-orange-500" | |
> | |
Back | |
</Link> | |
</div> | |
</form> | |
</main> | |
</div> | |
) | |
} | |
export default Page | |
export const getServerSideProps = async (context: GetServerSidePropsContext) => { | |
const { id } = context.query | |
const eventData: EventStruct = await getEvent(Number(id)) | |
return { | |
props: { | |
eventData: JSON.parse(JSON.stringify(eventData)), | |
}, | |
} | |
} |
No 6: Displaying Event Tickets
Update pages/events/tickets/[id].tsx
to retrieve data from the getTickets()
function.
import Ticket from '@/components/Tickets' | |
import { getTickets } from '@/services/blockchain' | |
import { TicketStruct } from '@/utils/type.dt' | |
import { GetServerSidePropsContext, NextPage } from 'next' | |
import Head from 'next/head' | |
import Link from 'next/link' | |
import { useRouter } from 'next/router' | |
const Page: NextPage<{ ticketsData: TicketStruct[] }> = ({ ticketsData }) => { | |
const router = useRouter() | |
const { id } = router.query | |
return ( | |
<div> | |
<Head> | |
<title>Event X | Tickets</title> | |
<link rel="icon" href="/favicon.ico" /> | |
</Head> | |
<section className="flex justify-center items-center flex-col flex-wrap p-6"> | |
<Ticket tickets={ticketsData} /> | |
<div className="mt-5 sm:mt-8 sm:flex sm:justify-center lg:justify-start space-x-2"> | |
<Link | |
href={'/events/' + id} | |
className="bg-[#010125] p-2 rounded-full py-3 px-10 | |
text-white border hover:bg-transparent hover:text-[#010125] | |
hover:border-[#010125] duration-300 transition-all" | |
> | |
Back | |
</Link> | |
</div> | |
</section> | |
</div> | |
) | |
} | |
export default Page | |
export const getServerSideProps = async (context: GetServerSidePropsContext) => { | |
const { id } = context.query | |
const ticketsData: TicketStruct[] = await getTickets(Number(id)) | |
return { | |
props: { | |
ticketsData: JSON.parse(JSON.stringify(ticketsData)), | |
}, | |
} | |
} |
Components with Smart Contract
Let's apply the same approach we used for the previous pages and update the following components to interact with the smart contract.
No 1: Purchasing Ticket
Update components/BuyTicket.tsx
file to use the handleSubmit()
function to call the buyTicket()
function.
import { buyTicket } from '@/services/blockchain' | |
import { globalActions } from '@/store/globalSlices' | |
import { EventStruct, RootState } from '@/utils/type.dt' | |
import React, { FormEvent, useState } from 'react' | |
import { FaTimes } from 'react-icons/fa' | |
import { useDispatch, useSelector } from 'react-redux' | |
import { toast } from 'react-toastify' | |
import { useAccount } from 'wagmi' | |
const BuyTicket: React.FC<{ event: EventStruct }> = ({ event }) => { | |
const { ticketModal } = useSelector((states: RootState) => states.globalStates) | |
const { setTicketModal } = globalActions | |
const { address } = useAccount() | |
const dispatch = useDispatch() | |
const [tickets, setTickets] = useState<number | string>('') | |
const handleSubmit = async (e: FormEvent) => { | |
e.preventDefault() | |
if (!address) return toast.warn('Connect wallet first') | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
buyTicket(event, Number(tickets)) | |
.then((tx) => { | |
dispatch(setTicketModal('scale-0')) | |
setTickets('') | |
console.log(tx) | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Ticket purchased successful 👌', | |
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-50 transition-transform duration-300 ${ticketModal}`} | |
> | |
<div className="bg-white text-black shadow-md shadow-orange-500 rounded-xl w-11/12 md:w-2/5 h-7/12 p-6"> | |
<div className="flex flex-col"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-semibold">Buy Tickets</p> | |
<button | |
onClick={() => dispatch(setTicketModal('scale-0'))} | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<FaTimes /> | |
</button> | |
</div> | |
<form onSubmit={handleSubmit} className="flex flex-col justify-center items-start my-5"> | |
<div className="w-full bg-gray-200 rounded-xl p-2 mb-5"> | |
<div | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
> | |
<input | |
className="block w-full text-sm bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
step="1" | |
min="1" | |
name="tickets" | |
placeholder="Ticket e.g 3" | |
value={tickets} | |
onChange={(e) => setTickets(e.target.value)} | |
required | |
/> | |
</div> | |
</div> | |
<button | |
type="submit" | |
className="bg-orange-500 p-2 rounded-full py-3 px-10 | |
text-white hover:bg-transparent border hover:text-orange-500 | |
hover:border-orange-500 duration-300 transition-all" | |
> | |
Buy Now | |
</button> | |
</form> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default BuyTicket |
No 2: Deleting and Paying out Events
Lastly, update components/EventAction.tsx
file to use the deleteEvent()
and payout()
functions.
import { Menu } from '@headlessui/react' | |
import { BsTrash3 } from 'react-icons/bs' | |
import { BiDotsVerticalRounded } from 'react-icons/bi' | |
import React from 'react' | |
import Link from 'next/link' | |
import { toast } from 'react-toastify' | |
import { useAccount } from 'wagmi' | |
import { EventStruct } from '@/utils/type.dt' | |
import { GrEdit } from 'react-icons/gr' | |
import { FiDollarSign } from 'react-icons/fi' | |
import { deleteEvent, payout } from '@/services/blockchain' | |
import { useRouter } from 'next/router' | |
const EventActions: React.FC<{ event: EventStruct }> = ({ event }) => { | |
const { address } = useAccount() | |
const router = useRouter() | |
const handleDelete = async () => { | |
if (!address) return toast.warn('Connect wallet first') | |
const userConfirmed = window.confirm('Are you sure you want to delete this event?') | |
if (!userConfirmed) return | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
deleteEvent(event.id) | |
.then((tx) => { | |
console.log(tx) | |
router.push('/') | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Event deleted successful 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const handlePayout = async () => { | |
if (!address) return toast.warn('Connect wallet first') | |
const userConfirmed = window.confirm('Are you sure you want to payout this event?') | |
if (!userConfirmed) return | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
payout(event.id) | |
.then((tx) => { | |
console.log(tx) | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Event paidout successful 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<Menu as="div" className="inline-block text-left text-gray-300 relative"> | |
<Menu.Button | |
className="bg-transparent p-2 rounded-full py-3 px-5 | |
text-black hover:bg-orange-500 hover:text-white | |
duration-300 transition-all flex justify-start items-center | |
space-x-2 border border-black hover:border-orange-500" | |
> | |
<BiDotsVerticalRounded size={17} /> <span>More</span> | |
</Menu.Button> | |
<Menu.Items | |
className="absolute right-0 w-56 origin-top-right | |
divide-y divide-gray-300 rounded-md bg-white shadow-md | |
ing-1 ring-gray-300 ring-opacity-5 focus:outline-none" | |
> | |
{address == event.owner && ( | |
<> | |
<Menu.Item> | |
{({ active }) => ( | |
<Link | |
href={'/events/edit/' + event.id} | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'text-orange-700' : 'text-black' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
> | |
<GrEdit size={17} /> | |
<span>Edit</span> | |
</Link> | |
)} | |
</Menu.Item> | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-green-700' : 'text-green-700' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={handlePayout} | |
> | |
<FiDollarSign size={17} /> | |
<span>Payout</span> | |
</button> | |
)} | |
</Menu.Item> | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-red-700' : 'text-red-700' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={handleDelete} | |
> | |
<BsTrash3 size={17} /> | |
<span>Delete</span> | |
</button> | |
)} | |
</Menu.Item> | |
</> | |
)} | |
</Menu.Items> | |
</Menu> | |
) | |
} | |
export default EventActions |
It should be noted that this component is a dropdown and contains the relevant actions for managing a specific event.
All the components and pages of the project have been successfully connected to the smart contract, indicating the completion of the project after implementing these updates.
If your Next.js server was offline, you can bring it back up by executing the command yarn dev
.
For further learning, we recommends subscribing to our YouTube channel and visiting our website for additional resources.
Conclusion
In this tutorial, we have successfully built a Web3 Events Marketplace using Next.js, TypeScript, and Solidity. We've established the development environment, constructed the Redux store, and deployed our smart contract to our local chain.
We've created dynamic interfaces, crafted Ethereum smart contracts, and managed shared data with Redux. By integrating the smart contract with the frontend, we've enabled users to list and participate in events, with transactions managed and secured by Ethereum smart contracts.
Now, you're equipped with the skills to build your own Web3 Events Marketplace. We've also provided you with a live demo and the finished work in our git repo for reference. Happy coding!
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)