Introduction
What you will be building, see the demo on the Goerli test network and git repo here.
Web3 development is officially the new way to build web applications, and if you are not there yet, you need to catch up. The way to master building web3 apps is by understanding smart contracts, a frontend framework such as React, and how to link the smart contract with the front end.
In this tutorial, you will learn how to build a decentralized web3 eShop for selling game items using the native ethereum currency.
This app comprises the smart contract layer, a front end where all the interactions with the smart contract take place, and an anonymous chat feature using the CometChat SDK.
Subscribe to my YouTube channel to learn how to build a Web3 app from scratch. I also offer private and specialized classes for serious folks who want to learn one-on-one from a mentor. Book your Web3 classes here.
If you are ready to crush this build, then let’s get started.
Prerequisite
You will need the following tools installed to build along with me:
- NodeJs (Super important)
- EthersJs
- Hardhat
- React
- Tailwind CSS
- CometChat SDK
- Metamask
- Yarn ## Installing Dependencies
Clone the starter project from this Git repo to your computer. Also, make sure to replace it with the name of your preferred project. See the command below.
git clone https://github.com/Daltonic/tailwind_ethers_starter_kit <PROJECT_NAME>
cd <PROJECT_NAME>
Now, open the project in VS Code or on your preferred code editor. Locate the package.json
file and update it with the codes below.
{ | |
"name": "GameShop", | |
"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", | |
"deploy": "yarn hardhat run scripts/deploy.js --network localhost" | |
}, | |
"dependencies": { | |
"@cometchat-pro/chat": "^3.0.10", | |
"@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", | |
"ipfs-http-client": "^57.0.3", | |
"moment": "^2.29.4", | |
"react": "^17.0.2", | |
"react-dom": "^17.0.2", | |
"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.0.8", | |
"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" | |
] | |
} | |
} |
With the above codes replaced and saved in your package.json, run the command below to install the entire packages listed above.
yarn install
Configuring CometChat SDK
Follow the steps below to configure the CometChat SDK; at the end, you must save these keys as an environment variable.
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 GameShop.
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_COMET_CHAT_REGION=**
REACT_APP_COMET_CHAT_APP_ID=**************
REACT_APP_COMET_CHAT_AUTH_KEY=******************************
The **.env**
file should be created at the root of your project.
Configuring Alchemy App
STEP 1:
Head to Alchemy, and create an account.
STEP 2:
From the dashboard create a new project.
STEP 3:
Copy the Goerli
test network WebSocket or HTTPS endpoint URL to your .env
file.
After that, enter the private key of your preferred Metamask account to the DEPLOYER_KEY
in your environment variables and save. If you followed the instructions correctly, your environment variables should now look like this.
ENDPOINT_URL=***************************
DEPLOYER_KEY=**********************
REACT_APP_COMET_CHAT_REGION=**
REACT_APP_COMET_CHAT_APP_ID=**************
REACT_APP_COMET_CHAT_AUTH_KEY=******************************
See the section below if you don't know how to access your private key.
Extracting Your Metamask Private Key
STEP 1:
Make sure Goerli is selected as the test network in your Metamask browser extension, Rinkeby and the older test-nets have now been depreciated.
Next, on the preferred account, click the vertical dotted line and choose account details. Please see the image below.
STEP 2:
Enter your password on the field provided and click the confirm button, this will enable you to access your account private key.
STEP 3:
Click on "export private key" to see your private key. Make sure you never expose your keys on a public page such as Github
. That is why we are appending it as an environment variable.
STEP 4:
Copy your private key to your .env
file. See the image and code snippet below:
ENDPOINT_URL=***************************
DEPLOYER_KEY=**********************
REACT_APP_COMET_CHAT_REGION=**
REACT_APP_COMET_CHAT_APP_ID=**************
REACT_APP_COMET_CHAT_AUTH_KEY=******************************
Configuring the Hardhat script
At the root of this project, open the hardhat.config.js
file and replace its content with the following settings.
require("@nomiclabs/hardhat-waffle"); | |
require('dotenv').config() | |
module.exports = { | |
defaultNetwork: "localhost", | |
networks: { | |
hardhat: { | |
}, | |
localhost: { | |
url: "http://127.0.0.1:8545" | |
}, | |
goerli: { | |
url: process.env.ENDPOINT_URL, | |
accounts: [process.env.DEPLOYER_KEY] | |
} | |
}, | |
solidity: { | |
version: '0.8.11', | |
settings: { | |
optimizer: { | |
enabled: true, | |
runs: 200 | |
} | |
} | |
}, | |
paths: { | |
sources: "./src/contracts", | |
artifacts: "./src/abis" | |
}, | |
mocha: { | |
timeout: 40000 | |
} | |
} |
The above script instructs hardhat on these three important rules.
Networks: This block contains the configurations for your choice of networks. On deployment, hardhat will require you to specify a network for shipping your smart contracts.
Solidity: This describes the version of the compiler to be used by hardhat for compiling your smart contract codes into
bytecodes
andabi
.-
Paths: This simply informs hardhat of the location of your smart contracts and also a location to dump the output of the compiler which is the abi.
The Blockchain Service File
Now that we have the above configurations set up, let’s create the smart contract for this build. On your project, head to the **src**
directory and create a new folder called **contracts**
.
Inside this contracts folder, create a new file called **Shop.sol**
, this file will contain all the logics that regulate the activities of the smart contract. Copy, paste, and save the codes below inside the **Shop.sol**
file. See the full code below.
//SPDX-License-Identifier: MIT | |
pragma solidity >=0.7.0 <0.9.0; | |
contract Shop { | |
enum OrderEnum { | |
PLACED, | |
DELEVIRED, | |
CANCELED, | |
REFUNDED | |
} | |
struct ProductStruct { | |
uint id; | |
string sku; | |
address seller; | |
string name; | |
string imageURL; | |
string description; | |
uint price; | |
uint timestamp; | |
bool deleted; | |
uint stock; | |
} | |
struct OrderStruct { | |
uint pid; | |
uint id; | |
string sku; | |
string name; | |
string imageURL; | |
address buyer; | |
address seller; | |
uint qty; | |
uint total; | |
uint timestamp; | |
string destination; | |
string phone; | |
OrderEnum status; | |
} | |
struct CartStruct { | |
uint id; | |
uint qty; | |
} | |
struct BuyerStruct { | |
address buyer; | |
uint price; | |
uint qty; | |
uint timestamp; | |
} | |
struct ShopStats { | |
uint products; | |
uint orders; | |
uint sellers; | |
uint sales; | |
uint paid; | |
uint balance; | |
} | |
address public owner; | |
ShopStats public stats; | |
uint public fee; | |
ProductStruct[] products; | |
mapping(address => ProductStruct[]) productsOf; | |
mapping(uint => OrderStruct[]) ordersOf; | |
mapping(address => ShopStats) public statsOf; | |
mapping(uint => BuyerStruct[]) buyersOf; | |
mapping(uint => bool) public productExist; | |
mapping(uint => mapping(uint => bool)) public orderExist; | |
event Sale( | |
uint256 id, | |
address indexed buyer, | |
address indexed seller, | |
uint256 price, | |
uint256 timestamp | |
); | |
constructor(uint _fee) { | |
owner = msg.sender; | |
fee = _fee; | |
} | |
function createProduct( | |
string memory sku, | |
string memory name, | |
string memory description, | |
string memory imageURL, | |
uint price, | |
uint stock | |
) public payable returns (bool) { | |
require(msg.value >= fee, "Insufficient fund"); | |
require(bytes(sku).length > 0, "sku cannot be empty"); | |
require(bytes(name).length > 0, "name cannot be empty"); | |
require(bytes(description).length > 0, "description cannot be empty"); | |
require(bytes(imageURL).length > 0, "image URL cannot be empty"); | |
require(price > 0, "price cannot be zero"); | |
require(stock > 0, "stock cannot be zero"); | |
productExist[stats.products] = true; | |
statsOf[msg.sender].products++; | |
stats.sellers++; | |
ProductStruct memory product; | |
product.id = stats.products++; | |
product.sku = sku; | |
product.seller = msg.sender; | |
product.name = name; | |
product.imageURL = imageURL; | |
product.description = description; | |
product.price = price; | |
product.stock = stock; | |
product.timestamp = block.timestamp; | |
products.push(product); | |
return true; | |
} | |
function updateProduct( | |
uint id, | |
string memory name, | |
string memory description, | |
string memory imageURL, | |
uint price, | |
uint stock | |
) public returns (bool) { | |
require(products[id].seller == msg.sender, "Unauthorize Personel"); | |
require(bytes(name).length > 0, "name cannot be empty"); | |
require(bytes(description).length > 0, "description cannot be empty"); | |
require(price > 0, "price cannot be zero"); | |
require(stock > 0, "stock cannot be zero"); | |
ProductStruct memory product; | |
product.id = id; | |
product.seller = msg.sender; | |
product.name = name; | |
product.imageURL = imageURL; | |
product.description = description; | |
product.price = price; | |
product.stock = stock; | |
products[id] = product; | |
updateOrderDetails(product); | |
return true; | |
} | |
function updateOrderDetails(ProductStruct memory product) internal { | |
for(uint i=0; i < ordersOf[product.id].length; i++) { | |
OrderStruct memory order = ordersOf[product.id][i]; | |
order.name = product.name; | |
order.imageURL = product.imageURL; | |
ordersOf[product.id][i] = order; | |
} | |
} | |
function deleteProduct(uint id) public returns (bool) { | |
require(products[id].seller == msg.sender, "Unauthorize Personel"); | |
products[id].deleted = true; | |
return true; | |
} | |
function getProduct(uint id) public view returns (ProductStruct memory) { | |
require(productExist[id], "Product not found"); | |
return products[id]; | |
} | |
function getProducts() public view returns (ProductStruct[] memory) { | |
return products; | |
} | |
function createOrder( | |
uint[] memory ids, | |
uint[] memory qtys, | |
string memory destination, | |
string memory phone | |
) public payable returns (bool) { | |
require(msg.value >= totalCost(ids, qtys), "Insufficient amount"); | |
require(bytes(destination).length > 0, "destination cannot be empty"); | |
require(bytes(phone).length > 0, "phone cannot be empty"); | |
stats.balance += totalCost(ids, qtys); | |
for(uint i = 0; i < ids.length; i++) { | |
if(productExist[ids[i]] && products[ids[i]].stock >= qtys[i]) { | |
products[ids[i]].stock -= qtys[i]; | |
statsOf[msg.sender].orders++; | |
stats.orders++; | |
OrderStruct memory order; | |
order.pid = products[ids[i]].id; | |
order.id = ordersOf[order.pid].length; // order Id resolved | |
order.sku = products[ids[i]].sku; | |
order.buyer = msg.sender; | |
order.seller = products[ids[i]].seller; | |
order.name = products[ids[i]].name; | |
order.imageURL = products[ids[i]].imageURL; | |
order.qty = qtys[i]; | |
order.total = qtys[i] * products[ids[i]].price; | |
order.timestamp = block.timestamp; | |
order.destination = destination; | |
order.phone = phone; | |
ordersOf[order.pid].push(order); | |
orderExist[order.pid][order.id] = true; | |
emit Sale( | |
order.id, | |
order.buyer, | |
order.seller, | |
order.total, | |
block.timestamp | |
); | |
} | |
} | |
return true; | |
} | |
function totalCost(uint[] memory ids, uint[] memory qtys) internal view returns (uint) { | |
uint total; | |
for(uint i = 0; i < ids.length; i++) { | |
total += products[i].price * qtys[i]; | |
} | |
return total; | |
} | |
function deliverOrder(uint pid, uint id) public returns (bool) { | |
require(orderExist[pid][id], "Order not found"); | |
OrderStruct memory order = ordersOf[pid][id]; | |
require(order.seller == msg.sender, "Unauthorized Entity"); | |
require(order.status != OrderEnum.DELEVIRED, "Order already delievered"); | |
order.status = OrderEnum.DELEVIRED; | |
ordersOf[pid][id] = order; | |
stats.balance -= order.total; | |
statsOf[order.seller].paid += order.total; | |
statsOf[order.seller].sales++; | |
stats.sales++; | |
payTo(order.seller, order.total); | |
buyersOf[id].push( | |
BuyerStruct( | |
order.buyer, | |
order.total, | |
order.qty, | |
block.timestamp | |
) | |
); | |
return true; | |
} | |
function cancelOrder(uint pid, uint id) public returns (bool) { | |
require(orderExist[pid][id], "Order not found"); | |
OrderStruct memory order = ordersOf[pid][id]; | |
require(order.buyer == msg.sender, "Unauthorized Entity"); | |
require(order.status != OrderEnum.CANCELED, "Order already canceled"); | |
order.status = OrderEnum.CANCELED; | |
products[order.pid].stock += order.qty; | |
ordersOf[pid][id] = order; | |
payTo(order.buyer, order.total); | |
return true; | |
} | |
function getOrders() public view returns (OrderStruct[] memory props) { | |
props = new OrderStruct[](stats.orders); | |
for(uint i=0; i < stats.orders; i++) { | |
for(uint j=0; j < ordersOf[i].length; j++) { | |
props[i] = ordersOf[i][j]; | |
} | |
} | |
} | |
function getOrder(uint pid, uint id) public view returns (OrderStruct memory) { | |
require(orderExist[pid][id], "Order not found"); | |
return ordersOf[pid][id]; | |
} | |
function getBuyers(uint pid) public view returns (BuyerStruct[] memory buyers) { | |
require(productExist[pid], "Product does not exist"); | |
return buyersOf[pid]; | |
} | |
function payTo(address to, uint256 amount) internal { | |
(bool success1, ) = payable(to).call{value: amount}(""); | |
require(success1); | |
} | |
} |
Now, let’s do some explanations of what is going on in the smart contract above. We have the following:
OrderEnum: This enumerable describes the various status an order goes through in its lifecycle. For example, an order could be placed, delivered, canceled, etc.
ProductStruct: This structure model the details of each product to be stored in this smart contract. For example, the SKU, stock, price, and so on.
OrderStruct: This structure embodies the details of each order placed in the shop such as the order id, the buyer, the quantity of items, and more.
CartStruct: This structure contains the data a cart collects for each item to be submitted as an order in this shop.
BuyerStruct: This structure speaks of the kind of data to be collected whenever a buyer purchases a product from our shop.
ShopStats: This is a structure that details the statistics of our shop. Information such as the number of sellers, products, orders, and sales are contained by this struct.
For the state variables, we have the following.
Owner: This state variable contains the account of the deployer of this smart contract.
Stats: This holds information about the current statistics of our shop.
Fee: This contains how much to be charged per creation of a product on this platform.
Products: This holds a collection of products added on this platform.
ProductsOf: This captures the products added by a specific seller to our shop.
OrdersOf: This contains a list of orders purchased by a specific buyer in the shop.
StatsOf: This holds the statistics of each buyer or seller on the platform.
BuyersOf: This accommodates information of the buyers of a specific product.
ProductExist: This checks if a product is found in our shop.
OrderExist: This checks if an order is found in our shop.
For the functions, we have the following.
CreateProduct: This adds a new function to the shop using supplied product information such as the name, description, and price.
UpdateProduct: This modifies existing product information with new data supplied via the function’s parameters.
UpdateOrderDetails: This function sends a product’s update across every order it has received already.
DeleteProduct: This toggles an existing product to a deleted state and becomes unavailable for purchase.
GetProduct: This returns the entire list of products in our shop.
GetProducts: This returns a specific product from our shop by targeting its Id.
CreateOrder: This function cancels an order, it is accessible only by the buyer of such product.
TotalCost: This calculates the overall cost for each product ordered.
DeliverOrder: This function delivers an order, it is accessible only by the seller of such product.
CancelOrder: This function marks an order as canceled and is accessible only to be the buyer of such product.
GetOrders: This returns the entire collection of orders placed on this shop.
GetOrder: This returns a specific order by its Id.
GetBuyers: Returns a collection of buyers of a particular product.
PayTo: Sends a specific amount to a specific address when invoked.
If you are new to Solidity, I have a full FREE course on YouTube called, Mastering Solidity Basics. So do check it out, like it, and subscribe!
https://www.youtube.com/watch?v=11DsTLhI_i4
Configuring the Deployment Script
Navigate to the scripts folder and then to your deploy.js
file and paste the code below into it. If you can't find a script folder, make one, create a deploy.js file, and paste the following code into it.
const { ethers } = require('hardhat') | |
const fs = require('fs') | |
async function main() { | |
const fee = ethers.utils.parseEther('0.002') | |
const Contract = await ethers.getContractFactory('Shop') | |
const contract = await Contract.deploy(fee) | |
await contract.deployed() | |
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 | |
}) |
The above script when executed as a Hardhat command, will ship the Shop.sol
smart contract into any chosen network.
With the above instructions diligently followed, open up a terminal pointing to this project and run the below commands separately on two terminals. VS Code enables you to do this straight from your editor. See the command below.
yarn hardhat node # Terminal #1
yarn hardhat run scripts/deploy.js --network localhost # Terminal #2
If the above commands were successfully executed, you will see these sorts of activities on your terminal. See the image below.
Developing the Frontend
Now that we have our smart contract on a network and all our artifacts (bytecodes and abi) generated, let’s start creating the front end with React step-by-step.
Components
Create a new folder called components in the src directory, which will house all of the React components.
Header component
This component is in charge of displaying information about the currently connected user, the number of items in his cart, and a clickable Identicon that shows more seller options. See the codes responsible for its behavior below.
import Identicon from 'react-identicons' | |
import { FaEthereum } from 'react-icons/fa' | |
import { Link, useNavigate } from 'react-router-dom' | |
import { AiOutlineShoppingCart } from 'react-icons/ai' | |
import { setGlobalState, truncate, useGlobalState } from '../store' | |
import { connectWallet } from '../Blockchain.Service' | |
const Header = () => { | |
const navigate = useNavigate() | |
const [cart] = useGlobalState('cart') | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
return ( | |
<div className="flex justify-between items-center shadow-sm shadow-gray-200 p-5"> | |
<Link | |
to="/" | |
className="flex justify-start items-center space-x-1 text-md font-bold" | |
> | |
<FaEthereum className="cursor-pointer" size={25} /> | |
<span>GameShop</span> | |
</Link> | |
<div className="flex justify-end items-center space-x-6"> | |
<div className="flex justify-center items-center space-x-4"> | |
<button | |
onClick={() => navigate('/cart')} | |
className="rounded-full text-gray-500 bg-gray-200 font-semibold text-sm flex | |
align-center cursor-pointer active:bg-gray-300 transition duration-300 | |
ease w-max py-1 px-2" | |
> | |
<AiOutlineShoppingCart className="cursor-pointer" size={25} /> | |
<span | |
className="rounded-full py-[2px] px-[10px] text-center font-bold | |
bg-red-600 text-white ml-2" | |
> | |
{cart.length} | |
</span> | |
</button> | |
<button | |
onClick={() => setGlobalState('menu', 'scale-100')} | |
className="bg-transparent shadow-sm shadow-gray-400 rounded-full" | |
> | |
<Identicon | |
string={connectedAccount} | |
size={25} | |
className="h-10 w-10 object-contain rounded-full cursor-pointer" | |
/> | |
</button> | |
</div> | |
{connectedAccount ? ( | |
<button | |
className="px-6 py-2.5 bg-blue-800 text-white font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg | |
focus:bg-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out" | |
> | |
{truncate(connectedAccount, 4, 4, 11)} | |
</button> | |
) : ( | |
<button | |
className="px-6 py-2.5 bg-blue-800 text-white font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg | |
focus:bg-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out" | |
onClick={connectWallet} | |
> | |
Connect | |
</button> | |
)} | |
</div> | |
</div> | |
) | |
} | |
export default Header |
Banner Component
This component captures a beautiful display of game items. This was designed to give our app a good feel of being a GameShop.
ShopStats Component
This component records a statistic about the shop's current state. This section displays the number of products, sellers, orders, and so on. Look at the code that is responsible for this.
import React from 'react' | |
const ShopStats = ({ stats }) => { | |
return ( | |
<div className="flex flex-col sm:flex-row justify-center items-center p-5"> | |
<div className="flex flex-col justify-center items-center h-20 border border-gray-200 shadow-md w-full"> | |
<span className="text-lg font-bold text-black leading-5"> | |
{stats.products} | |
</span> | |
<span>Products</span> | |
</div> | |
<div className="flex flex-col justify-center items-center h-20 border border-gray-200 shadow-md w-full"> | |
<span className="text-lg font-bold text-black leading-5"> | |
{stats.sellers} | |
</span> | |
<span>Sellers</span> | |
</div> | |
<div className="flex flex-col justify-center items-center h-20 border border-gray-200 shadow-md w-full"> | |
<span className="text-lg font-bold text-black leading-5"> | |
{stats.sales} | |
</span> | |
<span>Sales</span> | |
</div> | |
</div> | |
) | |
} | |
export default ShopStats |
The Cards Component
This component renders a collection of game products on cards. Each card contains game information such as the name, price, stock, and image URL. See the code snippet below.
import Identicon from 'react-identicons' | |
import { FaEthereum } from 'react-icons/fa' | |
import { Link } from 'react-router-dom' | |
import { truncate } from '../store' | |
const Cards = ({ products, title, seller }) => { | |
return ( | |
<> | |
<div className="flex flex-col items-center space-y-4"> | |
{seller ? ( | |
<Identicon | |
string={'0adsclsidnt'} | |
size={70} | |
className="h-10 w-10 object-contain rounded-full cursor-pointer shadow-sm shadow-gray-400" | |
/> | |
) : null} | |
<h4 className="text-center uppercase">{title}</h4> | |
</div> | |
<div className="flex flex-wrap justify-center items-center space-x-6 md:w-2/3 w-full p-5 mx-auto"> | |
{products.map((product, i) => | |
product.deleted ? null : <Card product={product} key={i} />, | |
)} | |
</div> | |
<div className="flex justify-center items-center my-5"> | |
<button | |
className="px-6 py-2.5 bg-blue-800 text-white font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg | |
focus:bg-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out" | |
> | |
Load More | |
</button> | |
</div> | |
</> | |
) | |
} | |
const Card = ({ product }) => ( | |
<div className="flex flex-col justify-center items-center sm:items-start my-5 w-full sm:w-1/4"> | |
<Link to={'/product/' + product.id}> | |
<img | |
className="h-56 w-56 object-cover" | |
src={product.imageURL} | |
alt={product.name} | |
/> | |
<h4 className="text-lg font-bold">{truncate(product.name, 20, 0, 23)}</h4> | |
</Link> | |
<div className="flex flex-row sm:flex-col justify-between items-start w-56"> | |
<div className="flex justify-start items-center"> | |
<FaEthereum size={15} /> | |
<span className="font-semibold">{product.price}</span> | |
</div> | |
<span className="text-sm text-gray-500">{product.stock} in stock</span> | |
</div> | |
</div> | |
) | |
export default Cards |
Details Component
This component displays the details of a specific game item such as the full name, image, description, seller details, and so on. Also, this component contains essential buttons for editing, adding items to the cart, deleting, and chatting with the seller button. See the codes below.
import Identicon from 'react-identicons' | |
import { FaEthereum } from 'react-icons/fa' | |
import { useNavigate, Link } from 'react-router-dom' | |
import { setGlobalState, truncate, useGlobalState } from '../store' | |
import { addToCart } from '../Cart.Service' | |
import { useEffect, useState } from 'react' | |
import { getUser } from '../Chat.Service' | |
import { toast } from 'react-toastify' | |
const Details = ({ product }) => { | |
const navigate = useNavigate() | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const [currentUser] = useGlobalState('currentUser') | |
const [seller, setSeller] = useState(false) | |
const handleChat = () => { | |
if (currentUser) { | |
if (seller) { | |
navigate('/chat/' + product.seller) | |
} else { | |
toast('Seller not registered for chat yet!') | |
} | |
} else { | |
setGlobalState('chatModal', 'scale-100') | |
} | |
} | |
const handleEdit = () => { | |
setGlobalState('product', product) | |
setGlobalState('updateModal', 'scale-100') | |
} | |
const handleDelete = () => { | |
setGlobalState('product', product) | |
setGlobalState('deleteModal', 'scale-100') | |
} | |
useEffect(async () => { | |
await getUser(product.seller).then((user) => { | |
if (user.name) setSeller(user.uid == product.seller) | |
}) | |
}, []) | |
return ( | |
<div | |
className="flex flex-col lg:flex-row justify-center lg:justify-between | |
items-center lg:space-x-10 md:w-2/3 w-full p-5 mx-auto" | |
> | |
<img | |
className="h-56 w-56 object-cover mb-5 lg:mb-0" | |
src={product.imageURL} | |
alt={product.name} | |
/> | |
<div className="flex flex-col justify-between items-start lg:items-center text-center lg:text-left"> | |
<div className="flex flex-col space-y-4 mb-5"> | |
<h4 className="text-3xl font-bold">{product.name}</h4> | |
<p className="text-gray-500">{product.description}</p> | |
<div className="flex justify-center lg:justify-between space-x-2 items-center"> | |
<Link | |
to={'/seller/0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'} | |
className="flex justify-start items-center space-x-2" | |
> | |
<Identicon | |
string={product.seller} | |
size={25} | |
className="h-10 w-10 object-contain rounded-full cursor-pointer" | |
/> | |
<small className="font-bold"> | |
{truncate(product.seller, 4, 4, 11)} | |
</small> | |
</Link> | |
<span className="text-sm text-gray-500"> | |
{product.stock} in stock | |
</span> | |
</div> | |
</div> | |
<div className="flex justify-start text-center items-center flex-wrap space-x-1 mx-auto lg:ml-0"> | |
{product.deleted ? null : connectedAccount == product.seller ? ( | |
<div className="flex justify-start text-center items-center space-x-1"> | |
<button | |
className="px-6 py-2.5 bg-blue-800 text-white font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg | |
focus:bg-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out flex justify-start items-center space-x-2" | |
onClick={handleEdit} | |
> | |
<span>Edit Product</span> | |
</button> | |
<button | |
className="px-6 py-2.5 bg-red-800 text-white font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-red-900 hover:shadow-lg | |
focus:bg-red-900 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-red-900 | |
active:shadow-lg transition duration-150 ease-in-out flex justify-start items-center space-x-2" | |
onClick={handleDelete} | |
> | |
<span>Delete Product</span> | |
</button> | |
</div> | |
) : ( | |
<button | |
className="px-6 py-2.5 bg-blue-800 text-white font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg | |
focus:bg-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out flex justify-start items-center space-x-2" | |
onClick={() => addToCart(product)} | |
> | |
<span>Add to Cart</span> | |
<div className="flex justify-start items-center"> | |
<FaEthereum size={15} /> | |
<span className="font-semibold">{product.price}</span> | |
</div> | |
</button> | |
)} | |
<button | |
className="px-6 py-2.5 bg-transparent border-blue-800 text-blue-800 font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg border | |
focus:border-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:border-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out hover:text-white" | |
onClick={handleChat} | |
> | |
Chat with Seller | |
</button> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default Details |
Buyers Component
This component shows a list of buyers who bought a specific game item. See the codes listed below.
import { FaEthereum } from 'react-icons/fa' | |
import Identicon from 'react-identicons' | |
import { truncate } from '../store' | |
const Buyers = ({ buyers }) => { | |
return ( | |
<div className="flex justify-center flex-col items-start w-full md:w-2/3 p-5 mx-auto"> | |
<div className="max-h-[calc(100vh_-_20rem)] overflow-y-auto shadow-md rounded-md w-full"> | |
{buyers.length < 1 ? null : ( | |
<table className="min-w-full"> | |
<thead className="border-b"> | |
<tr> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Buyer | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Cost | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Qty | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Date | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
{buyers.map((buyer, i) => ( | |
<tr | |
key={i} | |
className="border-b border-gray-200 transition duration-300 ease-in-out" | |
> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<div className="flex flex-row justify-start items-center space-x-3"> | |
<Identicon | |
string={buyer.buyer} | |
size={25} | |
className="h-10 w-10 object-contain rounded-full mr-3" | |
/> | |
<small className="font-bold"> | |
{truncate(buyer.buyer, 4, 4, 11)} | |
</small> | |
</div> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{buyer.price} EHT | |
</span> | |
</small> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-gray-700 font-bold">{buyer.qty}</span> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
{buyer.timestamp} | |
</td> | |
</tr> | |
))} | |
</tbody> | |
</table> | |
)} | |
</div> | |
</div> | |
) | |
} | |
export default Buyers |
Orders Component
This component renders a collection of orders for both the buyer and the seller, giving the buyer the ability to cancel an order so long as it isn’t delivered, and the seller, the ability to deliver a game product. See the codes below.
import { Link } from 'react-router-dom' | |
import { FaEthereum } from 'react-icons/fa' | |
import { cancelOrder, delieverOrder } from '../Blockchain.Service' | |
import { useGlobalState } from '../store' | |
import { toast } from 'react-toastify' | |
const DELEVIRED = 1 | |
const CANCELED = 2 | |
const onDeliver = async (pid, id) => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await delieverOrder(pid, id) | |
.then(() => resolve()) | |
.catch(() => reject()) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: | |
'Order delivered, will reflect in your Order history within 30sec 🙌', | |
error: 'Encountered error placing order 🤯', | |
}, | |
) | |
} | |
const onCancel = async (pid, id) => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await cancelOrder(pid, id) | |
.then(() => resolve()) | |
.catch(() => reject()) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: | |
'Order delivered, will reflect in your Order history within 30sec 🙌', | |
error: 'Encountered error placing order 🤯', | |
}, | |
) | |
} | |
const Order = ({ orders, title, seller }) => { | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
return ( | |
<div className="flex flex-col justify-between items-center space-x-2 md:w-2/3 w-full p-5 mx-auto"> | |
<h4 className="text-center uppercase mb-8">{title}</h4> | |
<table className="min-w-full hidden md:table"> | |
<thead className="border-b"> | |
<tr> | |
<th scope="col" className="text-sm font-medium px-6 py-4 text-left"> | |
S/N | |
</th> | |
<th scope="col" className="text-sm font-medium px-6 py-4 text-left"> | |
Product | |
</th> | |
<th scope="col" className="text-sm font-medium px-6 py-4 text-left"> | |
Qty | |
</th> | |
<th scope="col" className="text-sm font-medium px-6 py-4 text-left"> | |
Price | |
</th> | |
<th scope="col" className="text-sm font-medium px-6 py-4 text-left"> | |
Status | |
</th> | |
<th scope="col" className="text-sm font-medium px-6 py-4 text-left"> | |
Total | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
{seller | |
? orders.map((order, i) => | |
order.seller == connectedAccount ? ( | |
<SellerOrder key={i} order={order} i={i} /> | |
) : null, | |
) | |
: orders.map((order, i) => | |
order.buyer == connectedAccount ? ( | |
<BuyerOrder key={i} order={order} i={i} /> | |
) : null, | |
)} | |
</tbody> | |
</table> | |
<div className="flex flex-col justify-center items-center w-full md:hidden"> | |
{seller | |
? orders.map((order, i) => | |
order.seller == connectedAccount ? ( | |
<MobileSellerOrder key={i} order={order} i={i} /> | |
) : null, | |
) | |
: orders.map((order, i) => | |
order.buyer == connectedAccount ? ( | |
<MobileBuyerOrder key={i} order={order} i={i} /> | |
) : null, | |
)} | |
</div> | |
</div> | |
) | |
} | |
const SellerOrder = ({ order, i }) => ( | |
<tr className="border-b border-gray-200 transition duration-300 ease-in-out"> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-gray-700 font-bold">{i + 1}</span> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<Link to={'/product/' + order.pid}> | |
<img className="w-20" src={order.imageURL} alt="game" /> | |
<small className="font-bold">{order.name}</small> | |
</Link> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-gray-700 font-bold">{order.qty}</span> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{(order.total / order.qty).toFixed(3)} EHT | |
</span> | |
</small> | |
</td> | |
{order.status == DELEVIRED ? ( | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-green-500">Delievered</span> | |
</td> | |
) : order.status == CANCELED ? ( | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-red-500">Canceled</span> | |
</td> | |
) : ( | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<button | |
type="button" | |
className="rounded inline-block px-4 py-1.5 bg-green-600 text-white | |
font-medium text-xs leading-tight uppercase hover:bg-green-700 | |
focus:bg-green-700 focus:outline-none focus:ring-0 active:bg-green-800 | |
transition duration-150 ease-in-out" | |
onClick={() => onDeliver(order.pid, order.id)} | |
> | |
Deliever | |
</button> | |
</td> | |
)} | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold">{order.total} EHT</span> | |
</small> | |
</td> | |
</tr> | |
) | |
const BuyerOrder = ({ order, i }) => ( | |
<tr className="border-b border-gray-200 transition duration-300 ease-in-out"> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-gray-700 font-bold">{i + 1}</span> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<Link to={'/product/' + order.pid}> | |
<img className="w-20" src={order.imageURL} alt="game" /> | |
<small className="font-bold">{order.name}</small> | |
</Link> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-gray-700 font-bold">{order.qty}</span> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{(order.total / order.qty).toFixed(3)} EHT | |
</span> | |
</small> | |
</td> | |
{order.status == DELEVIRED ? ( | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-green-500">Delievered</span> | |
</td> | |
) : order.status == CANCELED ? ( | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-red-500">Canceled</span> | |
</td> | |
) : ( | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<button | |
type="button" | |
className="rounded inline-block px-4 py-1.5 bg-blue-600 text-white | |
font-medium text-xs leading-tight uppercase hover:bg-blue-700 | |
focus:bg-blue-700 focus:outline-none focus:ring-0 active:bg-blue-800 | |
transition duration-150 ease-in-out" | |
onClick={() => onCancel(order.pid, order.id)} | |
> | |
Cancel | |
</button> | |
</td> | |
)} | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold">{order.total} EHT</span> | |
</small> | |
</td> | |
</tr> | |
) | |
const MobileSellerOrder = ({ order, i }) => ( | |
<div | |
className="flex flex-col justify-center items-center my-4 | |
transition duration-300 ease-in-out border-b border-gray-200" | |
> | |
<div className="flex justify-center"> | |
<span className="text-gray-700 font-bold text-sm">#{i + 1}</span> | |
</div> | |
<Link | |
to={'/product/' + order.pid} | |
className="flex flex-col justify-center items-center space-y-2 text-sm font-light" | |
> | |
<img className="w-1/3 md:w-2/3" src={order.imageURL} alt="game" /> | |
<small className="font-bold">{order.name}</small> | |
</Link> | |
<div className="text-sm font-light"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{order.qty} x {order.total / order.qty} EHT = {order.total} EHT | |
</span> | |
</small> | |
</div> | |
{order.status == DELEVIRED ? ( | |
<div className="text-sm font-light mt-2 mb-4"> | |
<span | |
className="px-4 py-2 rounded-full text-green-500 bg-green-200 font-semibold | |
text-sm flex align-center w-max cursor-pointer active:bg-gray-300 | |
transition duration-300 ease" | |
> | |
Delievered | |
</span> | |
</div> | |
) : order.status == CANCELED ? ( | |
<div className="text-sm font-light mt-2 mb-4"> | |
<span | |
className="px-4 py-2 rounded-full text-red-500 bg-red-200 font-semibold | |
text-sm flex align-center w-max cursor-pointer active:bg-gray-300 | |
transition duration-300 ease" | |
> | |
Canceled | |
</span> | |
</div> | |
) : ( | |
<div className="text-sm font-light mt-2 mb-4"> | |
<button | |
type="button" | |
className="rounded inline-block px-4 py-1.5 bg-green-600 text-white | |
font-medium text-xs leading-tight uppercase hover:bg-green-700 | |
focus:bg-green-700 focus:outline-none focus:ring-0 active:bg-green-800 | |
transition duration-150 ease-in-out" | |
onClick={() => onDeliver(order.pid, order.id)} | |
> | |
Deliever | |
</button> | |
</div> | |
)} | |
</div> | |
) | |
const MobileBuyerOrder = ({ order, i }) => ( | |
<div | |
className="flex flex-col justify-center items-center my-4 | |
transition duration-300 ease-in-out border-b border-gray-200" | |
> | |
<div className="flex justify-center"> | |
<span className="text-gray-700 font-bold text-sm">#{i + 1}</span> | |
</div> | |
<Link | |
to={'/product/' + order.pid} | |
className="flex flex-col justify-center items-center space-y-2 text-sm font-light" | |
> | |
<img className="w-3/5" src={order.imageURL} alt="game" /> | |
<small className="font-bold">{order.name}</small> | |
</Link> | |
<div className="text-sm font-light"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{order.qty} x {order.total / order.qty} EHT = {order.total} EHT | |
</span> | |
</small> | |
</div> | |
{order.status == DELEVIRED ? ( | |
<div className="text-sm font-light mt-2 mb-4"> | |
<span | |
className="px-4 py-2 rounded-full text-green-500 bg-green-200 font-semibold | |
text-sm flex align-center w-max cursor-pointer active:bg-gray-300 | |
transition duration-300 ease" | |
> | |
Delievered | |
</span> | |
</div> | |
) : order.status == CANCELED ? ( | |
<div className="text-sm font-light mt-2 mb-4"> | |
<span | |
className="px-4 py-2 rounded-full text-red-500 bg-red-200 font-semibold | |
text-sm flex align-center w-max cursor-pointer active:bg-gray-300 | |
transition duration-300 ease" | |
> | |
Canceled | |
</span> | |
</div> | |
) : ( | |
<div className="text-sm font-light mt-2 mb-4"> | |
<button | |
type="button" | |
className="rounded inline-block px-4 py-1.5 bg-green-600 text-white | |
font-medium text-xs leading-tight uppercase hover:bg-green-700 | |
focus:bg-green-700 focus:outline-none focus:ring-0 active:bg-green-800 | |
transition duration-150 ease-in-out" | |
onClick={() => onCancel(order.pid, order.id)} | |
> | |
Cancel | |
</button> | |
</div> | |
)} | |
</div> | |
) | |
export default Order |
Adding a Game to Shop
To add a new game to our shop we use two components, the “AddButton” and the “CreateProduct” component. The “AddButton” is responsible for launching the create product modal. Create each one of these components in the components folder and paste the following codes inside them. see the codes below.
import { useEffect, useState } from 'react' | |
import { FaTimes } from 'react-icons/fa' | |
import { createProduct } from '../Blockchain.Service' | |
import { setGlobalState, useGlobalState } from '../store' | |
import { toast } from 'react-toastify' | |
import { getUser } from '../Chat.Service' | |
const CreateProduct = () => { | |
const [modal] = useGlobalState('modal') | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const [name, setName] = useState('') | |
const [price, setPrice] = useState('') | |
const [stock, setStock] = useState('') | |
const [description, setDescription] = useState('') | |
const [imageURL, setImageURL] = useState('') | |
const [seller, setSeller] = useState(false) | |
const handleSubmit = async (e) => { | |
e.preventDefault() | |
if (!name || !price || !imageURL || !description || !stock) return | |
const params = { | |
sku: (Math.random() + 1).toString(36).substring(7).toUpperCase(), | |
name, | |
description, | |
stock, | |
price, | |
imageURL, | |
} | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await createProduct(params) | |
.then(() => resolve()) | |
.catch(() => reject()) | |
}), | |
{ | |
pending: 'Approve transaction to product...', | |
success: 'Product successfully created, will reflect within 30sec 👌', | |
error: 'Encountered error updating your product 🤯', | |
}, | |
) | |
closeModal() | |
if(!seller) toast("Please sign in to have your customers chat with you.") | |
} | |
useEffect(async () => { | |
await getUser(connectedAccount).then((user) => { | |
if (user.name) setSeller(user.uid == connectedAccount) | |
}) | |
}, []) | |
const closeModal = () => { | |
setGlobalState('modal', 'scale-0') | |
resetForm() | |
} | |
const resetForm = () => { | |
setImageURL('') | |
setName('') | |
setPrice('') | |
setStock('') | |
setDescription('') | |
} | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center | |
justify-center bg-black bg-opacity-50 transform | |
transition-transform duration-300 ${modal}`} | |
> | |
<div className="bg-white shadow-xl shadow-black 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 text-black">Add Product</p> | |
<button | |
type="button" | |
onClick={closeModal} | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<FaTimes className="text-black" /> | |
</button> | |
</div> | |
{imageURL ? ( | |
<div className="flex flex-row justify-center items-center rounded-xl mt-5"> | |
<div className="shrink-0 rounded-xl overflow-hidden h-20 w-20"> | |
<img | |
alt="Project" | |
className="h-full w-full object-cover cursor-pointer" | |
src={imageURL} | |
/> | |
</div> | |
</div> | |
) : null} | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
text-slate-500 bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="text" | |
name="name" | |
placeholder="Title" | |
onChange={(e) => setName(e.target.value)} | |
value={name} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
text-slate-500 bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="number" | |
step={0.001} | |
min={0.001} | |
name="price" | |
placeholder="price (Eth)" | |
onChange={(e) => setPrice(e.target.value)} | |
value={price} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
text-slate-500 bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="number" | |
min={1} | |
name="stock" | |
placeholder="E.g. 2" | |
onChange={(e) => setStock(e.target.value)} | |
value={stock} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
text-slate-500 bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="url" | |
name="imageURL" | |
placeholder="ImageURL" | |
onChange={(e) => setImageURL(e.target.value)} | |
pattern="^(http(s)?:\/\/)+[\w\-\._~:\/?#[\]@!\$&'\(\)\*\+,;=.]+$" | |
value={imageURL} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<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" | |
onChange={(e) => setDescription(e.target.value)} | |
value={description} | |
required | |
></textarea> | |
</div> | |
<button | |
type="submit" | |
className="flex flex-row justify-center items-center | |
w-full text-white text-md bg-blue-500 | |
py-2 px-5 rounded-full drop-shadow-xl | |
border-transparent border | |
hover:bg-transparent hover:text-blue-500 | |
hover:border hover:border-blue-500 | |
focus:outline-none focus:ring mt-5" | |
> | |
Create Product | |
</button> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default CreateProduct |
The Administrative Components
This component includes the edit, delete, and chat with seller components. The ability to edit or delete a product is solely the responsibility of the owner of such a product.
For the chat with seller button, both the seller and the buyer must wilfully sign up for this service to be able to receive anonymous chats from the buyers. Then he will be able to see them in his chat history.
The logic for each of these components is contained in the codes below; create and paste the codes into their respective components.
import { FaTimes } from 'react-icons/fa' | |
import { setGlobalState, useGlobalState } from '../store' | |
import { loginWithCometChat, signUpWithCometChat } from '../Chat.Service' | |
import { toast } from 'react-toastify' | |
const ChatModal = () => { | |
const [chatModal] = useGlobalState('chatModal') | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const handleLogin = async () => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await loginWithCometChat(connectedAccount) | |
.then((res) => res == true ? resolve() : reject()) | |
.catch(() => reject()) | |
}), | |
{ | |
pending: 'Signing in...', | |
success: 'Successfully signed in 👌', | |
error: 'Encountered error while signing in 🤯', | |
}, | |
) | |
closeModal() | |
} | |
const handleSignup = async () => { | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await signUpWithCometChat(connectedAccount, connectedAccount) | |
.then((res) => res == true ? resolve() : reject()) | |
.catch(() => reject()) | |
}), | |
{ | |
pending: 'Signing up...', | |
success: 'Successfully signed up, proceed to login... 👌', | |
error: 'Encountered error while signing up 🤯', | |
}, | |
) | |
closeModal() | |
} | |
const closeModal = () => { | |
setGlobalState('chatModal', 'scale-0') | |
} | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center | |
justify-center bg-black bg-opacity-50 transform | |
transition-transform duration-300 ${chatModal}`} | |
> | |
<div className="bg-white shadow-xl shadow-black 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-end items-center"> | |
<button | |
type="button" | |
onClick={closeModal} | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<FaTimes className="text-black" /> | |
</button> | |
</div> | |
<ChatAuth login={handleLogin} sign={handleSignup} /> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
const ChatAuth = ({ login, sign }) => ( | |
<> | |
<div className="flex flex-col justify-center items-center text-center"> | |
<h4 className="text-xl text-bold mb-3">Authentication</h4> | |
<p> | |
You will have to sign up or login to access the chat features of this | |
app. | |
</p> | |
</div> | |
<div className="flex justify-center items-center space-x-3 text-center mt-5"> | |
<button | |
type="submit" | |
onClick={login} | |
className="flex flex-row justify-center items-center w-full | |
text-white text-md bg-blue-900 | |
py-2 px-5 rounded-full drop-shadow-xl | |
border-transparent border | |
hover:bg-transparent hover:text-blue-900 | |
hover:border hover:border-blue-900 | |
focus:outline-none focus:ring mt-5" | |
> | |
Login | |
</button> | |
<button | |
type="submit" | |
onClick={sign} | |
className="flex flex-row justify-center items-center w-full | |
text-blue-900 text-md border-blue-900 | |
py-2 px-5 rounded-full drop-shadow-xl | |
border-transparent border | |
hover:text-white | |
hover:border hover:bg-blue-900 | |
focus:outline-none focus:ring mt-5" | |
> | |
Sign Up | |
</button> | |
</div> | |
</> | |
) | |
export default ChatModal |
import { FaTimes } from 'react-icons/fa' | |
import { setGlobalState, useGlobalState } from '../store' | |
import { deleteProduct } from '../Blockchain.Service' | |
import { toast } from 'react-toastify' | |
const DeleteProduct = () => { | |
const [deleteModal] = useGlobalState('deleteModal') | |
const [product] = useGlobalState('product') | |
const handleDelete = async (e) => { | |
e.preventDefault() | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await deleteProduct(product?.id) | |
.then(() => resolve()) | |
.catch(() => reject()) | |
}), | |
{ | |
pending: 'Approving transaction...', | |
success: 'Product deleted, will reflect within 30sec 👌', | |
error: 'Encountered error deleting your product 🤯', | |
}, | |
) | |
closeModal() | |
} | |
const closeModal = () => { | |
setGlobalState('deleteModal', 'scale-0') | |
} | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center | |
justify-center bg-black bg-opacity-50 transform | |
transition-transform duration-300 ${deleteModal}`} | |
> | |
<div className="bg-white shadow-xl shadow-black 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-end items-center"> | |
<button | |
type="button" | |
onClick={closeModal} | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<FaTimes className="text-black" /> | |
</button> | |
</div> | |
<div className="flex flex-row justify-center items-center rounded-xl mt-5"> | |
<div className="shrink-0 rounded-xl overflow-hidden h-20 w-20"> | |
<img | |
alt="Product" | |
className="h-full w-full object-cover cursor-pointer" | |
src={product?.imageURL} | |
/> | |
</div> | |
</div> | |
<div className="flex flex-col justify-center items-center text-center mt-5"> | |
<p> | |
You are about to delete <strong>"{product?.name}"</strong>{' '} | |
permanently! | |
</p> | |
<small className="text-red-400">Are you sure?</small> | |
</div> | |
<button | |
type="submit" | |
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-transparent border | |
hover:bg-transparent hover:text-red-500 | |
hover:border hover:border-red-500 | |
focus:outline-none focus:ring mt-5" | |
> | |
Delete Product | |
</button> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default DeleteProduct |
import { useEffect, useState } from 'react' | |
import { FaTimes } from 'react-icons/fa' | |
import { updateProduct } from '../Blockchain.Service' | |
import { setGlobalState, useGlobalState } from '../store' | |
import { toast } from 'react-toastify' | |
const UpateProduct = () => { | |
const [modal] = useGlobalState('updateModal') | |
const [product] = useGlobalState('product') | |
const [name, setName] = useState(product?.name) | |
const [price, setPrice] = useState(product?.price) | |
const [stock, setStock] = useState(product?.stock) | |
const [oldStock, setOldStock] = useState(product?.stock) | |
const [description, setDescription] = useState(product?.description) | |
const [imageURL, setImageURL] = useState(product?.imageURL) | |
useEffect(() => { | |
setName(product?.name) | |
setDescription(product?.description) | |
setPrice(product?.price) | |
setStock(product?.stock) | |
setImageURL(product?.imageURL) | |
}, [product]) | |
const handleSubmit = async (e) => { | |
e.preventDefault() | |
if ( | |
!name || | |
!price || | |
!imageURL || | |
!description || | |
!stock || | |
stock < oldStock | |
) | |
return | |
const params = { | |
id: product.id, | |
name, | |
description, | |
stock, | |
price, | |
imageURL, | |
} | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await updateProduct(params) | |
.then(() => resolve()) | |
.catch(() => reject()) | |
}), | |
{ | |
pending: 'Approve transaction to product...', | |
success: 'Product successfully updated, will reflect within 30sec 🦄', | |
error: 'Encountered error updating your product 🤯', | |
}, | |
) | |
closeModal() | |
console.log('Product updated') | |
} | |
const closeModal = () => { | |
setGlobalState('updateModal', 'scale-0') | |
} | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center | |
justify-center bg-black bg-opacity-50 transform | |
transition-transform duration-300 z-50 ${modal}`} | |
> | |
<div className="bg-white shadow-xl shadow-black 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 text-black">Edit Product</p> | |
<button | |
type="button" | |
onClick={closeModal} | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<FaTimes className="text-black" /> | |
</button> | |
</div> | |
{imageURL ? ( | |
<div className="flex flex-row justify-center items-center rounded-xl mt-5"> | |
<div className="shrink-0 rounded-xl overflow-hidden h-20 w-20"> | |
<img | |
alt="Project" | |
className="h-full w-full object-cover cursor-pointer" | |
src={imageURL} | |
/> | |
</div> | |
</div> | |
) : null} | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
text-slate-500 bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="text" | |
name="name" | |
placeholder="Title" | |
onChange={(e) => setName(e.target.value)} | |
value={name || ''} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
text-slate-500 bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="number" | |
step={0.001} | |
min={0.001} | |
name="price" | |
placeholder="price (Eth)" | |
onChange={(e) => setPrice(e.target.value)} | |
value={price || ''} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
text-slate-500 bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="number" | |
min={1} | |
name="stock" | |
placeholder="E.g. 2" | |
onChange={(e) => setStock(e.target.value)} | |
value={stock || ''} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
text-slate-500 bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="url" | |
name="imageURL" | |
placeholder="ImageURL" | |
onChange={(e) => setImageURL(e.target.value)} | |
pattern="^(http(s)?:\/\/)+[\w\-\._~:\/?#[\]@!\$&'\(\)\*\+,;=.]+$" | |
value={imageURL || ''} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center bg-gray-300 rounded-xl mt-5"> | |
<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" | |
onChange={(e) => setDescription(e.target.value)} | |
value={description || ''} | |
required | |
></textarea> | |
</div> | |
<button | |
type="submit" | |
className="flex flex-row justify-center items-center | |
w-full text-white text-md bg-blue-500 | |
py-2 px-5 rounded-full drop-shadow-xl | |
border-transparent border | |
hover:bg-transparent hover:text-blue-500 | |
hover:border hover:border-blue-500 | |
focus:outline-none focus:ring mt-5" | |
> | |
Update Product | |
</button> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default UpateProduct |
The Menu Component
This component is in charge of directing users to other areas of the application, such as your order and sales history, recent customer chats, and statistics. See the code for the component below.
The Shopping Cart Components
The cart component has a highly responsive design as well as an instant price calibrator. See the codes listed below.
import { useEffect, useState } from 'react' | |
import { FaEthereum } from 'react-icons/fa' | |
import { Link } from 'react-router-dom' | |
import { remFromCart, updateCart } from '../Cart.Service' | |
import Summary from './Summary' | |
const Cart = ({ cart, summary }) => { | |
const [cartItems, setCartItems] = useState([]) | |
const [process, setProcess] = useState(false) | |
const increase = (product) => { | |
product.qty++ | |
updateCart(product) | |
setCartItems(cart) | |
setProcess(!process) | |
} | |
const decrease = (product) => { | |
if (product.qty == 1) { | |
remFromCart(product) | |
} else { | |
product.qty-- | |
updateCart(product) | |
} | |
setCartItems(cart) | |
setProcess(!process) | |
} | |
useEffect(() => { | |
setCartItems(cart) | |
}, [process]) | |
return ( | |
<> | |
<div className="flex flex-col justify-between items-center space-x-2 md:w-2/3 w-full p-5 mx-auto"> | |
<h4 className="text-center uppercase mb-8">Shopping Cart</h4> | |
<table className="min-w-full hidden md:table"> | |
<thead className="border-b"> | |
<tr> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
S/N | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Product | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Qty | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Price | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Action | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Total | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
{cartItems.map((product, i) => ( | |
<tr | |
key={i} | |
className="border-b border-gray-200 transition duration-300 ease-in-out" | |
> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<span className="text-gray-700 font-bold">{i + 1}</span> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<Link to={'/product/' + product.id}> | |
<img className="w-20" src={product.imageURL} alt="game" /> | |
<small className="font-bold">{product.name}</small> | |
</Link> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<div | |
className="inline-flex shadow-md hover:shadow-lg focus:shadow-lg" | |
role="group" | |
> | |
<button | |
type="button" | |
className="rounded-l inline-block px-4 py-1.5 bg-blue-600 text-white font-medium | |
text-xs leading-tight uppercase hover:bg-blue-700 focus:bg-blue-700 focus:outline-none | |
focus:ring-0 active:bg-blue-800 transition duration-150 ease-in-out" | |
onClick={() => decrease(product)} | |
> | |
- | |
</button> | |
<button | |
type="button" | |
className=" inline-block px-4 py-1.5 bg-transparent text-black font-medium | |
text-xs leading-tight uppercase focus:outline-none | |
focus:ring-0 transition duration-150 ease-in-out" | |
> | |
{product.qty} | |
</button> | |
<button | |
type="button" | |
className=" rounded-r inline-block px-4 py-1.5 bg-blue-600 text-white font-medium | |
text-xs leading-tight uppercase hover:bg-blue-700 focus:bg-blue-700 focus:outline-none | |
focus:ring-0 active:bg-blue-800 transition duration-150 ease-in-out" | |
onClick={() => increase(product)} | |
> | |
+ | |
</button> | |
</div> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{product.price} EHT | |
</span> | |
</small> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<button | |
type="button" | |
className="inline-block px-6 py-2.5 bg-transparent text-red-600 font-medium | |
text-xs leading-tight uppercase rounded hover:text-red-700 | |
hover:bg-gray-100 focus:bg-gray-100 focus:outline-none focus:ring-0 | |
active:bg-gray-200 transition duration-150 ease-in-out" | |
onClick={() => remFromCart(product)} | |
> | |
Remove | |
</button> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{(product.qty * product.price).toFixed(3)} EHT | |
</span> | |
</small> | |
</td> | |
</tr> | |
))} | |
</tbody> | |
</table> | |
<div className="flex flex-col justify-center items-center space-y-2 w-full md:hidden"> | |
{cartItems.map((product, i) => ( | |
<div | |
key={i} | |
className="flex flex-col justify-center items-center my-4 space-y-2 | |
border-b border-gray-200 transition duration-300 ease-in-out" | |
> | |
<Link | |
to={'/product/' + product.id} | |
className="flex flex-col justify-center items-center space-y-2 text-sm font-light" | |
> | |
<img | |
className="w-1/3 md:w-2/3" | |
src={product.imageURL} | |
alt="game" | |
/> | |
<small className="font-bold">{product.name}</small> | |
</Link> | |
<div className="flex justify-center"> | |
<div | |
className="inline-flex shadow-md hover:shadow-lg focus:shadow-lg" | |
role="group" | |
> | |
<button | |
type="button" | |
className="rounded-l inline-block px-4 py-1.5 bg-blue-600 text-white font-medium | |
text-xs leading-tight uppercase hover:bg-blue-700 focus:bg-blue-700 focus:outline-none | |
focus:ring-0 active:bg-blue-800 transition duration-150 ease-in-out" | |
onClick={() => decrease(product)} | |
> | |
- | |
</button> | |
<button | |
type="button" | |
className=" inline-block px-4 py-1.5 bg-transparent text-black font-medium | |
text-xs leading-tight uppercase focus:outline-none | |
focus:ring-0 transition duration-150 ease-in-out" | |
> | |
{product.qty} | |
</button> | |
<button | |
type="button" | |
className=" rounded-r inline-block px-4 py-1.5 bg-blue-600 text-white font-medium | |
text-xs leading-tight uppercase hover:bg-blue-700 focus:bg-blue-700 focus:outline-none | |
focus:ring-0 active:bg-blue-800 transition duration-150 ease-in-out" | |
onClick={() => increase(product)} | |
> | |
+ | |
</button> | |
</div> | |
</div> | |
<div className="text-sm font-light"> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{(product.qty * product.price).toFixed(3)} EHT | |
</span> | |
</small> | |
</div> | |
<div className="text-sm font-light mb-4"> | |
<button | |
type="button" | |
className="inline-block px-6 py-2.5 bg-transparent text-red-600 font-medium | |
text-xs leading-tight uppercase rounded hover:text-red-700 | |
hover:bg-gray-100 focus:bg-gray-100 focus:outline-none focus:ring-0 | |
active:bg-gray-200 transition duration-150 ease-in-out" | |
onClick={() => remFromCart(product)} | |
> | |
Remove | |
</button> | |
</div> | |
</div> | |
))} | |
</div> | |
</div> | |
<Summary summary={summary} /> | |
</> | |
) | |
} | |
export default Cart |
The Summary Component
This component enables you to supply the address and phone number of where you want the item shipped. See the codes below.
import { FaEthereum } from 'react-icons/fa' | |
import { useState } from 'react' | |
import { createOrder } from '../Blockchain.Service' | |
import { clearCart } from '../Cart.Service' | |
import { toast } from 'react-toastify' | |
const Summary = ({ summary }) => { | |
const [destination, setDestination] = useState('') | |
const [phone, setPhone] = useState('') | |
const handleCheckout = async (e) => { | |
e.preventDefault() | |
if (!phone || !destination) return | |
const params = { phone, destination, ...summary } | |
await toast.promise( | |
new Promise(async (resolve, reject) => { | |
await createOrder(params) | |
.then(() => { | |
onReset() | |
clearCart() | |
resolve() | |
}) | |
.catch(() => reject()) | |
}), | |
{ | |
pending: 'Approve transaction...', | |
success: | |
'Order placed, will reflect in your Order history within 30sec 🙌', | |
error: 'Encountered error placing order 🤯', | |
}, | |
) | |
} | |
const onReset = () => { | |
setDestination('') | |
setPhone('') | |
} | |
return ( | |
<div | |
className="flex flex-col md:flex-row justify-center md:justify-between | |
items-center flex-wrap space-x-2 md:w-2/3 w-full p-5 mx-auto" | |
> | |
<form className="w-4/5 md:w-2/5 my-2"> | |
<div className="mb-3"> | |
<label className="form-label inline-block mb-2 font-bold text-sm text-gray-700"> | |
Destination | |
</label> | |
<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 border border-solid border-gray-300 | |
rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white | |
focus:border-blue-600 focus:outline-none" | |
placeholder="Your full address" | |
name="destination" | |
onChange={(e) => setDestination(e.target.value)} | |
value={destination} | |
/> | |
</div> | |
<div className="mb-3"> | |
<label className="form-label inline-block mb-2 font-bold text-sm text-gray-700"> | |
Phone | |
</label> | |
<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 border border-solid border-gray-300 | |
rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white | |
focus:border-blue-600 focus:outline-none" | |
placeholder="Phone" | |
name="phone" | |
onChange={(e) => setPhone(e.target.value)} | |
value={phone} | |
/> | |
</div> | |
<div className="flex justify-between items-center mb-3"> | |
<button | |
className="px-6 py-2.5 bg-transparent border-blue-800 text-blue-800 font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg border | |
focus:border-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:border-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out hover:text-white w-full" | |
> | |
Back to Shopping | |
</button> | |
</div> | |
</form> | |
<div className="w-4/5 md:w-2/5 my-2"> | |
<div className="mb-3"> | |
<h4 className="mb-2 font-bold text-sm text-gray-700"> | |
Order Summary | |
</h4> | |
</div> | |
<div className="flex justify-between items-center mb-3"> | |
<h4 className="mb-2 text-sm text-gray-700">Subtotal</h4> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700"> | |
{(summary.grand - summary.tax).toFixed(3)} EHT | |
</span> | |
</small> | |
</div> | |
<div className="flex justify-between items-center mb-3"> | |
<h4 className="mb-2 text-sm text-gray-700">Tax</h4> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700">{summary.tax.toFixed(3)} EHT</span> | |
</small> | |
</div> | |
<div className="flex justify-between items-center mb-3"> | |
<h4 className="mb-2 text-sm text-gray-700 font-bold">Grand Total</h4> | |
<small className="flex justify-start items-center space-x-1"> | |
<FaEthereum /> | |
<span className="text-gray-700 font-bold"> | |
{summary.grand.toFixed(3)} EHT | |
</span> | |
</small> | |
</div> | |
<div className="flex justify-between items-center mb-3"> | |
<button | |
className="px-6 py-2.5 bg-blue-800 text-white font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg | |
focus:bg-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out w-full" | |
onClick={handleCheckout} | |
> | |
Place Order Now | |
</button> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default Summary |
The Stats Components
This section handles funding and withdrawals from your store. For a complete understanding, refer to the codes below.
const Treasury = ({ stats }) => { | |
return ( | |
<div className="flex flex-col sm:flex-row justify-center items-center p-5"> | |
<div className="flex flex-col justify-center items-center h-20 border border-gray-200 shadow-md w-full"> | |
<span className="text-lg font-bold text-black leading-5">{0} ETH</span> | |
<span>Paid</span> | |
</div> | |
<div className="flex flex-col justify-center items-center h-20 border border-gray-200 shadow-md w-full"> | |
<span className="text-lg font-bold text-black leading-5">{stats.balance} ETH</span> | |
<span>Balance</span> | |
</div> | |
<div className="flex justify-center items-center h-20 space-x-2 border border-gray-200 shadow-md w-full"> | |
<button | |
type="button" | |
className="inline-block px-6 py-2.5 bg-blue-600 | |
text-white font-medium text-xs leading-tight | |
uppercase rounded-full shadow-md hover:bg-blue-700 | |
hover:shadow-lg focus:bg-blue-700 focus:shadow-lg | |
focus:outline-none focus:ring-0 active:bg-blue-800 | |
active:shadow-lg transition duration-150 ease-in-out" | |
> | |
Withdraw Fund | |
</button> | |
<button | |
type="button" | |
className="inline-block px-6 py-2.5 bg-blue-600 | |
text-white font-medium text-xs leading-tight | |
uppercase rounded-full shadow-md hover:bg-blue-700 | |
hover:shadow-lg focus:bg-blue-700 focus:shadow-lg | |
focus:outline-none focus:ring-0 active:bg-blue-800 | |
active:shadow-lg transition duration-150 ease-in-out" | |
> | |
Fund Account | |
</button> | |
</div> | |
</div> | |
) | |
} | |
export default Treasury |
And there you have it for all the chunks of components. If you want web3 video tutorials such as building an NFT Minting website, subscribe to my YouTube channel so you won’t miss out on any release.
https://www.youtube.com/watch?v=QN3wb_mXBjY
Pages
It is time to put together all the components on their respective pages. On the root of your project, head to **src**
folder and create a new folder called **views**
. Now all the created components in this section must all be included in this views folder.
The Home Page
This page bundles up the banner, shop stats, and the cards components together, see the codes below.
import Banner from '../components/Banner' | |
import ShopStats from '../components/ShopStats' | |
import Cards from '../components/Cards' | |
import { useGlobalState } from '../store' | |
import { loadProducts } from '../Blockchain.Service' | |
import { useEffect, useState } from 'react' | |
const Home = () => { | |
const [products] = useGlobalState('products') | |
const [stats] = useGlobalState('stats') | |
const [loaded, setLoaded] = useState(false) | |
useEffect(async () => { | |
await loadProducts().then(() => setLoaded(true)) | |
}, []) | |
return loaded ? ( | |
<> | |
<Banner /> | |
<ShopStats stats={stats} /> | |
<div className="h-20"></div> | |
<Cards products={products} title="Global Shop" /> | |
</> | |
) : null | |
} | |
export default Home |
The Shopping Cart Page
This page features two components, the cart, and the summary components, they both help a customer place orders. The customer pays in ethers, see the codes below.
import Cart from '../components/Cart' | |
import { useGlobalState } from '../store' | |
const ShoppingCart = () => { | |
const [cart] = useGlobalState('cart') | |
const [summary] = useGlobalState('summary') | |
return ( | |
<> | |
<div className="h-10"></div> | |
{cart.length > 0 ? ( | |
<Cart cart={cart} summary={summary} /> | |
) : ( | |
<div className="flex flex-col justify-between items-center space-x-2 md:w-2/3 w-full p-5 mx-auto"> | |
<h4 className="text-center uppercase mb-8">Cart Empty</h4> | |
<p>Add some products to your cart...</p> | |
</div> | |
)} | |
</> | |
) | |
} | |
export default ShoppingCart |
The Product Page
The product page contains two essential components for displaying details pertaining to a specific game product. See the codes below.
import { useEffect, useState } from 'react' | |
import { useParams } from 'react-router-dom' | |
import { loadProduct } from '../Blockchain.Service' | |
import { useGlobalState } from '../store' | |
import Buyers from '../components/Buyers' | |
import Details from '../components/Details' | |
const Product = () => { | |
const { id } = useParams() | |
const [product] = useGlobalState('product') | |
const [buyers] = useGlobalState('buyers') | |
const [loaded, setLoaded] = useState(false) | |
useEffect(async () => { | |
await loadProduct(id).then(() => setLoaded(true)) | |
}, []) | |
return loaded ? ( | |
<> | |
<Details product={product} /> | |
<Buyers buyers={buyers} /> | |
</> | |
) : null | |
} | |
export default Product |
The Orders and Sales Page
The orders page uses the order component to render a list of orders for a buyer of a product that he can see from his order history.
Replicate the page by creating the component below inside of the views folder. See the codes below.
import { useEffect } from "react" | |
import { loadOrders } from "../Blockchain.Service" | |
import { useGlobalState } from "../store" | |
import Order from "../components/Order" | |
const Orders = () => { | |
const [orders] = useGlobalState('orders') | |
useEffect(async () => { | |
await loadOrders() | |
}, []) | |
return ( | |
<> | |
<Order orders={orders} title="Orders" /> | |
</> | |
) | |
} | |
export default Orders |
import { useEffect } from "react" | |
import { loadOrders } from "../Blockchain.Service" | |
import { useGlobalState } from "../store" | |
import Order from "../components/Order" | |
const Sales = () => { | |
const [orders] = useGlobalState('orders') | |
useEffect(async () => { | |
await loadOrders() | |
}, []) | |
return ( | |
<> | |
<Order orders={orders} title={'Sales'} seller /> | |
</> | |
) | |
} | |
export default Sales |
Chat Page
This page enables a buyer to chat with a seller of a product, this was made possible with the CometChat SDK.
Each seller must be authenticated anonymously with this chat service before receiving chats from their buyers. To enjoy this service, you must have configured the CometChat SDK which has been discussed above. See the codes below.
import Identicon from 'react-identicons' | |
import React, { useEffect, useState } from 'react' | |
import { FaTimes } from 'react-icons/fa' | |
import { useNavigate, useParams } from 'react-router-dom' | |
import { truncate, useGlobalState } from '../store' | |
import { sendMessage, CometChat, getMessages } from '../Chat.Service' | |
import { toast } from 'react-toastify' | |
const Chat = () => { | |
const { id } = useParams() | |
const [currentUser] = useGlobalState('currentUser') | |
const navigate = useNavigate() | |
useEffect(async () => { | |
if (currentUser) { | |
await getConversations().then((list) => setUsers(list)) | |
} else { | |
toast('Please authenticate with the chat feature first!') | |
navigate('/') | |
} | |
}, []) | |
return currentUser ? ( | |
<> | |
<ChatHeader id={id} /> | |
<Messages id={id} /> | |
</> | |
) : null | |
} | |
const ChatHeader = ({ id }) => { | |
const navigate = useNavigate() | |
return ( | |
<div className="flex justify-between items-start w-full md:w-2/3 p-5 mx-auto"> | |
<span | |
className="rounded-full text-gray-500 bg-gray-200 font-semibold text-sm | |
flex align-center cursor-pointer active:bg-gray-300 | |
transition duration-300 ease w-max" | |
> | |
<Identicon | |
string={id} | |
size={35} | |
className="w-11 h-11 max-w-none object-contain rounded-full" | |
/> | |
<span className="flex items-center px-3 py-2"> | |
{truncate(id, 4, 4, 11)} | |
</span> | |
</span> | |
<span | |
onClick={() => navigate('/product/' + 1)} | |
className="rounded-full text-gray-500 bg-gray-200 font-semibold text-sm | |
flex align-center cursor-pointer active:bg-gray-300 | |
transition duration-300 ease w-max" | |
> | |
<span className="flex items-center px-3 py-2">Exit</span> | |
<button className="bg-transparent hover focus:outline-none pr-2"> | |
<FaTimes size={15} /> | |
</button> | |
</span> | |
</div> | |
) | |
} | |
const Messages = ({ id }) => { | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const [message, setMessage] = useState('') | |
const [messages, setMessages] = useState([]) | |
const handleSubmit = async (e) => { | |
e.preventDefault() | |
sendMessage(id, message).then((msg) => { | |
setMessages((prevState) => [...prevState, msg]) | |
setMessage('') | |
scrollToEnd() | |
}) | |
} | |
const listenForMessage = (listenerID) => { | |
CometChat.addMessageListener( | |
listenerID, | |
new CometChat.MessageListener({ | |
onTextMessageReceived: (message) => { | |
setMessages((prevState) => [...prevState, message]) | |
scrollToEnd() | |
}, | |
}), | |
) | |
} | |
const scrollToEnd = () => { | |
const element = document.getElementById('messages-container') | |
element.scrollTop = element.scrollHeight | |
} | |
useEffect(async () => { | |
listenForMessage(id) | |
await getMessages(id).then((messages) => | |
setMessages(messages.filter((msg) => msg.category == 'message')), | |
) | |
}, [id]) | |
return ( | |
<div className="w-full lg:w-2/3 p-5 mx-auto"> | |
<div | |
id="messages-container" | |
className="h-[calc(100vh_-_18rem)] overflow-y-auto mb-8" | |
> | |
{messages.map((message, i) => | |
message.sender.uid != connectedAccount ? ( | |
<LeftMessage msg={message} key={i} /> | |
) : ( | |
<RightMessage msg={message} key={i} /> | |
), | |
)} | |
</div> | |
<form onSubmit={handleSubmit} className="flex w-full"> | |
<input | |
className="w-full bg-gray-200 rounded-lg p-4 | |
focus:ring-0 focus:outline-none border-gray-500" | |
type="text" | |
placeholder="Write a message..." | |
onChange={(e) => setMessage(e.target.value)} | |
value={message} | |
required | |
/> | |
<button type="submit" hidden> | |
Send | |
</button> | |
</form> | |
</div> | |
) | |
} | |
const RightMessage = ({ msg }) => ( | |
<div className="flex flex-row justify-end my-2"> | |
<div className="flex justify-center items-end space-x-2"> | |
<div | |
className="flex flex-col bg-blue-600 w-80 p-3 px-5 rounded-t-3xl | |
rounded-bl-3xl shadow shadow-black text-white font-semibold" | |
> | |
<div className="flex flex-row justify-start items-center space-x-2"> | |
<span>@You</span> | |
<small> | |
{new Date(msg.sentAt * 1000).toLocaleDateString()}{' '} | |
{new Date(msg.sentAt * 1000).toLocaleTimeString()} | |
</small> | |
</div> | |
<small className="leading-tight my-2">{msg.text}</small> | |
</div> | |
</div> | |
</div> | |
) | |
const LeftMessage = ({ msg }) => ( | |
<div className="flex flex-row justify-start my-2"> | |
<div className="flex justify-center items-end space-x-2"> | |
<div | |
className="flex flex-col bg-transparent w-80 p-3 px-5 rounded-t-3xl | |
rounded-br-3xl shadow shadow-gray-500" | |
> | |
<div className="flex flex-row justify-start items-center space-x-2"> | |
<span>@{truncate(msg.sender.uid, 4, 4, 11)}</span> | |
<small> | |
{new Date(msg.sentAt * 1000).toLocaleDateString()}{' '} | |
{new Date(msg.sentAt * 1000).toLocaleTimeString()} | |
</small> | |
</div> | |
<small className="leading-tight my-2">{msg.text}</small> | |
</div> | |
</div> | |
</div> | |
) | |
export default Chat |
The Recent Chat Page
This page shows you a list of buyers who wants to contact you for extra information about your listed products. The CometChat SDK enables all these chatting functionalities to take place, you will have to sign in or up specifically for the chat feature before you utilize it on your account.
Sellers who don’t opt-in for this service cannot receive chats from their customers. See the codes below.
import { useEffect, useState } from 'react' | |
import Identicon from 'react-identicons' | |
import { useNavigate } from 'react-router-dom' | |
import { getConversations } from '../Chat.Service' | |
import { truncate, useGlobalState } from '../store' | |
import { toast } from 'react-toastify' | |
const Recent = () => { | |
const [users, setUsers] = useState([]) | |
const [currentUser] = useGlobalState('currentUser') | |
const navigate = useNavigate() | |
useEffect(async () => { | |
if (currentUser) { | |
await getConversations().then((list) => setUsers(list)) | |
} else { | |
toast('Please authenticate with the chat feature first!') | |
navigate('/') | |
} | |
}, []) | |
return currentUser ? ( | |
<> | |
<div className="h-20"></div> | |
<div className="flex flex-col justify-between items-center space-x-2 md:w-2/3 w-full p-5 mx-auto"> | |
<h4 className="text-center uppercase mb-8">Recent Chats</h4> | |
<div className="max-h-[calc(100vh_-_20rem)] overflow-y-auto shadow-md rounded-md w-full"> | |
{users.map((user, i) => ( | |
<Conversation conversation={user.lastMessage} key={i} /> | |
))} | |
</div> | |
<div className="flex justify-between items-center my-4"> | |
<button | |
className="px-6 py-2.5 bg-transparent border-blue-800 text-blue-800 font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg border | |
focus:border-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:border-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out hover:text-white w-full" | |
> | |
Back to Home | |
</button> | |
</div> | |
</div> | |
</> | |
) : null | |
} | |
const Conversation = ({ conversation }) => { | |
const navigate = useNavigate() | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const uid = (conversation) => { | |
return conversation.sender.uid == connectedAccount | |
? conversation.receiver.uid | |
: conversation.sender.uid | |
} | |
return ( | |
<button | |
type="button" | |
data-mdb-ripple="true" | |
data-mdb-ripple-color="light" | |
className="px-6 py-2.5 bg-white text-black font-medium text-xs leading-tight | |
rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 | |
focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 hover:text-white | |
active:shadow-lg transition duration-150 ease-in-out w-full text-left my-2" | |
onClick={() => navigate('/chat/' + uid(conversation))} | |
> | |
<div className="flex justify-start items-center space-x-4"> | |
<Identicon | |
string={uid(conversation)} | |
size={30} | |
className="h-10 w-10 object-contain rounded-fullbg-white cursor-pointer" | |
/> | |
<div className="flex flex-col justify-start space-y-2"> | |
<h4 className="font-bold text-md"> | |
{truncate(uid(conversation), 4, 4, 11)} | |
</h4> | |
<span className="text-sm">{conversation.text}</span> | |
<small className="font-bold"> | |
{new Date(conversation.sentAt * 1000).toLocaleDateString()}{' '} | |
{new Date(conversation.sentAt * 1000).toLocaleTimeString()} | |
</small> | |
</div> | |
</div> | |
</button> | |
) | |
} | |
export default Recent |
The Seller and Stats page
The last two pages are dedicated to listing specific sellers' products as well as some shop statistics. The statistics show how well the seller is doing in this market. Still, in the views component, create these two pages. See the codes listed below.
import { useParams } from 'react-router-dom' | |
import Cards from '../components/Cards' | |
const Seller = () => { | |
const { id } = useParams() | |
return ( | |
<> | |
<div className="h-20"></div> | |
<Cards products={[]} title="Seller Shop" seller={id} /> | |
</> | |
) | |
} | |
export default Seller |
import { useEffect, useState } from 'react' | |
import { loadStats } from '../Blockchain.Service' | |
import ShopStats from '../components/ShopStats' | |
import Treasury from '../components/Treasury' | |
import { useGlobalState } from '../store' | |
const Stats = () => { | |
const [stats] = useGlobalState('myStats') | |
const [loaded, setLoaded] = useState(false) | |
useEffect(async () => { | |
await loadStats().then(() => setLoaded(true)) | |
}, []) | |
return loaded ? ( | |
<> | |
<div className="h-20"></div> | |
<h4 className="text-center uppercase mb-8">Your Stats</h4> | |
<ShopStats stats={stats} /> | |
<Treasury stats={stats} /> | |
<div className="flex justify-center items-center my-4"> | |
<button | |
className="px-6 py-2.5 bg-transparent border-blue-800 text-blue-800 font-medium text-xs | |
leading-tight uppercase rounded shadow-md hover:bg-blue-900 hover:shadow-lg border | |
focus:border-blue-900 focus:shadow-lg focus:outline-none focus:ring-0 active:border-blue-900 | |
active:shadow-lg transition duration-150 ease-in-out hover:text-white" | |
> | |
Back to Home | |
</button> | |
</div> | |
</> | |
) : null | |
} | |
export default Stats |
Fantastic, that will be all for the pages, let’s proceed to other essential components of this application.
Configuring Other Components
There are other components that complete this application and in this part, we will be working on them one step at a time.
The App.jsx File
Head to the src folder and open the **App.jsx**
file and replace its content with the codes below.
import { Route, Routes } from 'react-router-dom' | |
import { useEffect, useState } from 'react' | |
import { isWallectConnected } from './Blockchain.Service' | |
import { ToastContainer } from 'react-toastify' | |
import { checkStorage } from './Cart.Service' | |
import Header from './components/Header' | |
import AddButton from './components/AddButton' | |
import CreateProduct from './components/CreateProduct' | |
import UpateProduct from './components/UpateProduct' | |
import Menu from './components/Menu' | |
import Home from './views/Home' | |
import Product from './views/Product' | |
import Orders from './views/Orders' | |
import Chat from './views/Chat' | |
import Seller from './views/Seller' | |
import Recent from './views/Recent' | |
import Stats from './views/Stats' | |
import Sales from './views/Sales' | |
import ShoppingCart from './views/ShoppingCart' | |
import DeleteProduct from './components/DeleteProduct' | |
import ChatModal from './components/ChatModal' | |
import { isUserLoggedIn } from './Chat.Service' | |
const App = () => { | |
const [loaded, setLoaded] = useState(false) | |
useEffect(async () => { | |
await isWallectConnected().then(async () => { | |
checkStorage() | |
await isUserLoggedIn() | |
setLoaded(true) | |
console.log('Blockchain Loaded') | |
}) | |
}, []) | |
return loaded ? ( | |
<div className="min-h-screen"> | |
<Header /> | |
<Routes> | |
<Route path="/" element={<Home />} /> | |
<Route path="/cart" element={<ShoppingCart />} /> | |
<Route path="/product/:id" element={<Product />} /> | |
<Route path="/orders/" element={<Orders />} /> | |
<Route path="/sales/" element={<Sales />} /> | |
<Route path="/chat/:id" element={<Chat />} /> | |
<Route path="/recents" element={<Recent />} /> | |
<Route path="/seller/:id" element={<Seller />} /> | |
<Route path="/stats/:id" element={<Stats />} /> | |
</Routes> | |
<AddButton /> | |
<CreateProduct /> | |
<UpateProduct /> | |
<DeleteProduct /> | |
<Menu /> | |
<ChatModal /> | |
<ToastContainer | |
position="bottom-center" | |
autoClose={5000} | |
hideProgressBar={false} | |
newestOnTop={false} | |
closeOnClick | |
rtl={false} | |
pauseOnFocusLoss | |
draggable | |
pauseOnHover | |
theme="dark" | |
/> | |
</div> | |
) : null | |
} | |
export default App |
The above codes will ensure that all the components and pages are represented properly.
State Management Service
You will need a state management library to work with the blockchain and link all the various components together. For the sake of simplicity, we are using a react-hooks-global-state.
Navigate to the **project**
>>
**src**
and create a new folder called the store. Inside this store folder, create a new file called **index.jsx**
and paste the codes below inside and save.
import { createGlobalState } from 'react-hooks-global-state' | |
const { setGlobalState, useGlobalState, getGlobalState } = createGlobalState({ | |
chatModal: 'scale-0', | |
deleteModal: 'scale-0', | |
updateModal: 'scale-0', | |
modal: 'scale-0', | |
menu: 'scale-0', | |
connectedAccount: '', | |
currentUser: null, | |
contract: null, | |
stats: null, | |
myStats: null, | |
buyers: [], | |
orders: [], | |
sales: [], | |
products: [], | |
product: null, | |
cart: [], | |
summary: { total: 0, grand: 0, tax: 0, qtys: [], ids: [] }, | |
}) | |
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 | |
} | |
export { useGlobalState, setGlobalState, getGlobalState, truncate } |
All the data coming from the blockchain will be stored in the above file and used across the entire application.
The Blockchain Service
This file contains all the EthersJs procedures for communicating with your smart contract that lives on the blockchain. In the src folder, create a file called **Blockchain.services.jsx**
and paste the codes below and save.
import abi from './abis/src/contracts/Shop.sol/Shop.json' | |
import address from './abis/contractAddress.json' | |
import { getGlobalState, setGlobalState } from './store' | |
import { ethers } from 'ethers' | |
import { logOutWithCometChat } from './Chat.Service' | |
const toWei = (num) => ethers.utils.parseEther(num.toString()) | |
const { ethereum } = window | |
const contractAddress = address.address | |
const contractAbi = abi.abi | |
const fee = toWei('0.002') | |
const getEtheriumContract = () => { | |
const connectedAccount = getGlobalState('connectedAccount') | |
if (connectedAccount) { | |
const provider = new ethers.providers.Web3Provider(ethereum) | |
const signer = provider.getSigner() | |
const contract = new ethers.Contract(contractAddress, contractAbi, signer) | |
return contract | |
} else { | |
return getGlobalState('contract') | |
} | |
} | |
const isWallectConnected = async () => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const accounts = await ethereum.request({ method: 'eth_accounts' }) | |
window.ethereum.on('chainChanged', (chainId) => { | |
window.location.reload() | |
}) | |
window.ethereum.on('accountsChanged', async () => { | |
setGlobalState('connectedAccount', accounts[0].toLowerCase()) | |
await logOutWithCometChat() | |
await isWallectConnected() | |
}) | |
if (accounts.length) { | |
setGlobalState('connectedAccount', accounts[0].toLowerCase()) | |
} else { | |
alert('Please connect wallet.') | |
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].toLowerCase()) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const createProduct = async ({ | |
sku, | |
name, | |
description, | |
imageURL, | |
price, | |
stock, | |
}) => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const connectedAccount = getGlobalState('connectedAccount') | |
const contract = getEtheriumContract() | |
price = toWei(price) | |
await contract.createProduct( | |
sku, | |
name, | |
description, | |
imageURL, | |
price, | |
stock, | |
{ | |
from: connectedAccount, | |
value: fee._hex, | |
}, | |
) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const updateProduct = async ({ | |
id, | |
name, | |
description, | |
imageURL, | |
price, | |
stock, | |
}) => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const connectedAccount = getGlobalState('connectedAccount') | |
const contract = getEtheriumContract() | |
price = toWei(price) | |
await contract.updateProduct( | |
id, | |
name, | |
description, | |
imageURL, | |
price, | |
stock, | |
{ | |
from: connectedAccount, | |
}, | |
) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const deleteProduct = async (id) => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const connectedAccount = getGlobalState('connectedAccount') | |
const contract = getEtheriumContract() | |
await contract.deleteProduct(id, { from: connectedAccount }) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const createOrder = async ({ ids, qtys, phone, destination, grand }) => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const connectedAccount = getGlobalState('connectedAccount') | |
const contract = getEtheriumContract() | |
grand = toWei(grand) | |
await contract.createOrder(ids, qtys, destination, phone, { | |
from: connectedAccount, | |
value: grand._hex, | |
}) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const loadProducts = async () => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const connectedAccount = getGlobalState('connectedAccount') | |
const contract = getEtheriumContract() | |
const products = await contract.getProducts() | |
const stats = await contract.stats() | |
const myStats = await contract.statsOf(connectedAccount) | |
setGlobalState('products', structuredProducts(products)) | |
setGlobalState('stats', structureStats(stats)) | |
setGlobalState('myStats', structureStats(myStats)) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const loadProduct = async (id) => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const contract = getEtheriumContract() | |
const product = await contract.getProduct(id) | |
const buyers = await contract.getBuyers(id) | |
setGlobalState('product', structuredProducts([product])[0]) | |
setGlobalState('buyers', structuredBuyers(buyers)) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const loadOrders = async () => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const contract = getEtheriumContract() | |
const orders = await contract.getOrders() | |
setGlobalState('orders', structuredOrders(orders)) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const loadStats = async () => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const connectedAccount = getGlobalState('connectedAccount') | |
const contract = getEtheriumContract() | |
const myStats = await contract.statsOf(connectedAccount) | |
setGlobalState('myStats', structureStats(myStats)) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const delieverOrder = async (pid, id) => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const connectedAccount = getGlobalState('connectedAccount') | |
const contract = getEtheriumContract() | |
await contract.deliverOrder(pid, id, { from: connectedAccount }) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const cancelOrder = async (pid, id) => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const connectedAccount = getGlobalState('connectedAccount') | |
const contract = getEtheriumContract() | |
await contract.cancelOrder(pid, id, { from: connectedAccount }) | |
} catch (error) { | |
reportError(error) | |
} | |
} | |
const reportError = (error) => { | |
console.log(error.message) | |
throw new Error('No ethereum object.') | |
} | |
const structuredProducts = (products) => | |
products | |
.map((product) => ({ | |
id: Number(product.id), | |
sku: product.sku, | |
seller: product.seller.toLowerCase(), | |
name: product.name, | |
description: product.description, | |
imageURL: product.imageURL, | |
stock: Number(product.stock), | |
price: parseInt(product.price._hex) / 10 ** 18, | |
deleted: product.deleted, | |
timestamp: new Date(product.timestamp).getTime(), | |
})) | |
.reverse() | |
const structuredOrders = (orders) => | |
orders | |
.map((order) => ({ | |
pid: Number(order.pid), | |
id: Number(order.id), | |
name: order.name, | |
sku: order.sku, | |
seller: order.seller.toLowerCase(), | |
buyer: order.buyer.toLowerCase(), | |
destination: order.destination, | |
phone: order.phone, | |
imageURL: order.imageURL, | |
qty: Number(order.qty), | |
status: Number(order.status), | |
total: parseInt(order.total._hex) / 10 ** 18, | |
timestamp: new Date(order.timestamp.toNumber()).getTime(), | |
})) | |
.reverse() | |
const structuredBuyers = (buyers) => | |
buyers | |
.map((buyer) => ({ | |
buyer: buyer.buyer.toLowerCase(), | |
qty: Number(buyer.qty), | |
price: parseInt(buyer.price._hex) / 10 ** 18, | |
timestamp: new Date(buyer.timestamp.toNumber() * 1000).toDateString(), | |
})) | |
.reverse() | |
const structureStats = (stats) => ({ | |
balance: Number(stats.balance), | |
orders: Number(stats.orders), | |
products: Number(stats.products), | |
sales: Number(stats.sales), | |
paid: Number(stats.paid._hex), | |
sellers: Number(stats.sellers), | |
}) | |
export { | |
isWallectConnected, | |
connectWallet, | |
createProduct, | |
updateProduct, | |
deleteProduct, | |
loadProducts, | |
loadProduct, | |
createOrder, | |
loadOrders, | |
loadStats, | |
delieverOrder, | |
cancelOrder, | |
} |
The Cart Service
This file contains the codes that calibrate our cart system, it ensures that every change in price and quantity of items is reflected in the sub and grand total of our cart.
On the **src**
directory, create a new file named **Cart.Services.jsx**
, copy the codes below and paste them into it and save.
import { getGlobalState, setGlobalState } from './store' | |
const addToCart = (product) => { | |
const products = getGlobalState('cart') | |
if (!products.find((p) => product.id == p.id)) { | |
setGlobalState('cart', [...products, { ...product, qty: 1 }]) | |
localStorage.setItem( | |
'cart', | |
JSON.stringify([...products, { ...product, qty: 1 }]), | |
) | |
summarizeCart() | |
} | |
} | |
const remFromCart = (product) => { | |
let products = getGlobalState('cart') | |
products = products.filter((p) => p.id != product.id) | |
setGlobalState('cart', products) | |
localStorage.setItem('cart', JSON.stringify(products)) | |
summarizeCart() | |
} | |
const updateCart = (product) => { | |
const products = getGlobalState('cart') | |
products.forEach((p) => { | |
if (p.id == product.id) p = product | |
}) | |
setGlobalState('cart', products) | |
localStorage.setItem('cart', JSON.stringify(products)) | |
summarizeCart() | |
} | |
const clearCart = () => { | |
setGlobalState('cart', []) | |
localStorage.removeItem('cart') | |
summarizeCart() | |
} | |
const summarizeCart = () => { | |
const products = getGlobalState('cart') | |
const summary = getGlobalState('summary') | |
products.forEach((p, i) => { | |
summary.total += p.qty * p.price | |
if (summary.ids.includes(p.id)) { | |
summary.qtys[i] = p.qty | |
} else { | |
summary.ids[i] = p.id | |
summary.qtys[i] = p.qty | |
} | |
}) | |
summary.tax = 0.002 | |
summary.grand = summary.total + summary.tax | |
setGlobalState('summary', summary) | |
summary.total = 0 | |
// summary.grand = 0 | |
} | |
const checkStorage = () => { | |
let products = JSON.parse(localStorage.getItem('cart')) | |
if (products?.length) { | |
setGlobalState('cart', JSON.parse(localStorage.getItem('cart'))) | |
summarizeCart() | |
} | |
} | |
export { addToCart, remFromCart, updateCart, checkStorage, clearCart } |
The Chat Service
This file contains the codes for interacting with the CometChat SDK. In the **src**
folder, create a new file named **Chat.Services.jsx**
. Now, copy the codes below, paste them into the file, and save.
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) => error) | |
} | |
const loginWithCometChat = async (UID) => { | |
const authKey = CONSTANTS.Auth_Key | |
return await CometChat.login(UID, authKey) | |
.then((user) => { | |
setGlobalState('currentUser', user) | |
return true | |
}) | |
.catch((error) => error) | |
} | |
const signUpWithCometChat = async (UID, name) => { | |
let authKey = CONSTANTS.Auth_Key | |
const user = new CometChat.User(UID) | |
user.setName(name) | |
return await CometChat.createUser(user, authKey) | |
.then((user) => { | |
console.log('Signed In: ', user) | |
return true | |
}) | |
.catch((error) => error) | |
} | |
const logOutWithCometChat = async () => { | |
return await CometChat.logout() | |
.then(() => setGlobalState('currentUser', null)) | |
.catch((error) => error) | |
} | |
const isUserLoggedIn = async () => { | |
await CometChat.getLoggedinUser() | |
.then((user) => setGlobalState('currentUser', user)) | |
.catch((error) => console.log('error:', error)) | |
} | |
const getUser = async (UID) => { | |
return await CometChat.getUser(UID) | |
.then((user) => user) | |
.catch((error) => error) | |
} | |
const getMessages = async (UID) => { | |
const limit = 30 | |
const messagesRequest = new CometChat.MessagesRequestBuilder() | |
.setUID(UID) | |
.setLimit(limit) | |
.build() | |
return await messagesRequest | |
.fetchPrevious() | |
.then((messages) => messages) | |
.catch((error) => error) | |
} | |
const sendMessage = async (receiverID, messageText) => { | |
const receiverType = CometChat.RECEIVER_TYPE.USER | |
const textMessage = new CometChat.TextMessage( | |
receiverID, | |
messageText, | |
receiverType, | |
) | |
return await CometChat.sendMessage(textMessage) | |
.then((message) => message) | |
.catch((error) => error) | |
} | |
const getConversations = async () => { | |
const limit = 30 | |
const conversationsRequest = new CometChat.ConversationsRequestBuilder() | |
.setLimit(limit) | |
.build() | |
return await conversationsRequest | |
.fetchNext() | |
.then((conversationList) => conversationList) | |
.catch((error) => error) | |
} | |
export { | |
initCometChat, | |
loginWithCometChat, | |
signUpWithCometChat, | |
logOutWithCometChat, | |
getMessages, | |
sendMessage, | |
getConversations, | |
isUserLoggedIn, | |
getUser, | |
CometChat, | |
} |
Finally, click the link below to download the image. If the asset folder does not already exist in your src directory, create one.
https://github.com/Daltonic/gameshop/blob/master/src/assets/banner.png?raw=true
With all that setup, run the command below to have the project running on your local machine.
yarn start
This will open the project on the browser at **localhost:3000**
.
Conclusion
That concludes the tutorial for this build, you have learned how to create a decentralized eCommerce platform that allows you to list game products on a marketplace.
Buyers can purchase your game product and on delivery, the money is released to the seller of the product.
This is one powerful use case for developing a real-life decentralized web3 application. More of these kinds of builds can be found here on my account.
You can also watch my free videos on my YouTube channel. Or book your private web3 classes with me to speed up your web3 learning process.
With that said, I will catch you next time, have a great day!
About the Author
Gospel Darlington is a full-stack blockchain developer with 6+
years of experience in the software development industry.
By combining Software Development, writing, and teaching, he demonstrates how to build decentralized applications on EVM-compatible blockchain networks.
His stacks include JavaScript
, React
, Vue
, Angular
, Node
, React Native
, NextJs
, Solidity
, and more.
For more information about him, kindly visit and follow his page on Twitter, Github, LinkedIn, or his website.
Top comments (0)