What you will be building, see the live demo at sepolia test net and the git repo.
Introduction
Are you a movie fan who wants to be able to book tickets to your favorite films without having to go through a third-party website? Or are you someone who is involved in the movie business and wants to build a more secure and transparent way to sell tickets? If so, then this tutorial is for you.
We will use React, Solidity, and CometChat to build our dapp. React is a JavaScript library that is used to build user interfaces. Solidity is a programming language that is used to write smart contracts on the Ethereum blockchain. CometChat is a real-time communication platform that allows users to chat with each other in real time.
By the end of this tutorial, you will have learned how to:
- Build a React app
- Deploy a Solidity smart contract
- Use CometChat to add chat functionality to a DApp
So what are you waiting for? Let's get started!
Prerequisites
You will need the following tools installed to build along with me:
- Node.js
- Yarn
- MetaMask
- React
- Solidity
- CometChat SDK
- Tailwind CSS
To set up your MetaMask for this project, I recommend that you can watch the video below.
I hope you enjoy this tutorial!
Installing Dependencies
Clone the starter kit and open it in VS Code using the command below:
git clone https://github.com/Daltonic/tailwind_ethers_starter_kit dappCinemas
cd dappCinemas
Next, update the package.json with the snippet below.
{ | |
"name": "dappcinemas", | |
"private": true, | |
"version": "0.0.0", | |
"scripts": { | |
"start": "react-app-rewired start", | |
"build": "react-app-rewired build", | |
"test": "react-app-rewired test", | |
"eject": "react-scripts eject", | |
"dev": "yarn hardhat run scripts/deploy.js && yarn start" | |
}, | |
"dependencies": { | |
"@cometchat-pro/chat": "3.0.11", | |
"@emotion/react": "^11.10.5", | |
"@emotion/styled": "^11.10.5", | |
"@headlessui/react": "^1.7.15", | |
"@mui/icons-material": "^5.11.0", | |
"@mui/material": "^5.11.6", | |
"@nomiclabs/hardhat-ethers": "^2.1.0", | |
"@nomiclabs/hardhat-waffle": "^2.0.3", | |
"ethereum-waffle": "^3.4.4", | |
"ethers": "^5.6.9", | |
"hardhat": "^2.10.1", | |
"moment": "^2.29.4", | |
"react": "^18.2.0", | |
"react-datepicker": "^4.10.0", | |
"react-dom": "^18.2.0", | |
"react-hooks-global-state": "^1.0.2", | |
"react-icons": "^4.3.1", | |
"react-identicons": "^1.2.5", | |
"react-moment": "^1.1.2", | |
"react-router-dom": "6", | |
"react-scripts": "5.0.0", | |
"react-toastify": "^9.1.1", | |
"web-vitals": "^2.1.4" | |
}, | |
"devDependencies": { | |
"@openzeppelin/contracts": "^4.5.0", | |
"@tailwindcss/forms": "0.4.0", | |
"assert": "^2.0.0", | |
"autoprefixer": "10.4.2", | |
"babel-polyfill": "^6.26.0", | |
"babel-preset-env": "^1.7.0", | |
"babel-preset-es2015": "^6.24.1", | |
"babel-preset-stage-2": "^6.24.1", | |
"babel-preset-stage-3": "^6.24.1", | |
"babel-register": "^6.26.0", | |
"buffer": "^6.0.3", | |
"chai": "^4.3.6", | |
"chai-as-promised": "^7.1.1", | |
"crypto-browserify": "^3.12.0", | |
"dotenv": "^16.0.0", | |
"https-browserify": "^1.0.0", | |
"mnemonics": "^1.1.3", | |
"os-browserify": "^0.3.0", | |
"postcss": "8.4.5", | |
"process": "^0.11.10", | |
"react-app-rewired": "^2.1.11", | |
"stream-browserify": "^3.0.0", | |
"stream-http": "^3.2.0", | |
"tailwindcss": "3.0.18", | |
"url": "^0.11.0" | |
}, | |
"browserslist": { | |
"production": [ | |
">0.2%", | |
"not dead", | |
"not op_mini all" | |
], | |
"development": [ | |
"last 1 chrome version", | |
"last 1 firefox version", | |
"last 1 safari version" | |
] | |
} | |
} |
Next, execute the command “yarn install
" in your terminal to install the above dependencies for this project.
Configuring CometChat SDK
To configure the CometChat SDK, please follow the steps provided below. Once completed, make sure to save the generated keys as environment variables for future use.
STEP 1:
Head to CometChat Dashboard and create an account.
STEP 2:
Log in to the CometChat dashboard, only after registering.
STEP 3:
From the dashboard, add a new app called DappCinemas.
STEP 4:
Select the app you just created from the list.
STEP 5:
From the Quick Start copy the APP_ID
, REGION
, and AUTH_KEY
, to your .env
file. See the image and code snippet.
Replace the REACT_COMET_CHAT
placeholder keys with their appropriate values.
REACT_APP_COMETCHAT_APP_ID=****************
REACT_APP_COMETCHAT_AUTH_KEY=******************************
REACT_APP_COMETCHAT_REGION=**
The .env
file should be created at the root of your project.
Configuring the Hardhat script
Navigate to the root directory of the project and open the "hardhat.config.js
" file. Replace the existing content of the file with the provided settings.
require("@nomiclabs/hardhat-waffle"); | |
require('dotenv').config() | |
module.exports = { | |
defaultNetwork: "localhost", | |
networks: { | |
localhost: { | |
url: "http://127.0.0.1:8545" | |
}, | |
}, | |
solidity: { | |
version: '0.8.11', | |
settings: { | |
optimizer: { | |
enabled: true, | |
runs: 200 | |
} | |
} | |
}, | |
paths: { | |
sources: "./src/contracts", | |
artifacts: "./src/abis" | |
}, | |
mocha: { | |
timeout: 40000 | |
} | |
} |
This code configures Hardhat for your project. It includes importing necessary plugins, setting up networks (with localhost as the default), specifying the Solidity compiler version, defining paths for contracts and artifacts, and setting a timeout for Mocha tests.
The Smart Contract File
To proceed with creating the smart contract for this project, please follow these steps:
- In the root directory of your project, create a new folder named
contracts
inside thesrc
folder. This folder will hold all the smart contract files. - Inside the
contracts
folder, create a new file namedDappCinemas.sol
. This file will contain the code that defines the functionality of the smart contract. - Copy the provided code and paste it into the
DappCinemas.sol
file. - Save the file to ensure that the changes are applied.
By following these steps, you will have successfully set up the necessary directory structure and created the DappCinemas.sol
file, which will serve as the foundation for implementing the logic of the smart contract.
//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/security/ReentrancyGuard.sol"; | |
contract DappCinemas is Ownable { | |
using Counters for Counters.Counter; | |
Counters.Counter private _totalMovies; | |
Counters.Counter private _totalTickets; | |
Counters.Counter private _totalSlots; | |
struct MovieStruct { | |
uint256 id; | |
string name; | |
string imageUrl; | |
string genre; | |
string description; | |
uint256 timestamp; | |
bool deleted; | |
} | |
struct TicketStruct { | |
uint256 id; | |
uint256 movieId; | |
uint256 slotId; | |
address owner; | |
uint256 cost; | |
uint256 timestamp; | |
uint256 day; | |
bool refunded; | |
} | |
struct TimeSlotStruct { | |
uint256 id; | |
uint256 movieId; | |
uint256 ticketCost; | |
uint256 startTime; | |
uint256 endTime; | |
uint256 capacity; | |
uint256 seats; | |
bool deleted; | |
bool completed; | |
uint256 day; | |
uint256 balance; | |
} | |
event Action(string actionType); | |
uint256 public balance; | |
mapping(uint256 => bool) movieExists; | |
mapping(uint256 => MovieStruct) movies; | |
mapping(uint256 => TimeSlotStruct) movieTimeSlot; | |
mapping(uint256 => mapping(uint256 => address[])) ticketHolder; | |
function addMovie( | |
string memory name, | |
string memory imageUrl, | |
string memory genre, | |
string memory description | |
) public onlyOwner { | |
require(bytes(name).length > 0, "Movie name required"); | |
require(bytes(imageUrl).length > 0, "Movie image url required"); | |
require(bytes(genre).length > 0, "Movie genre required"); | |
require(bytes(description).length > 0, "Movie description required"); | |
_totalMovies.increment(); | |
MovieStruct memory movie; | |
movie.id = _totalMovies.current(); | |
movie.name = name; | |
movie.imageUrl = imageUrl; | |
movie.genre = genre; | |
movie.description = description; | |
movie.timestamp = currentTime(); | |
movies[movie.id] = movie; | |
movieExists[movie.id] = true; | |
emit Action("Movie added successfully"); | |
} | |
function updateMovie( | |
uint256 movieId, | |
string memory name, | |
string memory imageUrl, | |
string memory genre, | |
string memory description | |
) public onlyOwner { | |
require(movieExists[movieId], "Movie doesn't exist!"); | |
require(bytes(name).length > 0, "Movie name required"); | |
require(bytes(imageUrl).length > 0, "Movie image URL required"); | |
require(bytes(genre).length > 0, "Movie genre required"); | |
require(bytes(description).length > 0, "Movie description required"); | |
for (uint256 slotId = 1; slotId <= _totalSlots.current(); slotId++) { | |
address[] storage holders = ticketHolder[movieId][slotId]; | |
require( | |
holders.length == 0, | |
"Cannot update movie with purchased tickets" | |
); | |
} | |
movies[movieId].name = name; | |
movies[movieId].imageUrl = imageUrl; | |
movies[movieId].genre = genre; | |
movies[movieId].description = description; | |
emit Action("Movie updated successfully"); | |
} | |
function deleteMovie(uint256 movieId) public onlyOwner { | |
require(movieExists[movieId], "Movie doesn't exist!"); | |
for (uint256 slotId = 1; slotId <= _totalSlots.current(); slotId++) { | |
address[] memory holders = ticketHolder[movieId][slotId]; | |
require( | |
holders.length == 0, | |
"Cannot delete movie with purchased tickets" | |
); | |
} | |
movies[movieId].deleted = true; | |
movieExists[movieId] = false; | |
} | |
function getMovies() public view returns (MovieStruct[] memory Movies) { | |
uint256 totalMovies; | |
for (uint256 i = 1; i <= _totalMovies.current(); i++) { | |
if (!movies[i].deleted) totalMovies++; | |
} | |
Movies = new MovieStruct[](totalMovies); | |
uint256 j = 0; | |
for (uint256 i = 1; i <= _totalMovies.current(); i++) { | |
if (!movies[i].deleted) { | |
Movies[j] = movies[i]; | |
j++; | |
} | |
} | |
} | |
function getMovie(uint256 id) public view returns (MovieStruct memory) { | |
return movies[id]; | |
} | |
function addTimeslot( | |
uint256 movieId, | |
uint256[] memory ticketCosts, | |
uint256[] memory startTimes, | |
uint256[] memory endTimes, | |
uint256[] memory capacities, | |
uint256[] memory viewingDays | |
) public onlyOwner { | |
require(movieExists[movieId], "Movie not found"); | |
require(ticketCosts.length > 0, "Tickets cost must not be empty"); | |
require(capacities.length > 0, "Capacities must not be empty"); | |
require(startTimes.length > 0, "Start times cost must not be empty"); | |
require(endTimes.length > 0, "End times cost must not be empty"); | |
require(viewingDays.length > 0, "Viewing days must not be empty"); | |
require( | |
ticketCosts.length == viewingDays.length && | |
viewingDays.length == capacities.length && | |
capacities.length == startTimes.length && | |
startTimes.length == endTimes.length && | |
endTimes.length == viewingDays.length, | |
"All parameters must have equal array length" | |
); | |
for (uint i = 0; i < viewingDays.length; i++) { | |
_totalSlots.increment(); | |
TimeSlotStruct memory slot; | |
slot.id = _totalSlots.current(); | |
slot.movieId = movieId; | |
slot.ticketCost = ticketCosts[i]; | |
slot.startTime = startTimes[i]; | |
slot.endTime = endTimes[i]; | |
slot.day = viewingDays[i]; | |
slot.capacity = capacities[i]; | |
movieTimeSlot[slot.id] = slot; | |
} | |
} | |
function deleteTimeSlot(uint256 movieId, uint256 slotId) public onlyOwner { | |
require( | |
movieExists[movieId] && movieTimeSlot[slotId].movieId == movieId, | |
"Movie not found" | |
); | |
require(!movieTimeSlot[slotId].deleted, "Timeslot already deleted"); | |
for (uint i = 0; i < ticketHolder[movieId][slotId].length; i++) { | |
payTo( | |
ticketHolder[movieId][slotId][i], | |
movieTimeSlot[slotId].ticketCost | |
); | |
} | |
movieTimeSlot[slotId].deleted = true; | |
movieTimeSlot[slotId].balance -= | |
movieTimeSlot[slotId].ticketCost * | |
ticketHolder[movieId][slotId].length; | |
delete ticketHolder[movieId][slotId]; | |
} | |
function markTimeSlot(uint256 movieId, uint256 slotId) public onlyOwner { | |
require( | |
movieExists[movieId] && movieTimeSlot[slotId].movieId == movieId, | |
"Movie not found" | |
); | |
require(!movieTimeSlot[slotId].deleted, "Timeslot already deleted"); | |
movieTimeSlot[slotId].completed = true; | |
balance += movieTimeSlot[slotId].balance; | |
movieTimeSlot[slotId].balance = 0; | |
} | |
function getTimeSlotsByDay( | |
uint256 day | |
) public view returns (TimeSlotStruct[] memory MovieSlots) { | |
uint256 available; | |
for (uint i = 0; i < _totalSlots.current(); i++) { | |
if ( | |
movieTimeSlot[i + 1].day == day && !movieTimeSlot[i + 1].deleted | |
) { | |
available++; | |
} | |
} | |
MovieSlots = new TimeSlotStruct[](available); | |
uint256 index; | |
for (uint i = 0; i < _totalSlots.current(); i++) { | |
if ( | |
movieTimeSlot[i + 1].day == day && !movieTimeSlot[i + 1].deleted | |
) { | |
MovieSlots[index].startTime = movieTimeSlot[i + 1].startTime; | |
MovieSlots[index++].endTime = movieTimeSlot[i + 1].endTime; | |
} | |
} | |
} | |
function getTimeSlot( | |
uint256 slotId | |
) public view returns (TimeSlotStruct memory) { | |
return movieTimeSlot[slotId]; | |
} | |
function getTimeSlots( | |
uint256 movieId | |
) public view returns (TimeSlotStruct[] memory MovieSlots) { | |
uint256 available; | |
for (uint i = 0; i < _totalSlots.current(); i++) { | |
if ( | |
movieTimeSlot[i + 1].movieId == movieId && | |
!movieTimeSlot[i + 1].deleted | |
) { | |
available++; | |
} | |
} | |
MovieSlots = new TimeSlotStruct[](available); | |
uint256 index; | |
for (uint i = 0; i < _totalSlots.current(); i++) { | |
if ( | |
movieTimeSlot[i + 1].movieId == movieId && | |
!movieTimeSlot[i + 1].deleted | |
) { | |
MovieSlots[index++] = movieTimeSlot[i + 1]; | |
} | |
} | |
} | |
function buyTicket( | |
uint256 movieId, | |
uint256 slotId, | |
uint256 tickets | |
) public payable { | |
require( | |
movieExists[movieId] && movieTimeSlot[slotId].movieId == movieId, | |
"Movie not found" | |
); | |
require( | |
msg.value >= movieTimeSlot[slotId].ticketCost * tickets, | |
"Insufficient amount" | |
); | |
require( | |
movieTimeSlot[slotId].capacity > movieTimeSlot[slotId].seats, | |
"Out of capacity" | |
); | |
for (uint i = 0; i < tickets; i++) { | |
_totalTickets.increment(); | |
TicketStruct memory ticket; | |
ticket.id = _totalTickets.current(); | |
ticket.cost = movieTimeSlot[slotId].ticketCost; | |
ticket.day = movieTimeSlot[slotId].day; | |
ticket.slotId = slotId; | |
ticket.owner = msg.sender; | |
ticket.timestamp = currentTime(); | |
ticketHolder[movieId][slotId].push(msg.sender); | |
} | |
movieTimeSlot[slotId].seats += tickets; | |
movieTimeSlot[slotId].balance += | |
movieTimeSlot[slotId].ticketCost * | |
tickets; | |
} | |
function getMovieTicketHolders( | |
uint256 movieId, | |
uint256 slotId | |
) public view returns (address[] memory) { | |
return ticketHolder[movieId][slotId]; | |
} | |
function withdrawTo(address to, uint256 amount) public onlyOwner { | |
require(balance >= amount, "Insufficient fund"); | |
balance -= amount; | |
payTo(to, amount); | |
} | |
function payTo(address to, uint256 amount) internal { | |
(bool success, ) = payable(to).call{value: amount}(""); | |
require(success); | |
} | |
function currentTime() internal view returns (uint256) { | |
uint256 newNum = (block.timestamp * 1000) + 1000; | |
return newNum; | |
} | |
} |
Here is an overview of the key components and functions of the smart contract:
**Ownable**
: The contract inherits from the**Ownable**
contract in the OpenZeppelin library, which provides basic access control by allowing only the contract owner to execute certain functions.**Counters**
: The contract uses the**Counters**
library from OpenZeppelin to keep track of the total number of movies, tickets, and time slots created.**MovieStruct**
: This struct defines the properties of a movie, including**id**
,**name**
,**imageUrl**
,**genre**
,**description**
,**timestamp**
, and**deleted**
.**TicketStruct**
: This struct defines the properties of a ticket, including**id**
,**movieId**
,**slotId**
,**owner**
,**cost**
,**timestamp**
,**day**
, and**refunded**
.**TimeSlotStruct**
: This struct defines the properties of a time slot for a movie screening, including**id**
,**movieId**
,**ticketCost**
,**startTime**
,**endTime**
,**capacity**
,**seats**
,**deleted**
,**completed**
,**day**
, and**balance**
.**Action**
event: This event is emitted to indicate successful actions performed within the contract.**balance**
variable: This variable stores the contract's current balance.Various mappings: The contract uses mappings to store and retrieve data related to movies, time slots, and ticket holders.
The contract provides the following functions:
**addMovie**
: Allows the contract owner to add a new movie to the system by providing the necessary details such as**name**
,**imageUrl**
,**genre**
, and**description**
.**updateMovie**
: Allows the contract owner to update the details of an existing movie by specifying the**movieId**
and providing the updated information.**deleteMovie**
: Allows the contract owner to delete a movie from the system by specifying the**movieId**
. However, it can only be deleted if there are no purchased tickets associated with it.**getMovies**
: Retrieves an array of all available movies in the system.**getMovie**
: Retrieves the details of a specific movie by providing its**id**
.**addTimeslot**
: Allows the contract owner to add a time slot for a movie screening by specifying the**movieId**
and providing arrays of**ticketCosts**
,**startTimes**
,**endTimes**
,**capacities**
, and**viewingDays**
for the time slots.**deleteTimeSlot**
: Allows the contract owner to delete a time slot for a movie screening by specifying the**movieId**
and**slotId**
. The function refunds all ticket holders for the canceled time slot.**markTimeSlot**
: Allows the contract owner to mark a time slot as completed by specifying the**movieId**
and**slotId**
. The function adds the balance of the completed time slot to the contract's overall balance.**getTimeSlotsByDay**
: Retrieves an array of time slots available for a specific day by specifying the**day**
.**getTimeSlot**
: Retrieves the details of a specific time slot by providing its**slotId**
.**getTimeSlots**
: Retrieves an array of time slots available for a specific movie by specifying the**movieId**
.**buyTicket**
: Allows users to purchase tickets for a movie by specifying the**movieId**
,**slotId**
, and the number of**tickets**
to purchase. The function checks for availability, verifies the payment amount, and records the ticket details.**getMovieTicketHolders**
: Retrieves an array of ticket holders for a specific movie by providing its**movieId**
.**getTicket**
: Retrieves the details of a specific ticket by providing its**ticketId**
.**refundTicket**
: Allows the contract owner to refund a ticket by specifying the**ticketId**
. The function refunds the ticket owner and updates the ticket's**refunded**
status.**getContractBalance**
: Retrieves the current balance of the contract.**withdrawBalance**
: Allows the contract owner to withdraw the contract's balance.
In a nutshell, the DappCinemas smart contract provides functionality for managing a decentralized cinema ticket booking system on the Ethereum blockchain. It allows the cinema owner to add, update, and delete movies, create time slots for screenings, and enables users to purchase tickets for specific movie screenings. The contract keeps track of movie and time slot details, ticket holders, and manages the financial transactions related to ticket sales.
Here is an opportunity to enhance your proficiency in Solidity, the web3 language. Get hold of your copy of a comprehensive book that will guide you towards mastery in Solidity. Access it now by following this link.
The Test Script
The test script provided below is used to test the functionality of the **DappCinemas**
contract. It contains several test cases grouped into different sections.
In the "Movie Management" section, the script tests creating a movie, updating its details, and deleting a movie.
In the "Showtime Management" section, the script tests adding a showtime for a movie, deleting a showtime, and verifying the availability of showtimes.
In the "Ticket Booking" section, the script tests booking a ticket for a specific movie and showtime, calculating revenue generated by a movie, canceling a booked ticket, and withdrawing funds from the company balance.
The script uses Chai assertions for making assertions and the Hardhat framework for deploying and interacting with the contract. Before each test case, the contract is deployed, and necessary variables and initializations are performed.
At the root of the project, create a folder if not existing called “test”, copy and paste the code below inside of it.
const { expect } = require('chai') | |
const { ethers } = require('hardhat') | |
const toWei = (num) => ethers.utils.parseEther(num.toString()) | |
const fromWei = (num) => ethers.utils.formatEther(num) | |
describe('Contracts', () => { | |
const name = 'Terminator salvation' | |
const imageUrl = 'https://image.png' | |
const genre = 'thrill' | |
const description = 'Really cool movie' | |
const movieId = 1 | |
const slotId = 1 | |
let contract, result | |
beforeEach(async () => { | |
const Contract = await ethers.getContractFactory('DappCinemas') | |
;[deployer, buyer1, buyer2] = await ethers.getSigners() | |
contract = await Contract.deploy() | |
await contract.deployed() | |
}) | |
describe('Movie Management', () => { | |
beforeEach(async () => { | |
await contract.addMovie(name, imageUrl, genre, description) | |
}) | |
it('should create a movie and verify its details', async () => { | |
result = await contract.getMovies() | |
expect(result).to.have.lengthOf(1) | |
result = await contract.getMovie(movieId) | |
expect(result.name).to.be.equal(name) | |
expect(result.imageUrl).to.be.equal(imageUrl) | |
expect(result.genre).to.be.equal(genre) | |
expect(result.description).to.be.equal(description) | |
}) | |
it('should update movie details and verify the changes', async () => { | |
const updatedName = 'Terminator 2: Judgment Day' | |
const updatedImageUrl = 'https://updated-image.jpg' | |
const updatedGenre = 'Action' | |
const updatedDescription = 'One of the best action movies ever' | |
await contract.updateMovie( | |
movieId, | |
updatedName, | |
updatedImageUrl, | |
updatedGenre, | |
updatedDescription | |
) | |
const result = await contract.getMovie(movieId) | |
expect(result.name).to.be.equal(updatedName) | |
expect(result.imageUrl).to.be.equal(updatedImageUrl) | |
expect(result.genre).to.be.equal(updatedGenre) | |
expect(result.description).to.be.equal(updatedDescription) | |
}) | |
it('should delete a movie and ensure it is no longer accessible', async () => { | |
result = await contract.getMovie(movieId) | |
expect(result.deleted).to.be.equal(false) | |
await contract.deleteMovie(movieId) | |
result = await contract.getMovie(movieId) | |
expect(result.deleted).to.be.equal(true) | |
}) | |
}) | |
describe('Showtime Management', () => { | |
const ticketCosts = [toWei(0.02), toWei(0.04)] | |
const days = [1687305600000, 1687405600000] | |
const startTimes = [1687309200000, 1687309200000] | |
const endTimes = [1687314600000, 1687314600000] | |
const capacities = [5, 7] | |
beforeEach(async () => { | |
await contract.addMovie(name, imageUrl, genre, description) | |
await contract.addTimeslot( | |
movieId, | |
ticketCosts, | |
startTimes, | |
endTimes, | |
capacities, | |
days | |
) | |
}) | |
it('should add a showtime and ensure it is added successfully', async () => { | |
result = await contract.getTimeSlots(movieId) | |
expect(result).to.have.lengthOf(2) | |
result = await contract.getTimeSlotsByDay(days[0]) | |
expect(result).to.have.lengthOf(1) | |
result = await contract.getTimeSlot(slotId) | |
expect(result.day).to.be.equal(days[0]) | |
expect(result.startTime).to.be.equal(startTimes[0]) | |
expect(result.endTime).to.be.equal(endTimes[0]) | |
expect(result.ticketCost).to.be.equal(ticketCosts[0]) | |
}) | |
it('should delete a showtime and verify that it is no longer available.', async () => { | |
result = await contract.getTimeSlots(movieId) | |
expect(result).to.have.lengthOf(2) | |
result = await contract.getTimeSlot(slotId) | |
expect(result.deleted).to.be.equal(false) | |
await contract.deleteTimeSlot(movieId, slotId) | |
result = await contract.getTimeSlots(movieId) | |
expect(result).to.have.lengthOf(1) | |
result = await contract.getTimeSlot(slotId) | |
expect(result.deleted).to.be.equal(true) | |
}) | |
}) | |
describe('Ticket Booking', () => { | |
const ticketCosts = [toWei(0.02), toWei(0.04)] | |
const days = [1687305600000, 1687305600000] | |
const startTimes = [1687309200000, 1687309200000] | |
const endTimes = [1687314600000, 1687314600000] | |
const capacities = [5, 7] | |
beforeEach(async () => { | |
await contract.addMovie(name, imageUrl, genre, description) | |
await contract.addTimeslot( | |
movieId, | |
ticketCosts, | |
startTimes, | |
endTimes, | |
capacities, | |
days | |
) | |
}) | |
it('should book a ticket for a specific movie and verify that it is booked successfully', async () => { | |
result = await contract.getMovieTicketHolders(movieId, slotId) | |
expect(result).to.have.lengthOf(0) | |
const tickets = 2 | |
await contract.connect(buyer1).buyTicket(movieId, slotId, tickets, { | |
value: toWei(fromWei(ticketCosts[0]) * tickets), | |
}) | |
result = await contract.getMovieTicketHolders(movieId, slotId) | |
expect(result).to.have.lengthOf(tickets) | |
}) | |
it('should calculate the total revenue generated by a movie, verify and turn over to company balance', async () => { | |
result = await contract.balance() | |
expect(result).to.be.equal(0) | |
result = await contract.getTimeSlot(slotId) | |
expect(result.balance).to.be.equal(0) | |
const tickets = 2 | |
await contract.connect(buyer1).buyTicket(movieId, slotId, tickets, { | |
value: toWei(fromWei(ticketCosts[0]) * tickets), | |
}) | |
result = await contract.balance() | |
expect(result).to.be.equal(0) | |
result = await contract.getTimeSlot(slotId) | |
expect(result.balance).to.be.equal( | |
toWei(fromWei(ticketCosts[0]) * tickets) | |
) | |
await contract.markTimeSlot(movieId, slotId) | |
result = await contract.balance() | |
expect(result).to.be.equal(toWei(fromWei(ticketCosts[0]) * tickets)) | |
result = await contract.getTimeSlot(slotId) | |
expect(result.balance).to.be.equal(0) | |
}) | |
it('should Cancel a booked ticket and verify that it is no longer valid.', async () => { | |
result = await contract.getTimeSlot(slotId) | |
expect(result.balance).to.be.equal(0) | |
const tickets = 2 | |
await contract.connect(buyer1).buyTicket(movieId, slotId, tickets, { | |
value: toWei(fromWei(ticketCosts[0]) * tickets), | |
}) | |
result = await contract.getTimeSlot(slotId) | |
expect(result.balance).to.be.equal( | |
toWei(fromWei(ticketCosts[0]) * tickets) | |
) | |
await contract.deleteTimeSlot(movieId, slotId) | |
result = await contract.getTimeSlot(slotId) | |
expect(result.balance).to.be.equal(0) | |
}) | |
it('should withdraw from company balance and verify account value', async () => { | |
result = await contract.balance() | |
expect(result).to.be.equal(0) | |
const tickets = 2 | |
await contract.connect(buyer1).buyTicket(movieId, slotId, tickets, { | |
value: toWei(fromWei(ticketCosts[0]) * tickets), | |
}) | |
await contract.markTimeSlot(movieId, slotId) | |
result = await contract.balance() | |
expect(result).to.be.equal(toWei(fromWei(ticketCosts[0]) * tickets)) | |
await contract.withdrawTo(deployer.address, ticketCosts[0]) | |
result = await contract.balance() | |
expect(result).to.be.equal(ticketCosts[0]) | |
}) | |
}) | |
}) |
By running **yarn hardhat test**
or **npx hardhat test**
on the terminal will test out all the essential function of this smart contract
The Deployment Script
The hardhat deployment script provided below is responsible for deploying the **DappCinemas**
contract to the blockchain using Hardhat.
It first obtains the contract factory using **ethers.getContractFactory**
, deploys the contract, and waits for it to be deployed successfully. Then, it adds a movie to the deployed contract by invoking the **addMovie**
function with the movie details. After that, it writes the contract address to a JSON file for future reference. Finally, it logs the deployed contract address to the console.
If any errors occur during the deployment process, they are logged, and the script exits with a non-zero exit code.
In the root of the project, create a folder called “scripts” and another file inside of it called “deploy.js
” if it doesn’t yet exist. Copy and paste the code below inside of it.
const { ethers } = require('hardhat') | |
const fs = require('fs') | |
async function main() { | |
const Contract = await ethers.getContractFactory('DappCinemas') | |
const contract = await Contract.deploy() | |
await contract.deployed() | |
// You can remove this part if you wish | |
const name = 'Spider-Man: Across the Spider-Verse' | |
const imageUrl = | |
'https://weliveentertainment.com/wp-content/uploads/2023/05/across-spider-verse-banner-4.jpg' | |
const genre = 'Animated, Action, Adventure, Comedy, Sci-Fi' | |
const description = | |
'Miles Morales returns for an epic adventure across the multiverse, teaming up with Gwen Stacy and a new team of Spider-People to face a new threat.' | |
await contract.addMovie(name, imageUrl, genre, description) | |
// End... | |
const address = JSON.stringify({ address: contract.address }, null, 4) | |
fs.writeFile('./src/abis/contractAddress.json', address, 'utf8', (err) => { | |
if (err) { | |
console.error(err) | |
return | |
} | |
console.log('Deployed contract address', contract.address) | |
}) | |
} | |
main().catch((error) => { | |
console.error(error) | |
process.exitCode = 1 | |
}) |
Next, run the commands below to deploy the smart contract into the network on a terminal.
If you require additional assistance with setting up Hardhat or deploying your Fullstack DApp, I recommend watching this informative video that provides guidance and instructions.
Developing the Frontend
To begin developing the frontend of our application, we will create a new directory called components
in the “src” directory. This folder will contain all the components required for our project. For each of the components listed below, you need to create the corresponding files within the src/components
folder.
Sidebar Component
The above component represents a sidebar for application. It displays a navigation menu with options such as "Movies," "Manage movies," and "Add Movies." It also provides the functionality to connect a wallet and displays the connected account. The component is responsive and hidden on small screens.
Create this component in the components folder, copy and paste the codes below into it.
Header Component
The below code represents a header component for this application. It includes a responsive menu icon that toggles a menu when clicked. The menu contains options such as "Movies," "Manage Movie," and "Add Movie." It also includes a search bar for searching content. The component allows users to connect their wallets and displays the connected account. The menu is hidden on larger screens and displayed on smaller screens. See the codes below, ensure you copy and paste appropriately.
import { React, useState } from 'react' | |
import { Link } from 'react-router-dom' | |
import { MdMenu } from 'react-icons/md' | |
import { AiOutlineClose } from 'react-icons/ai' | |
import { TbSearch } from 'react-icons/tb' | |
import { truncate, useGlobalState } from '../store' | |
import { connectWallet } from '../services/blockchain' | |
const Header = () => { | |
const [toggleMenu, setToggleMenu] = useState(false) | |
return ( | |
<div className="flex flex-col justify-start border-b border-gray-200 p-2"> | |
<div className="flex content-center items-center justify-between w-full"> | |
<div className="flex p-2 sm:hidden"> | |
<Link to="/"> | |
Dapp<span className="text-red-500">Cinemas</span> | |
</Link> | |
</div> | |
<div className="flex space-x-4 p-1"> | |
<form> | |
<div className="hidden sm:flex border border-gray-200 text-gray-500 p-2 items-center rounded-full min-w-[25vw] max-w-[560px]"> | |
<TbSearch size={20} className="hidden md:flex" /> | |
<input | |
placeholder="Search everything" | |
className="border-none flex-1 text-m px-2 outline-none" | |
/> | |
</div> | |
</form> | |
</div> | |
<div className="flex m-4 sm:hidden"> | |
{toggleMenu ? ( | |
<AiOutlineClose | |
size={20} | |
onClick={() => setToggleMenu(false)} | |
className="cursor-pointer" | |
/> | |
) : ( | |
<MdMenu | |
size={30} | |
onClick={() => setToggleMenu(true)} | |
className="cursor-pointer" | |
/> | |
)} | |
</div> | |
</div> | |
{toggleMenu && <Menu />} | |
</div> | |
) | |
} | |
export default Header | |
const Menu = () => { | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
return ( | |
<div className="flex flex-col justify-center w-full p-2 space-y-2 sm:hidden"> | |
<div className="flex space-x-4 p-2 justify-center w-full shadow-md"> | |
<form> | |
<div className="flex border border-gray-200 text-gray-500 p-2 items-center rounded-full min-w-[25vw] max-w-[560px]"> | |
<TbSearch size={20} className="hidden md:flex" /> | |
<input | |
placeholder="Search everything" | |
className="border-none flex-1 text-m px-2 outline-none" | |
/> | |
</div> | |
</form> | |
</div> | |
<div className="flex flex-col space-y-4 items-center text-center w-full "> | |
<Link className="p-2 shadow-md w-full bg-white" to="/"> | |
Movies | |
</Link> | |
<Link className="p-2 shadow-md w-full bg-white" to="/manage/movies"> | |
Manage Movie | |
</Link> | |
<Link className="p-2 shadow-md w-full bg-white" to="/add/movies"> | |
Add Movie | |
</Link> | |
<div className="flex mt-4 list-none"> | |
{connectedAccount ? ( | |
<button | |
type="button" | |
className="inline-block px-8 py-2 border-2 border-red-600 text-xs leading-tight uppercase rounded-full focus:outline-none focus:ring-0 bg-gradient-to-r from-cyan-500 to-red-500 text-white font-bold" | |
> | |
{truncate(connectedAccount, 4, 4, 11)} | |
</button> | |
) : ( | |
<button | |
type="button" | |
className="inline-block px-6 py-2 border-2 border-red-600 font-medium text-xs leading-tight uppercase rounded-full hover:bg-opacity-5 focus:outline-none focus:ring-0 transition duration-150 ease-in-out hover:bg-gradient-to-r from-cyan-500 to-red-500 hover:text-white hover:border-white" | |
onClick={connectWallet} | |
> | |
Connect Wallet | |
</button> | |
)} | |
</div> | |
</div> | |
</div> | |
) | |
} |
Banner Component
The code below represents a component called **Banner**
in this project. This component displays a banner with a background image. The banner contains a title and a subtitle rendered with specific styles. This component is designed to be responsive and has a flex layout to position the title, subtitle, and button within the banner. See the code below.
Movie Cards Component
The **MovieCard**
component is a reusable component in this project that renders a movie card with an image, title, and truncated description. It is wrapped in a **Link**
component from React Router to enable navigation to the movie's details page. The movie image, title, and description are dynamically populated using the **movie**
prop. The component applies responsive styling and visual effects such as rounded corners, shadows, and borders. See the code below.
import { Link } from "react-router-dom" | |
import { truncate } from "../store" | |
const MovieCard = ({ movie }) => { | |
return ( | |
<Link | |
to={`/movie/${movie.id}`} | |
className="flex justify-start flex-row space-x-8 sm:space-x-0 | |
sm:flex-col p-3 space-y-2 shadow-lg w-auto rounded-lg border-2 border-gray-200" | |
> | |
<div className="flex h-full w-auto"> | |
<img | |
src={movie.imageUrl} | |
className="rounded-lg object-cover h-64 w-full" | |
/> | |
</div> | |
<div className="flex flex-col"> | |
<h3 className="font-bold md:text-lg my-2">{movie.name}</h3> | |
<p className="text-gray-600 font-light text-md"> | |
{truncate(movie.description, 74, 0, 77)} | |
</p> | |
</div> | |
</Link> | |
) | |
} | |
export default MovieCard |
Movies Table and Action Components
The **MoviesTable**
component displays a table of movies with information such as image, title, genre, date added, and description. It uses utility functions to format data and includes actions like editing, deleting, and managing time slots for each movie.
The **MovieAction**
component on the other hand renders a dropdown menu with options to edit, delete, add time slots, and view all time slots for a movie. It uses icons and React Router's **Link**
component for visual representation and navigation.
Use the codes below.
import { Menu } from '@headlessui/react' | |
import { FiEdit } from 'react-icons/fi' | |
import { TbCalendarPlus } from 'react-icons/tb' | |
import { FaRegCalendarCheck } from 'react-icons/fa' | |
import { BsTrash3 } from 'react-icons/bs' | |
import { BiDotsVerticalRounded } from 'react-icons/bi' | |
import { setGlobalState } from '../store' | |
import { Link } from 'react-router-dom' | |
const MovieAction = ({ movie }) => { | |
const openEditMovie = () => { | |
setGlobalState('movie', movie) | |
setGlobalState('updateMovieModal', 'scale-100') | |
} | |
const openDeleteMovie = () => { | |
setGlobalState('movie', movie) | |
setGlobalState('deleteMovieModal', 'scale-100') | |
} | |
return ( | |
<Menu as="div" className="inline-block text-left"> | |
<Menu.Button | |
className="inline-flex w-full justify-center | |
rounded-md bg-black bg-opacity-10 px-4 py-2 text-sm | |
font-medium text-black hover:bg-opacity-30 focus:outline-none | |
focus-visible:ring-2 focus-visible:ring-white | |
focus-visible:ring-opacity-75" | |
> | |
<BiDotsVerticalRounded size={17} /> | |
</Menu.Button> | |
<Menu.Items | |
className="absolute right-0 mt-2 w-56 origin-top-right | |
divide-y divide-gray-100 rounded-md bg-white shadow-md | |
ing-1 ring-black ring-opacity-5 focus:outline-none" | |
> | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={openEditMovie} | |
> | |
<FiEdit size={17} /> | |
<span>Edit</span> | |
</button> | |
)} | |
</Menu.Item> | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-red-500 text-white' : 'text-red-500' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={openDeleteMovie} | |
> | |
<BsTrash3 size={17} /> | |
<span>Delete</span> | |
</button> | |
)} | |
</Menu.Item> | |
<Menu.Item> | |
{({ active }) => ( | |
<Link | |
to={'/timeslot/add/' + movie.id} | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
> | |
<TbCalendarPlus size={17} /> | |
<span>Add Time Slot</span> | |
</Link> | |
)} | |
</Menu.Item> | |
<Menu.Item> | |
{({ active }) => ( | |
<Link | |
to={'/timeslot/' + movie.id} | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
> | |
<FaRegCalendarCheck size={17} /> | |
<span>All Slots</span> | |
</Link> | |
)} | |
</Menu.Item> | |
</Menu.Items> | |
</Menu> | |
) | |
} | |
export default MovieAction |
import React from 'react' | |
import { convertTimestampToDate, truncate } from '../store' | |
import { Link } from 'react-router-dom' | |
import MovieAction from './MovieActions' | |
const MoviesTable = ({ movies }) => { | |
return ( | |
<div className="overflow-x-auto"> | |
<table className="min-w-full bg-white"> | |
<thead> | |
<tr className="border-b"> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Image | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Title | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Genre | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Date Added | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Description | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Actions | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
{movies.map((movie, i) => ( | |
<tr key={i} className="border-b"> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
<img | |
className="h-16 w-16 object-cover rounded" | |
src={movie.imageUrl} | |
alt={movie.name} | |
/> | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap text-blue-500"> | |
<Link to={'/movie/' + movie.id}>{movie.name}</Link> | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap">{movie.genre}</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
{convertTimestampToDate(movie.timestamp)} | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
{truncate(movie.description, 50, 0, 53)} | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
<MovieAction movie={movie} /> | |
</td> | |
</tr> | |
))} | |
</tbody> | |
</table> | |
</div> | |
) | |
} | |
export default MoviesTable |
If you're interested in learning how to create a decentralized app by building a web3 lottery dapp, I recommend that you watch this video:
This video provides a hands-on tutorial on how to build a lottery dapp using NextJs, Tailwind CSS, and Solidity.
Update Movie Component
The **UpdateMovie**
component is a modal that allows users to edit movie details. It includes a form with input fields for image URL, movie name, genre, and description. The form validates required fields and displays a toast notification when the update is in progress, successful, or encounters an error. The component also handles the closing of the modal, updates the movie state, and triggers the updateMovie function. Use the code below.
import { useEffect, useState } from 'react' | |
import { useGlobalState, setGlobalState } from '../store' | |
import { FaTimes } from 'react-icons/fa' | |
import { updateMovie } from '../services/blockchain' | |
import { toast } from 'react-toastify' | |
const UpdateMovie = () => { | |
const [updateMovieModal] = useGlobalState('updateMovieModal') | |
const [movieData] = useGlobalState('movie') | |
const [movie, setMovie] = useState({ | |
imageUrl: '', | |
name: '', | |
genre: '', | |
description: '', | |
}) | |
useEffect(() => { | |
if (movieData) { | |
setMovie({ | |
imageUrl: movieData.imageUrl, | |
name: movieData.name, | |
genre: movieData.genre, | |
description: movieData.description, | |
}) | |
} | |
}, [movieData]) | |
const closeModal = () => { | |
setGlobalState('updateMovieModal', 'scale-0') | |
setMovie({ | |
imageUrl: '', | |
name: '', | |
genre: '', | |
description: '', | |
}) | |
setGlobalState('movie', null) | |
} | |
const handleSubmit = async (e) => { | |
e.preventDefault() | |
if (!movie.imageUrl || !movie.name || !movie.genre || !movie.description) | |
return | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await updateMovie({ ...movie, id: movieData.id }) | |
.then(async () => { | |
closeModal() | |
resolve() | |
}) | |
.catch((error) => { | |
console.log(error) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Movie updated successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const handleChange = (e) => { | |
const { name, value } = e.target | |
setMovie((prevMovie) => ({ | |
...prevMovie, | |
[name]: value, | |
})) | |
} | |
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 ${updateMovieModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 rounded-xl w-11/12 md:w-2/5 h-7/12 p-6"> | |
<form onSubmit={handleSubmit} className="flex flex-col"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-semibold">Edit Movie</p> | |
<button | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
onClick={closeModal} | |
> | |
<FaTimes className="text-gray-400" /> | |
</button> | |
</div> | |
<div className="flex flex-col justify-center items-center rounded-xl mt-5 mb-5"> | |
<div className="flex justify-center items-center rounded-full overflow-hidden h-10 w-64 shadow-md shadow-slate-300 p-4"> | |
<p className="text-slate-700"> | |
{' '} | |
Dapp <span className="text-red-700">Cinemas</span> | |
</p> | |
</div> | |
</div> | |
<div | |
className="flex flex-row justify-between items-center | |
bg-gray-300 rounded-xl mt-5 p-2" | |
> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="url" | |
name="image_url" | |
placeholder="image url" | |
value={movie.imageUrl} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5 p-2"> | |
<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="movie name" | |
value={movie.name} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5 p-2"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="genre" | |
placeholder="separate genre with commas, eg. hilarious, action, thrilling" | |
value={movie.genre} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5 p-2"> | |
<textarea | |
className="block w-full text-sm resize-none text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0 h-20" | |
type="text" | |
name="description" | |
placeholder="description." | |
value={movie.description} | |
onChange={handleChange} | |
required | |
></textarea> | |
</div> | |
<button | |
type="submit" | |
className="flex flex-row justify-center items-center w-full text-white text-md | |
bg-red-500 py-2 px-5 rounded-full drop-shadow-xl border border-transparent | |
hover:bg-transparent hover:border-red-500 hover:text-red-500 focus:outline-none mt-5" | |
> | |
Update | |
</button> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default UpdateMovie |
Delete Movie Component
The DeleteMovie
component is a modal that confirms the deletion of a movie. It displays a warning icon and the name of the movie to be deleted. The component includes a form with a delete button. When the form is submitted, a toast notification is displayed to indicate the progress of the deletion. The component also handles the closing of the modal and triggers the deleteMovie function. Use the snippet below:
import { RiErrorWarningFill } from 'react-icons/ri' | |
import { useGlobalState, setGlobalState } from '../store' | |
import { toast } from 'react-toastify' | |
import { deleteMovie } from '../services/blockchain' | |
import { FaTimes } from 'react-icons/fa' | |
const DeleteMovie = () => { | |
const [deleteMovieModal] = useGlobalState('deleteMovieModal') | |
const [movie] = useGlobalState('movie') | |
const closeModal = () => { | |
setGlobalState('deleteMovieModal', 'scale-0') | |
setGlobalState('movie', null) | |
} | |
const handleSubmit = async (e) => { | |
e.preventDefault() | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await deleteMovie(movie.id) | |
.then((res) => { | |
closeModal() | |
resolve(res) | |
}) | |
.catch((error) => { | |
console.log(error) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Movie 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-50 transition-transform duration-300 ${deleteMovieModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 rounded-xl w-11/12 md:w-2/5 h-7/12 p-6"> | |
<form onSubmit={handleSubmit} className="flex flex-col"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-semibold">Delete Movie</p> | |
<button | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
onClick={closeModal} | |
> | |
<FaTimes className="text-gray-400" /> | |
</button> | |
</div> | |
<div className="flex flex-col justify-center items-center rounded-xl mt-5 mb-5"> | |
<RiErrorWarningFill className="text-6xl text-red-700 " /> | |
<p className="p-2"> | |
Are you sure you want to delete{' '} | |
<span className="italic">"{movie?.name}"</span> | |
</p> | |
</div> | |
<button | |
className="flex flex-row justify-center items-center w-full text-white text-md | |
bg-red-500 py-2 px-5 rounded-full drop-shadow-xl border border-transparent | |
hover:bg-transparent hover:border-red-500 hover:text-red-500 focus:outline-none mt-5" | |
> | |
Delete | |
</button> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default DeleteMovie |
Withdrawal Component
The **Withdrawal**
component is a modal form that allows users to withdraw money. It utilizes React hooks such as **useState**
and custom hooks like **useGlobalState**
from the store. The component interacts with the blockchain through the **withdraw**
function from the blockchain service. Toast notifications from **react-toastify**
are used to display the status of the transaction. The component captures input values, handles form submission, and provides a close button to dismiss the modal. Use the code below.
import { useState } from 'react' | |
import { useGlobalState, setGlobalState } from '../store' | |
import { FaTimes } from 'react-icons/fa' | |
import { withdraw } from '../services/blockchain' | |
import { toast } from 'react-toastify' | |
const Withdrawal = () => { | |
const [withdrwalModal] = useGlobalState('withdrwalModal') | |
const [transfer, setTransfer] = useState({ | |
account: '', | |
amount: '', | |
}) | |
const closeModal = () => { | |
setGlobalState('withdrwalModal', 'scale-0') | |
setTransfer({ | |
account: '', | |
amount: '', | |
}) | |
} | |
const handleSubmit = async (e) => { | |
e.preventDefault() | |
if (!transfer.account || !transfer.amount) return | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await withdraw(transfer) | |
.then(async () => { | |
closeModal() | |
resolve() | |
}) | |
.catch((error) => { | |
console.log(error) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Transfer successful 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const handleChange = (e) => { | |
const { name, value } = e.target | |
setTransfer((prevMovie) => ({ | |
...prevMovie, | |
[name]: value, | |
})) | |
} | |
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 ${withdrwalModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 rounded-xl w-11/12 md:w-2/5 h-7/12 p-6"> | |
<form onSubmit={handleSubmit} className="flex flex-col"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-semibold">Withraw Money</p> | |
<button | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
onClick={closeModal} | |
> | |
<FaTimes className="text-gray-400" /> | |
</button> | |
</div> | |
<div className="flex flex-col justify-center items-center rounded-xl mt-5 mb-5"> | |
<div className="flex justify-center items-center rounded-full overflow-hidden h-10 w-64 shadow-md shadow-slate-300 p-4"> | |
<p className="text-slate-700"> | |
{' '} | |
Dapp <span className="text-red-700">Cinemas</span> | |
</p> | |
</div> | |
</div> | |
<div | |
className="flex flex-row justify-between items-center | |
bg-gray-300 rounded-xl mt-5 p-2" | |
> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="text" | |
name="account" | |
minLength={42} | |
maxLength={42} | |
pattern="[A-Za-z0-9]+" | |
placeholder="ETH Account" | |
value={transfer.account} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5 p-2"> | |
<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 (ETH)" | |
value={transfer.amount} | |
onChange={handleChange} | |
required | |
/> | |
</div> | |
<button | |
type="submit" | |
className="flex flex-row justify-center items-center w-full text-white text-md | |
bg-red-500 py-2 px-5 rounded-full drop-shadow-xl border border-transparent | |
hover:bg-transparent hover:border-red-500 hover:text-red-500 focus:outline-none mt-5" | |
> | |
Wire Fund | |
</button> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default Withdrawal |
The Chat Components
The ChatActions
component provides various chat options based on the user's authentication status and the movie's group status. It includes functionality for signing up, logging in, creating a group, and joining a group. Each action triggers an asynchronous operation and displays toast notifications to indicate the progress and outcome of the operation.
The ChatModal
component represents a chat room modal. It displays messages exchanged in the chat room and allows the user to send new messages. The component includes functionality for fetching initial messages, listening for new messages, and scrolling to the latest message. It also includes a Message
component that represents an individual chat message, displaying the sender's avatar and truncated username along with the message content.
The chat components, **ChatActions**
and **ChatModal**
, are built using the CometChat SDK from the chat service. CometChat provides a comprehensive set of features and functionalities for integrating real-time chat functionality into applications.
In the **ChatActions**
component, functions such as **signUpWithCometChat**
, **loginWithCometChat**
, **createNewGroup**
, and **joinGroup**
are called asynchronously using the CometChat SDK. These functions handle the respective chat operations, such as signing up a user, logging in, creating a group, and joining a group. Toast notifications are used to indicate the status of these operations, such as pending, success, or error.
The **ChatModal**
component utilizes the CometChat SDK to retrieve messages from a specific chat room (**getMessages**
), send new messages (**sendMessage**
), and listen for incoming messages (**listenForMessage**
). The component also includes functionality to scroll to the latest message in the chat room.
By leveraging the CometChat SDK, these chat components enable users to interact with chat functionality, including signing up, logging in, creating or joining groups, and exchanging messages within a chat room.
Bring real-time messaging to your application with CometChat. Try our SDK and UI Kits today!
Now, let’s create these components into our application like we have been doing with the other components. Use the code below.
import { Menu } from '@headlessui/react' | |
import { FiEdit } from 'react-icons/fi' | |
import { BiLogInCircle } from 'react-icons/bi' | |
import { SiGnuprivacyguard } from 'react-icons/si' | |
import { BsChatLeftDots } from 'react-icons/bs' | |
import { FiChevronDown } from 'react-icons/fi' | |
import { MdOutlineJoinLeft } from 'react-icons/md' | |
import { toast } from 'react-toastify' | |
import { | |
createNewGroup, | |
joinGroup, | |
loginWithCometChat, | |
signUpWithCometChat, | |
} from '../services/chat' | |
import { setGlobalState, useGlobalState } from '../store' | |
const ChatActions = ({ movie, group }) => { | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const [owner] = useGlobalState('deployer') | |
const [currentUser] = useGlobalState('currentUser') | |
const handleSignUp = async () => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await signUpWithCometChat(connectedAccount) | |
.then((user) => resolve(user)) | |
.catch((error) => { | |
alert(JSON.stringify(error)) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Signning up...', | |
success: 'Signed up successfully, please login 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const handleLogin = async () => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await loginWithCometChat(connectedAccount) | |
.then((user) => { | |
setGlobalState('currentUser', user) | |
resolve(user) | |
window.location.reload() | |
}) | |
.catch((error) => { | |
alert(JSON.stringify(error)) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Logging...', | |
success: 'Logged in successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const handleCreateGroup = async () => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await createNewGroup(`guid_${movie.id}`, movie.name) | |
.then((group) => { | |
setGlobalState('group', group) | |
resolve(group) | |
window.location.reload() | |
}) | |
.catch((error) => { | |
alert(JSON.stringify(error)) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Creating group...', | |
success: 'Group created successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const handleJoinGroup = async () => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await joinGroup(`guid_${movie.id}`) | |
.then((group) => { | |
setGlobalState('group', group) | |
resolve() | |
window.location.reload() | |
}) | |
.catch((error) => { | |
alert(JSON.stringify(error)) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Joining group...', | |
success: 'Group joined successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<Menu as="div" className="relative inline-block text-left mx-auto"> | |
<Menu.Button | |
className="inline-flex justify-center items-center space-x-2 | |
rounded-md bg-black bg-opacity-10 px-4 py-2 text-sm | |
font-medium text-black hover:bg-opacity-30 focus:outline-none | |
focus-visible:ring-2 focus-visible:ring-white | |
focus-visible:ring-opacity-75" | |
> | |
<span>Chat Options</span> | |
<FiChevronDown size={17} /> | |
</Menu.Button> | |
<Menu.Items | |
className="absolute right-0 mt-2 w-56 origin-top-right | |
divide-y divide-gray-100 rounded-md bg-white shadow-md | |
ing-1 ring-black ring-opacity-5 focus:outline-none" | |
> | |
{!currentUser && ( | |
<> | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-red-500' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={handleSignUp} | |
> | |
<SiGnuprivacyguard size={17} /> | |
<span>Chat SignUp</span> | |
</button> | |
)} | |
</Menu.Item> | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={handleLogin} | |
> | |
<BiLogInCircle size={17} /> | |
<span>Chat Login</span> | |
</button> | |
)} | |
</Menu.Item> | |
</> | |
)} | |
{currentUser && ( | |
<> | |
{currentUser.uid != owner && group && !group.hasJoined && ( | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={handleJoinGroup} | |
> | |
<MdOutlineJoinLeft size={17} /> | |
<span>Join Group</span> | |
</button> | |
)} | |
</Menu.Item> | |
)} | |
{currentUser.uid == owner && !group && ( | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={handleCreateGroup} | |
> | |
<FiEdit size={17} /> | |
<span>Create Group</span> | |
</button> | |
)} | |
</Menu.Item> | |
)} | |
{group && group.hasJoined && ( | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={() => setGlobalState('chatModal', 'scale-100')} | |
> | |
<BsChatLeftDots size={17} /> | |
<span>Chat</span> | |
</button> | |
)} | |
</Menu.Item> | |
)} | |
</> | |
)} | |
</Menu.Items> | |
</Menu> | |
) | |
} | |
export default ChatActions |
import { FaTimes } from 'react-icons/fa' | |
import { setGlobalState, useGlobalState } from '../store' | |
import Identicon from 'react-identicons' | |
import { truncate } from '../store' | |
import { getMessages, sendMessage, listenForMessage } from '../services/chat' | |
import { useState, useEffect } from 'react' | |
const ChatModal = ({ movie }) => { | |
const [chatModal] = useGlobalState('chatModal') | |
const [message, setMessage] = useState('') | |
const [messages] = useGlobalState('messages') | |
const onSendMessage = async (e) => { | |
e.preventDefault() | |
if (!message) return | |
await sendMessage('guid_' + movie.id, message).then((msg) => { | |
setGlobalState('messages', (prevState) => [...prevState, msg]) | |
setMessage('') | |
scrollToEnd() | |
}) | |
} | |
useEffect(() => { | |
const fetchData = async () => { | |
await getMessages('guid_' + movie.id).then((msgs) => { | |
setGlobalState('messages', msgs) | |
scrollToEnd() | |
}) | |
await listenForMessage('guid_' + movie.id).then((msg) => { | |
setGlobalState('messages', (prevState) => [...prevState, msg]) | |
scrollToEnd() | |
}) | |
} | |
fetchData() | |
}, []) | |
const scrollToEnd = () => { | |
const elmnt = document.getElementById('messages-container') | |
elmnt.scrollTop = elmnt.scrollHeight | |
} | |
return ( | |
<div | |
className={`fixed -top-4 left-0 w-screen h-screen flex items-center justify-center bg-black bg-opacity-50 transform z-50 transition-transform duration-300 ${chatModal}`} | |
> | |
<div className="bg-slate-200 shadow-lg shadow-slate-900 rounded-xl w-11/12 md:w-3/5 h-[30rem] p-6 relative"> | |
<div className="flex justify-between items-center"> | |
<h2 className="capitalize">{movie.name}: Chat Room</h2> | |
<FaTimes | |
className="cursor-pointer" | |
onClick={() => setGlobalState('chatModal', 'scale-0')} | |
/> | |
</div> | |
<div | |
id="messages-container" | |
className="overflow-y-scroll overflow-x-hidden h-[20rem] scroll-bar mt-5 px-4 py-3 bg-gray-300 rounded-md" | |
> | |
<div className="w-11/12"> | |
{messages.map((msg, i) => ( | |
<Message message={msg.text} uid={msg.sender.uid} key={i} /> | |
))} | |
</div> | |
</div> | |
<form className="h-[4rem] w-full mt-4" onSubmit={onSendMessage}> | |
<input | |
value={message} | |
onChange={(e) => setMessage(e.target.value)} | |
className="h-full w-full p-5 focus:outline-none focus:ring-0 rounded-md border-none | |
bg-[rgba(0,0,0,0.7)] text-white placeholder-white" | |
placeholder="Leave a message..." | |
/> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
const Message = ({ message, uid }) => { | |
return ( | |
<div className="flex justify-start items-center space-x-3 space-y-3"> | |
<div className="flex items-center space-x-2"> | |
<Identicon string={uid} size={15} className="rounded-full" /> | |
<p className="font-semibold text-sm">{truncate(uid, 4, 4, 11)}</p> | |
</div> | |
<p className="text-xs">{message}</p> | |
</div> | |
) | |
} | |
export default ChatModal |
The Timeslots Components
The TimeSlotTable
component displays a table of time slots. It receives an array of slots
as props and maps over them to generate table rows. Each row represents a time slot and displays information such as ID, day, ticket cost, balance, start time, end time, capacity, and actions. The convertTimestampToDate
and convertTimestampToTime
functions from the store are used to format the timestamp values. The actions column includes the TimeslotActions
component.
The TimeslotActions
component is a dropdown menu of actions available for each time slot. It provides options to delete a slot, mark a slot as completed, and view all ticket holders. The actions trigger corresponding functions like handleDelete
and handleCompletion
. The component uses icons from various libraries such as react-icons/tfi
, react-icons/bs
, and react-icons/bi
. Toast notifications from react-toastify
are used to display the status of the transaction. The menu is implemented using the Menu
component from @headlessui/react
, and the Link
component is used for navigation.
Use the codes below for creating the both components.
import { Menu } from '@headlessui/react' | |
import { TfiTicket } from 'react-icons/tfi' | |
import { BsFileEarmarkCheck, BsTrash3 } from 'react-icons/bs' | |
import { BiDotsVerticalRounded } from 'react-icons/bi' | |
import { setGlobalState } from '../store' | |
import { Link } from 'react-router-dom' | |
import { toast } from 'react-toastify' | |
import { markSlot } from '../services/blockchain' | |
const TimeslotActions = ({ slot }) => { | |
const handleDelete = () => { | |
setGlobalState('slot', slot) | |
setGlobalState('deleteSlotModal', 'scale-100') | |
} | |
const handleCompletion = async () => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await markSlot(slot) | |
.then(() => resolve()) | |
.catch((error) => { | |
console.log(error) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Marked as completed 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<Menu as="div" className="inline-block text-left"> | |
<Menu.Button | |
className="inline-flex w-full justify-center | |
rounded-md bg-black bg-opacity-10 px-4 py-2 text-sm | |
font-medium text-black hover:bg-opacity-30 focus:outline-none | |
focus-visible:ring-2 focus-visible:ring-white | |
focus-visible:ring-opacity-75" | |
> | |
<BiDotsVerticalRounded size={17} /> | |
</Menu.Button> | |
<Menu.Items | |
className="absolute right-0 mt-2 w-56 origin-top-right | |
divide-y divide-gray-100 rounded-md bg-white shadow-md | |
ing-1 ring-black ring-opacity-5 focus:outline-none" | |
> | |
{!slot.completed && ( | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-red-500 text-white' : 'text-red-500' | |
} 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> | |
)} | |
{!slot.completed && slot.seats > 0 && ( | |
<Menu.Item> | |
{({ active }) => ( | |
<button | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
onClick={handleCompletion} | |
> | |
<BsFileEarmarkCheck size={17} /> | |
<span>Finish up</span> | |
</button> | |
)} | |
</Menu.Item> | |
)} | |
<Menu.Item> | |
{({ active }) => ( | |
<Link | |
className={`flex justify-start items-center space-x-1 ${ | |
active ? 'bg-gray-200 text-black' : 'text-gray-900' | |
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} | |
to={`/timeslot/${slot.movieId}/${slot.id}`} | |
> | |
<TfiTicket size={17} /> | |
<span>All Holders</span> | |
</Link> | |
)} | |
</Menu.Item> | |
</Menu.Items> | |
</Menu> | |
) | |
} | |
export default TimeslotActions |
import React from 'react' | |
import { convertTimestampToDate, convertTimestampToTime } from '../store' | |
import TimeslotActions from './TimeslotActions' | |
import { Link } from 'react-router-dom' | |
const TimeSlotTable = ({ slots }) => { | |
return ( | |
<div className="overflow-x-auto"> | |
<table className="min-w-full bg-white"> | |
<thead> | |
<tr className="border-b"> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Id | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Day | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Ticket | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Balance | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Starts | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Ends | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Capacity | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Actions | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
{slots.map((slot, i) => ( | |
<tr key={i} className="border-b"> | |
<td className="px-6 py-4 whitespace-nowrap">{i + 1}</td> | |
<td className="px-6 py-4 whitespace-nowrap text-blue-500"> | |
<Link to={`/timeslot/${slot.movieId}/${slot.id}`}> | |
{convertTimestampToDate(slot.day)} | |
</Link> | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
{slot.ticketCost} ETH | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
{slot.balance} ETH | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
{convertTimestampToTime(slot.startTime)} | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
{convertTimestampToTime(slot.endTime)} | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
{slot.seats} / {slot.capacity} | |
</td> | |
<td className="px-6 py-4 whitespace-nowrap"> | |
<TimeslotActions slot={slot} /> | |
</td> | |
</tr> | |
))} | |
</tbody> | |
</table> | |
</div> | |
) | |
} | |
export default TimeSlotTable |
Timeslot Lits Component
The TimeSlotList
component displays a list of available time slots, allowing users to purchase tickets. It uses the slots
array as props to populate the list items with relevant information such as date, time, seat availability, ticket cost, and a "Buy Ticket" button. The component handles ticket purchases through the handleTicketPurchase
function and displays transaction status using react-toastify
notifications. The component also utilizes the FaEthereum
icon from react-icons/fa
and applies Tailwind CSS classes for styling.
Use the code below.
import React from 'react' | |
import { convertTimestampToDate, convertTimestampToTime } from '../store' | |
import { FaEthereum } from 'react-icons/fa' | |
import { toast } from 'react-toastify' | |
import { buyTicket } from '../services/blockchain' | |
const TimeSlotList = ({ slots }) => { | |
const handleTicketPurchase = async (slot) => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await buyTicket(slot) | |
.then((res) => resolve(res)) | |
.catch((error) => { | |
console.log(error) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Ticket successfully purchased 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<div className="flex flex-col items-center mb-10 w-full sm:w-3/6 mx-auto"> | |
<h2 className="text-2xl font-bold mb-2">Available Time Slots</h2> | |
{slots.length > 0 ? ( | |
<ul className="space-y-4 w-full"> | |
{slots.map((slot, i) => ( | |
<li | |
key={i} | |
className="bg-gray-100 p-4 rounded-md w-full flex justify-between items-center" | |
> | |
<div> | |
<h3 className="text-lg font-semibold"> | |
{convertTimestampToDate(slot.day)} | |
</h3> | |
<p className="text-sm font-medium"> | |
{convertTimestampToTime(slot.startTime)} -{' '} | |
{convertTimestampToTime(slot.endTime)} | |
</p> | |
<div className="flex justify-center items-center space-x-1 text-sm font-light"> | |
<span> | |
{slot.seats} / {slot.capacity} left | |
</span> | |
<span className="flex justify-center items-center"> | |
<FaEthereum /> <span>{slot.ticketCost}</span> | |
</span> | |
</div> | |
</div> | |
<button | |
className="inline-block px-3 py-2 border-2 border-red-600 font-medium text-xs | |
leading-tight uppercase rounded-full hover:bg-opacity-5 focus:outline-none | |
focus:ring-0 transition duration-150 ease-in-out hover:bg-gradient-to-r | |
from-cyan-500 to-red-500 hover:text-white hover:border-white" | |
onClick={() => handleTicketPurchase(slot)} | |
> | |
Buy Ticket | |
</button> | |
</li> | |
))} | |
</ul> | |
) : ( | |
<p className="text-gray-700 font-light"> | |
Not available, check back later | |
</p> | |
)} | |
</div> | |
) | |
} | |
export default TimeSlotList |
Delete Slot Component
The DeleteSlot
component displays a modal to confirm the deletion of a time slot. It manages state using useGlobalState
and setGlobalState
, and imports icons from react-icons/ri
and react-icons/fa
. The modal shows a warning, confirms the selected slot's deletion, and has a "Delete" button. Deletion is handled by handleDelete
, using deleteSlot
from blockchain
. Styling is done with Tailwind CSS classes. Overall, DeleteSlot
provides a confirmation interface for deleting slots and handles related actions and state changes.
Use the code below:
import { RiErrorWarningFill } from 'react-icons/ri' | |
import { | |
useGlobalState, | |
setGlobalState, | |
convertTimestampToDate, | |
} from '../store' | |
import { FaTimes } from 'react-icons/fa' | |
import { deleteSlot } from '../services/blockchain' | |
import { toast } from 'react-toastify' | |
const DeleteSlot = () => { | |
const [deleteSlotModal] = useGlobalState('deleteSlotModal') | |
const [slot] = useGlobalState('slot') | |
const closeModal = () => { | |
setGlobalState('deleteSlotModal', 'scale-0') | |
} | |
const handleDelete = async () => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await deleteSlot(slot) | |
.then((res) => { | |
closeModal() | |
resolve(res) | |
}) | |
.catch((error) => { | |
console.log(error) | |
reject(error) | |
}) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Slot 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-50 transition-transform duration-300 ${deleteSlotModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 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">Delete Time Slot</p> | |
<button | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
onClick={closeModal} | |
> | |
<FaTimes className="text-gray-400" /> | |
</button> | |
</div> | |
<div className="flex flex-col justify-center items-center rounded-xl mt-5 mb-5"> | |
<RiErrorWarningFill className="text-6xl text-red-700 " /> | |
<p className="text-center p-2"> | |
Are you sure you want to delete slot for <br /> | |
<span className="font-semibold"> | |
{convertTimestampToDate(slot?.day)} | |
</span> | |
</p> | |
</div> | |
<button | |
onClick={handleDelete} | |
className="flex flex-row justify-center items-center w-full text-white text-md | |
bg-red-500 py-2 px-5 rounded-full drop-shadow-xl border border-transparent | |
hover:bg-transparent hover:border-red-500 hover:text-red-500 focus:outline-none mt-5" | |
> | |
Delete | |
</button> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default DeleteSlot |
Ticket Holders Component
The TicketHoldersTable
component renders a table that displays ticket holders' information. It receives the holders
and slot
props. The table structure consists of three columns: "Id," "Cost," and "Holder." The holders
array is mapped to generate table rows, with each row displaying the corresponding holder's information. The "Id" column displays the index of the holder plus one, the "Cost" column shows the ticket cost from the slot
prop, and the "Holder" column displays the holder's information. The component utilizes Tailwind CSS classes for styling and provides an organized representation of the ticket holders' data. See the codes below.
import React from 'react' | |
const TicketHoldersTable = ({ holders, slot }) => { | |
return ( | |
<div className="overflow-x-auto"> | |
<table className="min-w-full bg-white"> | |
<thead> | |
<tr className="border-b"> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Id | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Cost | |
</th> | |
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
Holder | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
{holders.map((holder, i) => ( | |
<tr key={i} className="border-b"> | |
<td className="px-6 py-4 whitespace-nowrap">{i + 1}</td> | |
<td className="px-6 py-4 whitespace-nowrap">{slot.ticketCost} ETH</td> | |
<td className="px-6 py-4 whitespace-nowrap">{holder}</td> | |
</tr> | |
))} | |
</tbody> | |
</table> | |
</div> | |
) | |
} | |
export default TicketHoldersTable |
Find Holders Component
Lastly for the components is the FindHolder
component, which displays a modal window for searching ticket holders by their Ethereum address. It uses hooks and global state variables to manage the search functionality. The modal includes an input field for entering the address, and the handleSearch
function updates the results. Matching addresses are shown along with the count. The component provides a user-friendly interface for searching ticket holders within a modal window. See the code below.
import { useState } from 'react' | |
import { useGlobalState, setGlobalState } from '../store' | |
import { FaTimes } from 'react-icons/fa' | |
import { TbSearch } from 'react-icons/tb' | |
const FindHolder = () => { | |
const [holderSearchModal] = useGlobalState('holderSearchModal') | |
const [holders] = useGlobalState('ticketHolders') | |
const [address, setAddress] = useState('') | |
const [addresses, setAddresses] = useState([]) | |
const closeModal = () => { | |
setGlobalState('holderSearchModal', 'scale-0') | |
} | |
const handleSearch = (characters) => { | |
const sanitizedCharacters = characters.trim().toLowerCase() | |
setAddress(sanitizedCharacters) | |
setAddresses([]) | |
if (sanitizedCharacters !== '') { | |
for (let i = 0; i < holders.length; i++) { | |
const walletAddress = holders[i].toLowerCase() | |
if (walletAddress.trim() !== '') { | |
const regex = /^0x[a-fA-F0-9]{40}$/ // Regex pattern for Ethereum address | |
if ( | |
regex.test(walletAddress) && | |
walletAddress.includes(sanitizedCharacters) | |
) { | |
setAddresses((prevState) => [walletAddress, ...prevState]) | |
} | |
} | |
} | |
} | |
} | |
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 ${holderSearchModal}`} | |
> | |
<div className="bg-white shadow-lg shadow-slate-900 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">Find Holder</p> | |
<button | |
type="button" | |
className="border-0 bg-transparent focus:outline-none" | |
onClick={closeModal} | |
> | |
<FaTimes className="text-gray-400" /> | |
</button> | |
</div> | |
<div className="flex flex-col justify-center items-center rounded-xl mt-5 mb-5"> | |
<div | |
className="flex border border-gray-200 text-gray-500 p-2 | |
items-center rounded-full w-full" | |
> | |
<TbSearch size={20} className="hidden md:flex" /> | |
<input | |
onChange={(e) => handleSearch(e.target.value)} | |
value={address} | |
placeholder="Search holders ETH address" | |
className="border-none flex-1 text-m px-2 outline-none" | |
/> | |
</div> | |
</div> | |
{addresses.length > 0 && ( | |
<div className="flex flex-col justify-center items-center rounded-xl mb-5"> | |
<div className="text-green-800 font-medium"> | |
Found {addresses.length} match(es) | |
</div> | |
<div className="mt-2"> | |
{addresses.map((address, i) => ( | |
<pre key={i}>{address}</pre> | |
))} | |
</div> | |
</div> | |
)} | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default FindHolder |
Want to learn how to build an NFT marketplace? Watch this video tutorial:
This video provides a step-by-step guide on how to build an NFT marketplace using React and Solidity. The video covers all of the essential concepts and techniques you need to know to build an NFT marketplace.
Now that we have covered all the components in this application, it’s time we couple the various pages together. Let’s start with the homepage.
To begin developing the pages of our application, we will create a new directory called pages in the “src” directory. This folder will contain all the pages required for our project. For each of the pages listed below, you need to create the corresponding files within the src/pages
folder.
The List Movies Page
The ListMovies
page component displays a list of movies. It utilizes the useGlobalState
hook to retrieve the movie data from the global state. The component includes a Banner
component at the top and a grid layout for displaying the movies using the MovieCard
component. If there are movies available, they are rendered in the grid layout. Otherwise, a message indicating that there are no movies yet is displayed. The ListMovies
component provides a visually appealing way to present a collection of movies. Use the code below.
import { useGlobalState } from '../store' | |
import MovieCard from '../components/MovieCard' | |
import Banner from '../components/Banner' | |
const ListMovies = () => { | |
const [movies] = useGlobalState('movies') | |
return ( | |
<div className="flex flex-col w-full p-4"> | |
<Banner /> | |
<div> | |
<div className="flex flex-col"> | |
<h2 className="text-xl font-semibold p-4">Movies</h2> | |
{movies.length > 0 ? ( | |
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-3 gap-4"> | |
{movies.map((movie, i) => ( | |
<MovieCard key={i} movie={movie} /> | |
))} | |
</div> | |
) : ( | |
<div className="mt-10">No movies yet</div> | |
)} | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default ListMovies |
Movie Details Page
The **MovieDetails**
page component is responsible for displaying detailed information about a specific movie. It utilizes various hooks and services to fetch and manage the movie data, time slots, and chat group (CometChat SDK). The component retrieves the movie ID from the URL parameters using **useParams**
from React Router.
Upon rendering, the component fetches the movie details and available time slots by calling the **getMovie**
and **getSlots**
functions from the blockchain service. It also retrieves the chat group associated with the movie using the **getGroup**
function. The component manages the loading state with the **loaded**
and **setLoaded**
state variables.
Once the data is loaded, the component displays the movie image, name, genre, description, and chat actions. It also includes the **TimeSlotList**
component, which filters and displays the available time slots based on the **isValidTimeslot**
function. See the code below.
import { useEffect, useState } from 'react' | |
import { getMovie, getSlots } from '../services/blockchain' | |
import { useGlobalState } from '../store' | |
import { useParams } from 'react-router-dom' | |
import ChatModal from '../components/ChatModal' | |
import { getGroup } from '../services/chat' | |
import ChatActions from '../components/ChatActions' | |
import TimeSlotList from '../components/TimeSlotList' | |
const MovieDetails = () => { | |
const [loaded, setLoaded] = useState(false) | |
const [group, setGroup] = useState(null) | |
const [movie] = useGlobalState('movie') | |
const [slots] = useGlobalState('slotsForDay') | |
const { id } = useParams() | |
useEffect(() => { | |
const fetchData = async () => { | |
await getMovie(id) | |
await getSlots(id) | |
setLoaded(true) | |
const GROUP = await getGroup(`guid_${id}`) | |
setGroup(GROUP) | |
} | |
fetchData() | |
}, []) | |
const isValidTimeslot = (slot) => { | |
const currentDate = new Date() | |
currentDate.setHours(0, 0, 0, 0) | |
const currentTimestamp = currentDate.getTime() | |
if ( | |
currentTimestamp <= slot.day && | |
!slot.completed && | |
slot.seats < slot.capacity | |
) { | |
return true | |
} else { | |
return false | |
} | |
} | |
return ( | |
loaded && ( | |
<div className="flex flex-col w-full p-4 space-y-4"> | |
<div className="flex w-full "> | |
<img src={movie.imageUrl} className="w-full object-cover h-[30rem]" /> | |
</div> | |
<div className="flex flex-col space-y-4 align-center text-center w-full"> | |
<div className="flex flex-col space-y-6"> | |
<h3 className="font-black text-2xl">{movie.name}</h3> | |
<div className="flex space-x-2 my-2 justify-center"> | |
{movie.genre.split(',').map((genre, i) => ( | |
<span | |
key={i} | |
className="inline-block px-4 py-2 rounded-full bg-cyan-500 text-white | |
text-sm font-medium shadow-md transition-colors duration-300 | |
hover:bg-cyan-600 cursor-pointer" | |
> | |
{genre} | |
</span> | |
))} | |
</div> | |
<p className="text-gray-700 my-5 w-5/6 text-center mx-auto font-light"> | |
{movie.description} | |
</p> | |
<ChatActions movie={movie} group={group} /> | |
<TimeSlotList | |
slots={slots.filter((slot) => isValidTimeslot(slot))} | |
/> | |
</div> | |
</div> | |
<ChatModal movie={movie} /> | |
</div> | |
) | |
) | |
} | |
export default MovieDetails |
Manage Movies Page
The ManageMovies
page allows users to manage movies. It displays a table of movies using the MoviesTable
component. Users can add a new movie by clicking the "Add Movie" button, which navigates them to the "/add/movies" route. The page also provides a "Withdraw" button that triggers a withdrawal action and displays the current balance using the balance
state. The withdrawal action is implemented using the setGlobalState
function and opens the withdrawal modal. The page layout is responsive and utilizes spacing and styling to enhance the user interface. See the code below.
import { setGlobalState, useGlobalState } from '../store' | |
import MoviesTable from '../components/MoviesTable' | |
import { Link } from 'react-router-dom' | |
import { FaEthereum } from 'react-icons/fa' | |
const ManageMovies = () => { | |
const [movies] = useGlobalState('movies') | |
const [balance] = useGlobalState('balance') | |
return ( | |
<div className="w-full min-h-[89vh] p-3 space-y-6"> | |
<h3 className="my-3 text-3xl font-bold">Manage Movies</h3> | |
<MoviesTable movies={movies} /> | |
<div className="flex space-x-2"> | |
<Link | |
to="/add/movies" | |
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded" | |
> | |
Add Movie | |
</Link> | |
<button | |
className="bg-blue-500 hover:bg-blue-700 text-white | |
font-bold py-2 px-4 rounded flex justify-center items-center" | |
onClick={() => setGlobalState('withdrwalModal', 'scale-100')} | |
> | |
<span>Withdraw</span> | |
<FaEthereum /> | |
<span>{balance}</span> | |
</button> | |
</div> | |
</div> | |
) | |
} | |
export default ManageMovies |
Add Movie Page
The AddMovie
page allows users to add a new movie. It provides a form where users can input the movie's image URL, name, genre, and description. Upon submission, the handleSubmit
function is called, which performs input validation and calls the addMovie
function from the blockchain service to add the movie.
The form submission process displays a toast notification to indicate the status of the transaction, including pending, success, or error messages. If all the required fields are filled, the movie is added successfully, and the user is navigated back to the home page ("/")
.
The page layout is centered and responsive, and the form input fields are styled using Tailwind classes to ensure a consistent and user-friendly interface. See the codes below.
import { useState } from 'react' | |
import { addMovie } from '../services/blockchain' | |
import { toast } from 'react-toastify' | |
import { useNavigate } from 'react-router-dom' | |
const Addmovie = () => { | |
const navigate = useNavigate() | |
const [movie, setMovie] = useState({ | |
imageUrl: '', | |
name: '', | |
genre: '', | |
description: '', | |
}) | |
const handleChange = (e) => { | |
const { name, value } = e.target | |
setMovie((prevMovie) => ({ | |
...prevMovie, | |
[name]: value, | |
})) | |
} | |
const handleSubmit = async (e) => { | |
e.preventDefault() | |
if (!movie.imageUrl || !movie.name || !movie.genre || !movie.description) | |
return | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await addMovie(movie) | |
.then((tx) => { | |
navigate('/') | |
resolve(tx) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Movie created successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<div className="flex justify-center items-center py-12 m-auto w-full "> | |
<div className="block rounded-lg justify-center items-center m-auto shadow-lg shadow-gray-400 w-3/5"> | |
<form className="p-6" onSubmit={handleSubmit}> | |
<div className="flex items-center justify-center mb-4"> | |
<h2>Add Movies</h2> | |
</div> | |
<div className="mb-6 w-full"> | |
<input | |
type="url" | |
className="form-control block w-full px-3 py-1.5 text-base font-normal text-gray-700 | |
bg-white bg-clip-padding rounded-lg outline-none lg:w-full focus:border-red-700 | |
focus:outline-none focus:ring-0" | |
placeholder="Image URL" | |
name="imageUrl" | |
value={movie.imageUrl} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="mb-6"> | |
<input | |
type="text" | |
className="form-control block w-full px-3 py-1.5 text-base font-normal text-gray-700 | |
bg-white bg-clip-padding rounded-lg outline-none lg:w-full focus:border-red-700 | |
focus:outline-none focus:ring-0" | |
placeholder="Movie Name" | |
name="name" | |
value={movie.name} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="mb-6"> | |
<input | |
type="text" | |
className="form-control block w-full px-3 py-1.5 text-base font-normal text-gray-700 | |
bg-white bg-clip-padding rounded-lg outline-none lg:w-full focus:border-red-700 | |
focus:outline-none focus:ring-0" | |
placeholder="e.g. hilarious, action, comedy..." | |
name="genre" | |
value={movie.genre} | |
onChange={handleChange} | |
/> | |
</div> | |
<div className="mb-6"> | |
<textarea | |
className="form-control block w-full h-32 resize-none px-3 py-1.5 text-base font-normal text-gray-700 | |
bg-white bg-clip-padding rounded-lg outline-none lg:w-full focus:border-red-700 | |
focus:outline-none focus:ring-0" | |
placeholder="Movie Description" | |
name="description" | |
value={movie.description} | |
onChange={handleChange} | |
></textarea> | |
</div> | |
<div className="flex justify-end"> | |
<button | |
type="submit" | |
className="w-42 px-6 py-2.5 bg-transparent text-black font-medium text-xs leading-tight focus:ring-0 focus:outline-none uppercase rounded-full shadow-md border-2 border-red-700 hover:bg-gradient-to-r from-cyan-500 to-red-500 hover:text-white hover:border-white" | |
> | |
Add Movie | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default Addmovie |
Add Slot Page
The AddTimeslot
page allows users to add a time slot for a movie. It uses React for building the user interface. The component includes form inputs for selecting a day, start time, end time, ticket cost, and capacity.
The selected day is stored in the selectedDay
state variable. When a day is selected, it triggers a call to getSlotsByDay
function to fetch existing time slots for that day.
The form is submitted when all required fields are filled, and the data is stored in separate state variables for ticket costs, start times, end times, capacities, and viewing days.
There are functions to handle form submission, saving the time slot to the blockchain, resetting the form, and removing selected slots.
The component utilizes external libraries such as react-datepicker
for date and time picking, react-router-dom
for navigation, and react-toastify
for displaying transaction status. See the codes below.
import React, { useEffect, useState } from 'react' | |
import DatePicker from 'react-datepicker' | |
import 'react-datepicker/dist/react-datepicker.css' | |
import { useParams, useNavigate } from 'react-router-dom' | |
import { toast } from 'react-toastify' | |
import { addTimeslot, getSlotsByDay, toWei } from '../services/blockchain' | |
import { convertTimestampToTime, useGlobalState } from '../store' | |
import { FaTimes } from 'react-icons/fa' | |
const AddTimeslot = () => { | |
const [currentSlots] = useGlobalState('currentSlots') | |
const [ticketCost, setTicketCost] = useState('') | |
const [startTime, setStartTime] = useState(null) | |
const [endTime, setEndTime] = useState(null) | |
const [capacity, setCapacity] = useState('') | |
const [selectedDay, setSelectedDay] = useState(null) | |
const [blockedStamps, setBlockedStamps] = useState([]) | |
const [ticketCosts, setTicketCosts] = useState([]) | |
const [startTimes, setStartTimes] = useState([]) | |
const [endTimes, setEndTimes] = useState([]) | |
const [capacities, setCapacities] = useState([]) | |
const [viewingDays, setViewingDays] = useState([]) | |
const { id } = useParams() | |
const router = useNavigate() | |
const timeInterval = 30 | |
const handleSelectedDay = async (date) => { | |
const day = new Date(date) | |
const options = { year: 'numeric', month: '2-digit', day: '2-digit' } | |
const newDate = new Date( | |
`${day.toLocaleDateString('en-US', options).replace(/\//g, '-')}` | |
).getTime() | |
setSelectedDay(newDate) | |
} | |
useEffect(() => { | |
const fetchData = async () => { | |
await getSlotsByDay(selectedDay) | |
initAvailableSlot() | |
} | |
if (selectedDay) fetchData() | |
}, [selectedDay]) | |
useEffect(() => { | |
if (currentSlots.length > 0) { | |
initAvailableSlot() | |
} | |
}, [currentSlots]) | |
const dateMax = () => { | |
const startOfDay = new Date(selectedDay) | |
startOfDay.setHours(0, 0, 0, 0) | |
const minStartTime = | |
startOfDay.toLocaleDateString() === new Date().toLocaleDateString() | |
? new Date() | |
: startOfDay | |
const maxStartTime = new Date(selectedDay).setHours(23, 59, 59, 999) | |
const minEndTime = new Date(startTime) | |
const maxEndTime = new Date(selectedDay).setHours(23, 59, 59, 999) | |
return { startOfDay, minStartTime, maxStartTime, maxEndTime, minEndTime } | |
} | |
const initAvailableSlot = () => { | |
const timestamps = [] | |
currentSlots.forEach((slot) => { | |
const { startTime, endTime } = slot | |
let currTime = new Date(startTime) | |
while (currTime < endTime) { | |
timestamps.push(currTime.getTime()) | |
currTime.setMinutes(currTime.getMinutes() + 10) | |
} | |
}) | |
setBlockedStamps(timestamps) | |
} | |
const handleSubmit = (e) => { | |
e.preventDefault() | |
if (!selectedDay || !startTime || !endTime || !capacity || !ticketCost) | |
return | |
setTicketCosts((prev) => [toWei(ticketCost), ...prev]) | |
setStartTimes((prev) => [new Date(startTime).getTime(), ...prev]) | |
setEndTimes((prev) => [new Date(endTime).getTime(), ...prev]) | |
setCapacities((prev) => [Number(capacity), ...prev]) | |
setViewingDays((prev) => [selectedDay, ...prev]) | |
resetForm() | |
} | |
const saveMovieSlot = async () => { | |
if ( | |
viewingDays.length < 1 || | |
startTimes.length < 1 || | |
endTimes.length < 1 || | |
capacities.length < 1 || | |
ticketCosts.length < 1 | |
) | |
return | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
const params = { | |
movieId: id, | |
ticketCosts, | |
startTimes, | |
endTimes, | |
capacities, | |
viewingDays, | |
} | |
await addTimeslot(params) | |
.then((res) => { | |
setTicketCosts([]) | |
setStartTimes([]) | |
setEndTimes([]) | |
setCapacities([]) | |
setViewingDays([]) | |
router('/timeslot/' + id) | |
resolve(res) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: 'Time slot added successfully 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const resetForm = () => { | |
setSelectedDay(null) | |
setStartTime(null) | |
setEndTime(null) | |
setTicketCost('') | |
setCapacity('') | |
} | |
const removeSlot = (index) => { | |
ticketCosts.splice(index, 1) | |
startTimes.splice(index, 1) | |
endTimes.splice(index, 1) | |
capacities.splice(index, 1) | |
viewingDays.splice(index, 1) | |
setTicketCosts((prevState) => [...prevState]) | |
setStartTimes((prevState) => [...prevState]) | |
setEndTimes((prevState) => [...prevState]) | |
setCapacities((prevState) => [...prevState]) | |
setViewingDays((prevState) => [...prevState]) | |
} | |
return ( | |
<div className="flex justify-center items-center py-12 m-auto w-full"> | |
<div className="block rounded-lg justify-center items-center m-auto sm:shadow-md p-6 shadow-gray-400 w-full sm:w-3/5"> | |
<form onSubmit={handleSubmit} className="flex flex-col"> | |
<div className="flex items-center justify-center mb-4"> | |
<h2>Add Time Slot</h2> | |
</div> | |
<div | |
className="flex flex-row justify-between items-center | |
bg-gray-300 rounded-xl mt-5 p-2" | |
> | |
<DatePicker | |
selected={selectedDay} | |
onChange={(date) => handleSelectedDay(date)} | |
dateFormat="dd/MM/yyyy" | |
placeholderText="Select Day..." | |
minDate={Date.now()} | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5 p-2"> | |
<DatePicker | |
selected={startTime} | |
onChange={setStartTime} | |
showTimeSelect | |
showTimeSelectOnly | |
minDate={new Date(selectedDay)} | |
maxDate={new Date(selectedDay)} | |
minTime={dateMax().minStartTime} | |
maxTime={dateMax().maxStartTime} | |
timeCaption="Start Time" | |
excludeTimes={blockedStamps} | |
timeIntervals={timeInterval} | |
dateFormat="h:mm aa" | |
placeholderText="Select start time..." | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5 p-2"> | |
<DatePicker | |
selected={endTime} | |
onChange={setEndTime} | |
showTimeSelect | |
showTimeSelectOnly | |
timeFormat="p" | |
timeIntervals={timeInterval} | |
excludeTimes={blockedStamps} | |
minDate={new Date(selectedDay)} | |
maxDate={new Date(selectedDay)} | |
minTime={dateMax().minEndTime} | |
maxTime={dateMax().maxEndTime} | |
timeCaption="End Time" | |
dateFormat="h:mm aa" | |
placeholderText="Select end time..." | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5 p-2"> | |
<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="cost" | |
placeholder="Ticket Cost e.g. 0.02 ETH" | |
value={ticketCost} | |
onChange={(e) => setTicketCost(e.target.value)} | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5 p-2"> | |
<input | |
className="block w-full text-sm text-slate-500 bg-transparent | |
border-0 focus:outline-none focus:ring-0" | |
type="number" | |
name="capacity" | |
placeholder="Capacity e.g. 20" | |
value={capacity} | |
onChange={(e) => setCapacity(e.target.value)} | |
/> | |
</div> | |
{startTimes.length > 0 && ( | |
<div className="flex flex-wrap justify-start items-center mt-5 space-x-2 text-xs"> | |
{startTimes.slice(0, 2).map((time, i) => ( | |
<span | |
key={i} | |
className="flex space-x-1 px-2 py-1 mt-1 font-semibold text-gray-700 bg-gray-200 rounded-full" | |
> | |
<span> | |
{convertTimestampToTime(time)} -{' '} | |
{convertTimestampToTime(endTimes[i])} | |
</span> | |
<button onClick={() => removeSlot(i)}> | |
<FaTimes /> | |
</button> | |
</span> | |
))} | |
{startTimes.length - startTimes.slice(0, 2).length > 0 && ( | |
<span | |
className="flex items-center justify-center px-2 py-1 | |
font-semibold text-gray-700 bg-gray-200 rounded-full | |
hover:bg-gray-300 mt-1" | |
> | |
+{startTimes.length - startTimes.slice(0, 2).length} | |
</span> | |
)} | |
</div> | |
)} | |
<div className="flex justify-between items-center mt-6"> | |
<button | |
type="submit" | |
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded" | |
> | |
Add Day | |
</button> | |
<button | |
onClick={saveMovieSlot} | |
type="button" | |
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" | |
> | |
Submit({ticketCosts.length}) | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default AddTimeslot |
All Time slots Page
The **TimeSlots**
component is a page that displays the time slots for a specific movie. It retrieves the movie ID from the URL parameters using **useParams**
and fetches the movie details and corresponding time slots from the blockchain using **getMovie**
and **getSlots**
functions.
The fetched movie information is stored in the **movie**
state variable, while the time slots are stored in the **slots**
state variable using the global state management system.
The component renders a heading displaying the movie name and a **TimeSlotTable**
component to present the time slots in a table format.
Two links are provided: "Add Slots" redirects to a page where new time slots can be added for the movie, while "Back" navigates to the movie management page. Use the code below.
import React, { useEffect } from 'react' | |
import { Link, useParams } from 'react-router-dom' | |
import { getMovie, getSlots } from '../services/blockchain' | |
import { useGlobalState } from '../store' | |
import TimeSlotTable from '../components/TimeSlotTable' | |
const TimeSlots = () => { | |
const { id } = useParams() | |
const [movie] = useGlobalState('movie') | |
const [slots] = useGlobalState('slotsForDay') | |
useEffect(() => { | |
const fetchData = async () => { | |
await getMovie(id) | |
await getSlots(id) | |
} | |
fetchData() | |
}, []) | |
return ( | |
<div className="w-full min-h-screen p-3 space-y-6 mb-10"> | |
<h3 className="my-3 text-3xl font-bold"> | |
Timeslots: <span className="text-gray-500">{movie?.name}</span> | |
</h3> | |
<TimeSlotTable slots={slots} /> | |
<div className="flex space-x-2"> | |
<Link | |
to={'/timeslot/add/' + id} | |
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded" | |
> | |
Add Slots | |
</Link> | |
<Link | |
to={'/manage/movies'} | |
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" | |
> | |
Back | |
</Link> | |
</div> | |
</div> | |
) | |
} | |
export default TimeSlots |
Ticket Holders Page
Lastly, the **TicketHolders**
component is a page that displays the ticket holders for a specific time slot of a movie. It retrieves the slot and movie details from the blockchain, renders a heading with the movie name and slot date, and presents the ticket holders in a table format.
The component includes a "Find Holder" button to search for a specific ticket holder and a "Back" link to navigate back to the time slots page. It uses React, **react-router-dom**
, and global state management. Use the code below.
import React, { useEffect } from 'react' | |
import { Link, useParams } from 'react-router-dom' | |
import { getMovie, getSlot, getTicketHolders } from '../services/blockchain' | |
import { | |
convertTimestampToDate, | |
setGlobalState, | |
useGlobalState, | |
} from '../store' | |
import TicketHoldersTable from '../components/TicketHoldersTable' | |
const TicketHolders = () => { | |
const { slotId, movieId } = useParams() | |
const [slot] = useGlobalState('slot') | |
const [movie] = useGlobalState('movie') | |
const [holders] = useGlobalState('ticketHolders') | |
useEffect(() => { | |
const fetchData = async () => { | |
await getSlot(slotId) | |
await getMovie(movieId) | |
await getTicketHolders(movieId, slotId) | |
} | |
fetchData() | |
}, []) | |
return ( | |
<div className="w-full min-h-screen p-3 space-y-6 mb-10"> | |
<h3 className="my-3 text-3xl font-bold"> | |
{movie?.name}:{' '} | |
<span className="text-gray-500"> | |
{convertTimestampToDate(slot?.day)} | |
</span> | |
</h3> | |
<TicketHoldersTable holders={holders} slot={slot} /> | |
<div className="flex space-x-2"> | |
<button | |
onClick={() => setGlobalState('holderSearchModal', 'scale-100')} | |
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded" | |
> | |
Find Holder | |
</button> | |
<Link | |
to={'/timeslot/' + movieId} | |
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" | |
> | |
Back | |
</Link> | |
</div> | |
</div> | |
) | |
} | |
export default TicketHolders |
Fantastic, we have so far worked on all the components and pages of this project, let’s couple them together with their respective services. Let’s start with the App component.
App Entry File
The **App**
component serves as the main entry point for the Dapp Cinemas project. It sets up the application's routing using **react-router-dom**
and includes various pages and components to manage movies, time slots, ticket holders, and other functionality.
The component also initializes the connection to the blockchain, loads blockchain data, and checks the authentication state. It renders a layout with a sidebar, header, and content area. The content area displays different pages based on the current route, such as listing movies, movie details, time slots, adding time slots, managing movies, etc.
Additionally, it includes several auxiliary components for updating movies, deleting movies and time slots, finding ticket holders, and making withdrawals. The component also utilizes the **react-toastify**
library for displaying toast notifications.
Replace the existing App.jsx
component with the code below.
import { useEffect } from 'react' | |
import { Routes, Route } from 'react-router-dom' | |
import { isWalletConnected, loadBlockchainData } from './services/blockchain' | |
import Sidebar from './components/Sidebar' | |
import Header from './components/Header' | |
import Addmovie from './pages/Addmovie' | |
import ListMovies from './pages/ListMovies' | |
import MovieDetails from './pages/MovieDetails' | |
import ManageMovies from './pages/ManageMovies' | |
import { ToastContainer } from 'react-toastify' | |
import UpdateMovie from './components/UpdateMovie' | |
import DeleteMovie from './components/DeleteMovie' | |
import { checkAuthState } from './services/chat' | |
import TimeSlots from './pages/TimeSlots' | |
import AddTimeslot from './pages/AddTimeslot' | |
import DeleteSlot from './components/DeleteSlot' | |
import TicketHolders from './pages/TicketHolders' | |
import FindHolder from './components/FindHolder' | |
import Withdrawal from './components/Withdrawal' | |
const App = () => { | |
useEffect(() => { | |
const fetchData = async () => { | |
await isWalletConnected() | |
await loadBlockchainData() | |
await checkAuthState() | |
} | |
fetchData() | |
}, []) | |
return ( | |
<div className="h-screen flex"> | |
<Sidebar /> | |
<div className="flex-1 overflow-auto"> | |
<Header /> | |
<Routes> | |
<Route path="/" element={<ListMovies />} /> | |
<Route path="/movie/:id" element={<MovieDetails />} /> | |
<Route path="/timeslot/:id" element={<TimeSlots />} /> | |
<Route path="/timeslot/add/:id" element={<AddTimeslot />} /> | |
<Route | |
path="/timeslot/:movieId/:slotId" | |
element={<TicketHolders />} | |
/> | |
<Route path="/add/movies" element={<Addmovie />} /> | |
<Route path="/manage/movies" element={<ManageMovies />} /> | |
</Routes> | |
</div> | |
<UpdateMovie /> | |
<DeleteMovie /> | |
<DeleteSlot /> | |
<FindHolder /> | |
<Withdrawal /> | |
<ToastContainer | |
position="bottom-center" | |
autoClose={5000} | |
hideProgressBar={false} | |
newestOnTop={false} | |
closeOnClick | |
rtl={false} | |
pauseOnFocusLoss | |
draggable | |
pauseOnHover | |
theme="dark" | |
/> | |
</div> | |
) | |
} | |
export default App |
The Blockchain Service
The provided service is responsible for interacting with the Ethereum blockchain and managing the Dapp Cinemas smart contract. It allows the application to perform various actions such as adding movies, updating movie details, deleting movies, adding time slots, deleting time slots, buying tickets, and handling withdrawals.
The service utilizes the Ethereum provider (**Metamask**
) to connect to the user's wallet and perform transactions using the **ethers.js**
library. It includes helper functions for converting between Wei and Ether units and for structuring movie and timeslot data.
All the components and pages in the application leverage this service to interact with the blockchain and retrieve/update movie and timeslot information. They call the appropriate functions from this service to perform the desired actions, ensuring the application's functionality and data synchronization with the blockchain.
To utilize this code, create a folder called “services” in the “src” directory of the project and another file called “blockchain.js
” within this folder. Now copy and paste the code below inside of it.
import abi from '../abis/src/contracts/DappCinemas.sol/DappCinemas.json' | |
import address from '../abis/contractAddress.json' | |
import { setGlobalState } from '../store' | |
import { ethers } from 'ethers' | |
import { logOutWithCometChat } from './chat' | |
const { ethereum } = window | |
const ContractAddress = address.address | |
const ContractAbi = abi.abi | |
let tx | |
const toWei = (num) => ethers.utils.parseEther(num.toString()) | |
const fromWei = (num) => ethers.utils.formatEther(num) | |
const getEthereumContract = async () => { | |
const accounts = await ethereum.request({ method: 'eth_accounts' }) | |
const provider = accounts[0] | |
? new ethers.providers.Web3Provider(ethereum) | |
: new ethers.providers.JsonRpcProvider(process.env.REACT_APP_RPC_URL) | |
const wallet = accounts[0] ? null : ethers.Wallet.createRandom() | |
const signer = provider.getSigner(accounts[0] ? undefined : wallet.address) | |
const contract = new ethers.Contract(ContractAddress, ContractAbi, signer) | |
return contract | |
} | |
const isWalletConnected = async () => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const accounts = await ethereum.request({ method: 'eth_accounts' }) | |
if (accounts.length) { | |
setGlobalState('connectedAccount', accounts[0]) | |
} else { | |
reportError('Please connect wallet.') | |
console.log('No accounts found.') | |
} | |
window.ethereum.on('chainChanged', (chainId) => { | |
window.location.reload() | |
}) | |
window.ethereum.on('accountsChanged', async () => { | |
setGlobalState('connectedAccount', accounts[0]) | |
await isWalletConnected() | |
await logOutWithCometChat() | |
}) | |
if (accounts.length) { | |
setGlobalState('connectedAccount', accounts[0]) | |
} else { | |
setGlobalState('connectedAccount', '') | |
console.log('No accounts found') | |
} | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const connectWallet = async () => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const accounts = await ethereum.request({ method: 'eth_requestAccounts' }) | |
setGlobalState('connectedAccount', accounts[0]) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const addMovie = async ({ name, imageUrl, genre, description }) => { | |
if (!ethereum) return alert('Please install metamask') | |
return new Promise(async (resolve, reject) => { | |
try { | |
const contract = await getEthereumContract() | |
tx = await contract.addMovie(name, imageUrl, genre, description) | |
await tx.wait() | |
await loadBlockchainData() | |
resolve(tx) | |
} catch (error) { | |
reportError(error) | |
reject(error) | |
} | |
}) | |
} | |
const updateMovie = async ({ id, name, imageUrl, genre, description }) => { | |
if (!ethereum) return alert('Please install metamask') | |
return new Promise(async (resolve, reject) => { | |
try { | |
const contract = await getEthereumContract() | |
tx = await contract.updateMovie(id, name, imageUrl, genre, description) | |
await tx.wait() | |
await loadBlockchainData() | |
resolve(tx) | |
} catch (error) { | |
reportError(error) | |
reject(error) | |
} | |
}) | |
} | |
const deleteMovie = async (movieId) => { | |
if (!ethereum) return alert('Please install metamask') | |
return new Promise(async (resolve, reject) => { | |
try { | |
const contract = await getEthereumContract() | |
tx = await contract.deleteMovie(movieId) | |
await tx.wait() | |
await loadBlockchainData() | |
resolve(tx) | |
} catch (error) { | |
reportError(error) | |
reject(error) | |
} | |
}) | |
} | |
const addTimeslot = async ({ | |
movieId, | |
ticketCosts, | |
startTimes, | |
endTimes, | |
capacities, | |
viewingDays, | |
}) => { | |
if (!ethereum) return alert('Please install metamask') | |
return new Promise(async (resolve, reject) => { | |
try { | |
const contract = await getEthereumContract() | |
tx = await contract.addTimeslot( | |
movieId, | |
ticketCosts, | |
startTimes, | |
endTimes, | |
capacities, | |
viewingDays | |
) | |
await tx.wait() | |
resolve(tx) | |
} catch (error) { | |
reportError(error) | |
reject(error) | |
} | |
}) | |
} | |
const deleteSlot = async ({ movieId, id }) => { | |
if (!ethereum) return alert('Please install metamask') | |
return new Promise(async (resolve, reject) => { | |
try { | |
const contract = await getEthereumContract() | |
tx = await contract.deleteTimeSlot(movieId, id) | |
await tx.wait() | |
await getSlots(movieId) | |
resolve(tx) | |
} catch (error) { | |
reportError(error) | |
reject(error) | |
} | |
}) | |
} | |
const markSlot = async ({ movieId, id }) => { | |
if (!ethereum) return alert('Please install metamask') | |
return new Promise(async (resolve, reject) => { | |
try { | |
const contract = await getEthereumContract() | |
tx = await contract.markTimeSlot(movieId, id) | |
await tx.wait() | |
await getSlots(movieId) | |
resolve(tx) | |
} catch (error) { | |
reportError(error) | |
reject(error) | |
} | |
}) | |
} | |
const buyTicket = async ({ movieId, id, ticketCost }) => { | |
if (!ethereum) return alert('Please install metamask') | |
return new Promise(async (resolve, reject) => { | |
try { | |
const contract = await getEthereumContract() | |
tx = await contract.buyTicket(movieId, id, 1, { | |
value: toWei(ticketCost), | |
}) | |
await tx.wait() | |
await getSlots(movieId) | |
resolve(tx) | |
} catch (error) { | |
reportError(error) | |
reject(error) | |
} | |
}) | |
} | |
const withdraw = async ({ account, amount }) => { | |
if (!ethereum) return alert('Please install metamask') | |
return new Promise(async (resolve, reject) => { | |
try { | |
const contract = await getEthereumContract() | |
tx = await contract.withdrawTo(account, toWei(amount)) | |
await tx.wait() | |
await getData() | |
resolve(tx) | |
} catch (error) { | |
reportError(error) | |
reject(error) | |
} | |
}) | |
} | |
const getMovies = async () => { | |
if (!ethereum) return alert('Please install metamask') | |
try { | |
const contract = await getEthereumContract() | |
const movies = await contract.getMovies() | |
setGlobalState('movies', structuredMovie(movies)) | |
} catch (err) { | |
reportError(err) | |
} | |
} | |
const getMovie = async (id) => { | |
if (!ethereum) return alert('Please install metamask') | |
try { | |
const contract = await getEthereumContract() | |
const movie = await contract.getMovie(id) | |
setGlobalState('movie', structuredMovie([movie])[0]) | |
} catch (err) { | |
reportError(err) | |
} | |
} | |
const getSlots = async (movieId) => { | |
if (!ethereum) return alert('Please install metamask') | |
try { | |
const contract = await getEthereumContract() | |
const slots = await contract.getTimeSlots(movieId) | |
setGlobalState('slotsForDay', structuredTimeslot(slots)) | |
} catch (err) { | |
reportError(err) | |
} | |
} | |
const getSlotsByDay = async (day) => { | |
if (!ethereum) return alert('Please install metamask') | |
try { | |
const contract = await getEthereumContract() | |
const slots = await contract.getTimeSlotsByDay(day) | |
setGlobalState('currentSlots', structuredTimeslot(slots)) | |
} catch (err) { | |
reportError(err) | |
} | |
} | |
const getSlot = async (slotId) => { | |
if (!ethereum) return alert('Please install metamask') | |
try { | |
const contract = await getEthereumContract() | |
const slot = await contract.getTimeSlot(slotId) | |
setGlobalState('slot', structuredTimeslot([slot])[0]) | |
} catch (err) { | |
reportError(err) | |
} | |
} | |
const getTicketHolders = async (movieId, id) => { | |
if (!ethereum) return alert('Please install metamask') | |
try { | |
const contract = await getEthereumContract() | |
const ticketHolders = await contract.getMovieTicketHolders(movieId, id) | |
setGlobalState('ticketHolders', ticketHolders) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const getData = async () => { | |
if (!ethereum) return alert('Please install metamask') | |
try { | |
const contract = await getEthereumContract() | |
const deployer = await contract.owner() | |
const balance = await contract.balance() | |
setGlobalState('deployer', deployer.toLowerCase()) | |
setGlobalState('balance', fromWei(balance)) | |
} catch (err) { | |
reportError(err) | |
} | |
} | |
const loadBlockchainData = async () => { | |
await getMovies() | |
await getData() | |
} | |
const reportError = (error) => { | |
console.log(error) | |
} | |
const structuredMovie = (movies) => | |
movies | |
.map((movie) => ({ | |
id: Number(movie.id), | |
name: movie.name, | |
imageUrl: movie.imageUrl, | |
genre: movie.genre, | |
description: movie.description, | |
timestamp: Number(movie.timestamp), | |
deleted: movie.deleted, | |
})) | |
.sort((a, b) => b.timestamp - a.timestamp) | |
const structuredTimeslot = (slots) => | |
slots | |
.map((slot) => ({ | |
id: Number(slot.id), | |
movieId: Number(slot.movieId), | |
ticketCost: fromWei(slot.ticketCost), | |
startTime: Number(slot.startTime), | |
endTime: Number(slot.endTime), | |
capacity: Number(slot.capacity), | |
seats: Number(slot.seats), | |
deleted: slot.deleted, | |
completed: slot.completed, | |
day: Number(slot.day), | |
balance: fromWei(slot.balance), | |
})) | |
.sort((a, b) => { | |
if (a.day !== b.day) return a.day - b.day | |
return a.startTime - b.startTime | |
}) | |
export { | |
getEthereumContract, | |
loadBlockchainData, | |
isWalletConnected, | |
getTicketHolders, | |
getSlotsByDay, | |
connectWallet, | |
deleteMovie, | |
addTimeslot, | |
updateMovie, | |
deleteSlot, | |
buyTicket, | |
withdraw, | |
addMovie, | |
getMovie, | |
getSlots, | |
markSlot, | |
getSlot, | |
toWei, | |
} |
Please ensure that you update the environment variables to look like this:
REACT_APP_COMETCHAT_APP_ID=****************
REACT_APP_COMETCHAT_AUTH_KEY=******************************
REACT_APP_COMETCHAT_REGION=**
REACT_APP_RPC_URL=http://127.0.0.1:8545
The Chat Service
The chat service provided uses the CometChat Pro SDK to enable chat functionality in the application. It includes functions to initialize CometChat, handle user authentication, interact with chat groups, fetch and send messages, and listen for incoming messages.
The **initCometChat**
function initializes CometChat with the app ID and region. User authentication is managed through the **loginWithCometChat**
and **signUpWithCometChat**
functions. The **logOutWithCometChat**
function logs the user out and updates the global state.
Group management is handled by functions such as **createNewGroup**
, **getGroup**
, and **joinGroup**
. Message-related operations are performed using functions like **getMessages**
, **sendMessage**
, and **listenForMessage**
.
All components and pages in the application rely on these functions to leverage the chat service's capabilities, including user authentication, group interactions, and message handling.
Still inside the services folder, create a file named “chat.jsx”, copy and paste the code below into it.
import { CometChat } from '@cometchat-pro/chat' | |
import { setGlobalState } from '../store' | |
const CONSTANTS = { | |
APP_ID: process.env.REACT_APP_COMET_CHAT_APP_ID, | |
REGION: process.env.REACT_APP_COMET_CHAT_REGION, | |
Auth_Key: process.env.REACT_APP_COMET_CHAT_AUTH_KEY, | |
} | |
const initCometChat = async () => { | |
const appID = CONSTANTS.APP_ID | |
const region = CONSTANTS.REGION | |
const appSetting = new CometChat.AppSettingsBuilder() | |
.subscribePresenceForAllUsers() | |
.setRegion(region) | |
.build() | |
await CometChat.init(appID, appSetting) | |
.then(() => console.log('Initialization completed successfully')) | |
.catch((error) => console.log(error)) | |
} | |
const loginWithCometChat = async (UID) => { | |
const authKey = CONSTANTS.Auth_Key | |
return new Promise(async (resolve, reject) => { | |
await CometChat.login(UID, authKey) | |
.then((user) => resolve(user)) | |
.catch((error) => reject(error)) | |
}) | |
} | |
const signUpWithCometChat = async (UID) => { | |
const authKey = CONSTANTS.Auth_Key | |
const user = new CometChat.User(UID) | |
user.setName(UID) | |
return new Promise(async (resolve, reject) => { | |
await CometChat.createUser(user, authKey) | |
.then((user) => resolve(user)) | |
.catch((error) => reject(error)) | |
}) | |
} | |
const logOutWithCometChat = async () => { | |
return new Promise(async (resolve, reject) => { | |
await CometChat.logout() | |
.then(() => { | |
setGlobalState('currentUser', null) | |
resolve() | |
}) | |
.catch(() => reject()) | |
}) | |
} | |
const checkAuthState = async () => { | |
return new Promise(async (resolve, reject) => { | |
await CometChat.getLoggedinUser() | |
.then((user) => { | |
setGlobalState('currentUser', user) | |
resolve(user) | |
}) | |
.catch((error) => reject(error)) | |
}) | |
} | |
const createNewGroup = async (GUID, groupName) => { | |
const groupType = CometChat.GROUP_TYPE.PUBLIC | |
const password = '' | |
const group = new CometChat.Group(GUID, groupName, groupType, password) | |
return new Promise(async (resolve, reject) => { | |
await CometChat.createGroup(group) | |
.then((group) => resolve(group)) | |
.catch((error) => reject(error)) | |
}) | |
} | |
const getGroup = async (GUID) => { | |
return new Promise(async (resolve, reject) => { | |
await CometChat.getGroup(GUID) | |
.then((group) => resolve(group)) | |
.catch((error) => reject(error)) | |
}) | |
} | |
const joinGroup = async (GUID) => { | |
const groupType = CometChat.GROUP_TYPE.PUBLIC | |
const password = '' | |
return new Promise(async (resolve, reject) => { | |
await CometChat.joinGroup(GUID, groupType, password) | |
.then((group) => resolve(group)) | |
.catch((error) => reject(error)) | |
}) | |
} | |
const getMessages = async (UID) => { | |
const limit = 30 | |
const messagesRequest = new CometChat.MessagesRequestBuilder() | |
.setGUID(UID) | |
.setLimit(limit) | |
.build() | |
return new Promise(async (resolve, reject) => { | |
await messagesRequest | |
.fetchPrevious() | |
.then((messages) => resolve(messages.filter((msg) => msg.type == 'text'))) | |
.catch((error) => reject(error)) | |
}) | |
} | |
const sendMessage = async (receiverID, messageText) => { | |
const receiverType = CometChat.RECEIVER_TYPE.GROUP | |
const textMessage = new CometChat.TextMessage( | |
receiverID, | |
messageText, | |
receiverType | |
) | |
return new Promise(async (resolve, reject) => { | |
await CometChat.sendMessage(textMessage) | |
.then((message) => resolve(message)) | |
.catch((error) => reject(error)) | |
}) | |
} | |
const listenForMessage = async (listenerID) => { | |
return new Promise(async (resolve, reject) => { | |
CometChat.addMessageListener( | |
listenerID, | |
new CometChat.MessageListener({ | |
onTextMessageReceived: (message) => resolve(message), | |
}) | |
) | |
}) | |
} | |
export { | |
initCometChat, | |
loginWithCometChat, | |
signUpWithCometChat, | |
logOutWithCometChat, | |
getMessages, | |
sendMessage, | |
checkAuthState, | |
createNewGroup, | |
getGroup, | |
joinGroup, | |
listenForMessage, | |
} |
Great, now let’s work on the store file that serves as a state management library.
The Store File
The store service provided below is responsible for managing global state in the application using the [**react-hooks-global-state**](https://www.npmjs.com/package/react-hooks-global-state)
library. It includes functions and variables for setting, retrieving, and using global state values. The initial global state includes properties such as **movies**
, **messages**
, **balance**
, **currentSlots**
, **ticketHolders**
, and more.
Additionally, the service provides utility functions like **truncate**
to truncate text based on a specified length, **convertTimestampToDate**
to convert a timestamp to a formatted date, and **convertTimestampToTime**
to convert a timestamp to a formatted time.
The functions **setGlobalState**
and **useGlobalState**
are used to set and access global state values, respectively, while **getGlobalState**
retrieves the current global state.
The components and pages in the application leverage this store service to manage and share data across different parts of the application, ensuring synchronized state management and access to shared data.
Create a folder called “store” in the “src” directory of this project and another file called “**index.jsx**
” inside of it. Now copy and paste the code below inside of it.
import { createGlobalState } from 'react-hooks-global-state' | |
const { setGlobalState, useGlobalState, getGlobalState } = createGlobalState({ | |
movies: [], | |
movie: null, | |
messages: [], | |
balance: 0, | |
currentSlots: [], | |
slotsForDay: [], | |
slotsForMovie: [], | |
ticketHolders: [], | |
connectedAccount: '', | |
slot: null, | |
deployer: null, | |
withdrwalModal: 'scale-0', | |
updateMovieModal: 'scale-0', | |
deleteMovieModal: 'scale-0', | |
deleteSlotModal: 'scale-0', | |
slotsModal: 'scale-0', | |
addSlotModal: 'scale-0', | |
holderSearchModal: 'scale-0', | |
currentUser: null, | |
chatModal: 'scale-0', | |
chatCommandModal: 'scale-0', | |
authChatModal: 'scale-0', | |
group: null, | |
}) | |
const truncate = (text, startChars, endChars, maxLength) => { | |
if (text.length > maxLength) { | |
let start = text.substring(0, startChars) | |
let end = text.substring(text.length - endChars, text.length) | |
while (start.length + end.length < maxLength) { | |
start = start + '.' | |
} | |
return start + end | |
} | |
return text | |
} | |
const convertTimestampToDate = (timestamp) => { | |
const date = new Date(timestamp) | |
const options = { month: 'long', day: 'numeric', year: 'numeric' } | |
return date.toLocaleDateString('en-US', options) | |
} | |
const convertTimestampToTime = (timestamp) => { | |
const date = new Date(timestamp) | |
let hours = date.getHours() | |
const minutes = date.getMinutes() | |
const amPm = hours >= 12 ? 'PM' : 'AM' | |
hours = hours % 12 || 12 | |
const formattedTime = `${hours.toString().padStart(2, '0')}:${minutes | |
.toString() | |
.padStart(2, '0')} ${amPm}` | |
return formattedTime | |
} | |
export { | |
setGlobalState, | |
useGlobalState, | |
getGlobalState, | |
truncate, | |
convertTimestampToDate, | |
convertTimestampToTime, | |
} |
The Index file
The **index.jsx**
file serves as the entry point of the application. It initializes the CometChat service, sets up dependencies, and renders the React application using the **App**
component within a **BrowserRouter**
. It creates a root element for rendering and sets up the necessary configurations for the application to start running.
Replace the code below inside of the “**index.jsx**
” file in the src folder of this project.
import React from 'react' | |
import ReactDOM from 'react-dom/client' | |
import { BrowserRouter } from 'react-router-dom' | |
import './index.css' | |
import 'react-toastify/dist/ReactToastify.css' | |
import App from './App' | |
import { initCometChat } from './services/chat' | |
const root = ReactDOM.createRoot(document.getElementById('root')) | |
initCometChat().then(() => { | |
root.render( | |
<React.StrictMode> | |
<BrowserRouter> | |
<App /> | |
</BrowserRouter> | |
</React.StrictMode> | |
) | |
}) |
The Static Assets
Create a folder called “assets” in the “src” directory and put the image below inside of it.
Now execute **yarn start**
to have you application running on the browser.
Congratulations on building a web3 cinema ticket booking Dapp that features real-time chat communication with the CometChat SDK! If you're looking for a powerful and versatile chat SDK that can be used to add chat functionality to any application, I encourage you to try out CometChat. CometChat offers a wide range of chat features, including 1-on-1 chat, group chat, file sharing, and more. It's also very easy to integrate with other platforms, making it a great choice for developers of all skill levels.
Conclusion
Dapp Cinemas is a decentralized application that uses blockchain technology to revolutionize the movie industry. The project offers a comprehensive solution for movie management, showtime scheduling, ticket booking, and revenue tracking.
The smart contracts on the Ethereum blockchain ensure seamless and transparent operations. The front-end interface provides an intuitive user experience, and the real-time chat functionality enhances user engagement. Comprehensive testing ensures the reliability and correctness of the smart contracts.
Dapp Cinemas demonstrates the potential of blockchain technology in revolutionizing the movie industry, providing benefits such as increased transparency, secure transactions, and efficient revenue management.
If you're interested in learning more about web3 development, be sure to subscribe to our YouTube channel and check out website. We have a wide range of tutorials, courses, and books that can help you get started.
Till next time, all the best!
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
Twitter: Follow
LinkedIn: Connect
GitHub: Explore
Website: Visit
Top comments (5)
Thanks For Sharing
You're welcome
Saved. Love the detailed explanation.
Thanks man
You might find interesting to take a look at Scrypto.