What you will be building, see demo and git repo here…

Introduction
I’m super excited to release this web3.0 build to you, I know you’ve been looking for a great example to get you started in developing decentralized applications.
If you are new here, I’m Darlington Gospel, a Dapp Mentor helping transition developers like you from Web 2.0 to Web 3.0. Book your private classes with me.
In this tutorial, you will learn step-by-step how to implement a decentralized autonomous organization (DAO) with anonymous chat features.
If you are pumped for this build, let’s jump into the tutorial…
Check out my YouTube channel for FREE web3 tutorials now.
Prerequisite
You will need the following tools installed to successfully crush this build:
- Node
- Ganache-Cli
- Truffle
- React
- Infuria
- Tailwind CSS
- CometChat SDK
- Metamask
- Yarn
Installing Dependencies
NodeJs Installation
Make sure you have NodeJs installed on your machine already, and if you haven’t, install it from HERE. Next, run the code on the terminal to confirm it is installed.
Yarn, Ganache-cli and Truffle Installation
Run the following codes on your terminal to install these essential packages globally.
npm i -g yarn
npm i -g truffle
npm i -g ganache-cli
Cloning Web3 Starter Project
Using the commands below, clone the web 3.0 starter project below. This will ensure that we’re all on the same page and are using the same packages.
git clone https://github.com/Daltonic/dominionDAO
Fantastic, let us replace the package.json
file with the one below:
{ | |
"name": "dominionDAO", | |
"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" | |
}, | |
"dependencies": { | |
"@cometchat-pro/chat": "3.0.6", | |
"moment": "^2.29.3", | |
"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.1", | |
"recharts": "^2.1.9", | |
"web-vitals": "^2.1.4", | |
"web3": "^1.7.1" | |
}, | |
"devDependencies": { | |
"@openzeppelin/contracts": "^4.5.0", | |
"@tailwindcss/forms": "0.4.0", | |
"@truffle/hdwallet-provider": "^2.0.4", | |
"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" | |
] | |
} | |
} |
Great, replace your package.json
file with the above code and then run yarn install
on your terminal.
With that all installed, let’s start with writing the Dominion DAO smart contract.
Configuring CometChat SDK
To configure the CometChat SDK, follow the steps below, at the end, you need to store 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 dominionDAO.
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
placeholders keys with their appropriate values.
REACT_APP_COMET_CHAT_REGION=**
REACT_APP_COMET_CHAT_APP_ID=**************
REACT_APP_COMET_CHAT_AUTH_KEY=******************************
Configuring Infuria App
STEP 1:
Head to Infuria, create an account.
STEP 2:
From the dashboard create a new project.
STEP 3:
Copy the Rinkeby
test network WebSocket endpoint URL to your .env
file.
Next, add your Metamask secret phrase and your preferred account private key. If you have done those 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=******************************
If you don’t know how to access your private key, see the section below.
Accessing Your Metamask Private Key
STEP 1:
Click on your Metamask
browser extension, and make sure Rinkeby
is selected as the test network. Next, on the preferred account, click on the vertical dotted line and select account details. 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=***************************
SECRET_KEY=******************
DEPLOYER_KEY=**********************
REACT_APP_COMET_CHAT_REGION=**
REACT_APP_COMET_CHAT_APP_ID=**************
REACT_APP_COMET_CHAT_AUTH_KEY=******************************
As for your SECRET_KEY
, you are required to paste your Metamask
secret phrase in the space provided in the environment file.
The Dominion DAO Smart Contract
Here is the full code for the smart contract, I will explain all the functions and variables one after the other.
// SPDX-License-Identifier: MIT | |
pragma solidity ^0.8.7; | |
import "@openzeppelin/contracts/access/AccessControl.sol"; | |
import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; | |
contract DominionDAO is ReentrancyGuard, AccessControl { | |
bytes32 private immutable CONTRIBUTOR_ROLE = keccak256("CONTRIBUTOR"); | |
bytes32 private immutable STAKEHOLDER_ROLE = keccak256("STAKEHOLDER"); | |
uint32 immutable MIN_VOTE_DURATION = 1 weeks; | |
uint256 totalProposals; | |
uint256 public daoBalance; | |
mapping(uint256 => ProposalStruct) private raisedProposals; | |
mapping(address => uint256[]) private stakeholderVotes; | |
mapping(uint256 => VotedStruct[]) private votedOn; | |
mapping(address => uint256) private contributors; | |
mapping(address => uint256) private stakeholders; | |
struct ProposalStruct { | |
uint256 id; | |
uint256 amount; | |
uint256 duration; | |
uint256 upvotes; | |
uint256 downvotes; | |
string title; | |
string description; | |
bool passed; | |
bool paid; | |
address payable beneficiary; | |
address proposer; | |
address executor; | |
} | |
struct VotedStruct { | |
address voter; | |
uint256 timestamp; | |
bool choosen; | |
} | |
event Action( | |
address indexed initiator, | |
bytes32 role, | |
string message, | |
address indexed beneficiary, | |
uint256 amount | |
); | |
modifier stakeholderOnly(string memory message) { | |
require(hasRole(STAKEHOLDER_ROLE, msg.sender), message); | |
_; | |
} | |
modifier contributorOnly(string memory message) { | |
require(hasRole(CONTRIBUTOR_ROLE, msg.sender), message); | |
_; | |
} | |
function createProposal( | |
string calldata title, | |
string calldata description, | |
address beneficiary, | |
uint256 amount | |
)external | |
stakeholderOnly("Proposal Creation Allowed for Stakeholders only") | |
{ | |
uint256 proposalId = totalProposals++; | |
ProposalStruct storage proposal = raisedProposals[proposalId]; | |
proposal.id = proposalId; | |
proposal.proposer = payable(msg.sender); | |
proposal.title = title; | |
proposal.description = description; | |
proposal.beneficiary = payable(beneficiary); | |
proposal.amount = amount; | |
proposal.duration = block.timestamp + MIN_VOTE_DURATION; | |
emit Action( | |
msg.sender, | |
CONTRIBUTOR_ROLE, | |
"PROPOSAL RAISED", | |
beneficiary, | |
amount | |
); | |
} | |
function performVote(uint256 proposalId, bool choosen) | |
external | |
stakeholderOnly("Unauthorized: Stakeholders only") | |
{ | |
ProposalStruct storage proposal = raisedProposals[proposalId]; | |
handleVoting(proposal); | |
if (choosen) proposal.upvotes++; | |
else proposal.downvotes++; | |
stakeholderVotes[msg.sender].push(proposal.id); | |
votedOn[proposal.id].push( | |
VotedStruct( | |
msg.sender, | |
block.timestamp, | |
choosen | |
) | |
); | |
emit Action( | |
msg.sender, | |
STAKEHOLDER_ROLE, | |
"PROPOSAL VOTE", | |
proposal.beneficiary, | |
proposal.amount | |
); | |
} | |
function handleVoting(ProposalStruct storage proposal) private { | |
if ( | |
proposal.passed || | |
proposal.duration <= block.timestamp | |
) { | |
proposal.passed = true; | |
revert("Proposal duration expired"); | |
} | |
uint256[] memory tempVotes = stakeholderVotes[msg.sender]; | |
for (uint256 votes = 0; votes < tempVotes.length; votes++) { | |
if (proposal.id == tempVotes[votes]) | |
revert("Double voting not allowed"); | |
} | |
} | |
function payBeneficiary(uint256 proposalId) | |
external | |
stakeholderOnly("Unauthorized: Stakeholders only") | |
returns (bool) | |
{ | |
ProposalStruct storage proposal = raisedProposals[proposalId]; | |
require(daoBalance >= proposal.amount, "Insufficient fund"); | |
require(block.timestamp > proposal.duration, "Proposal still ongoing"); | |
if (proposal.paid) revert("Payment sent before"); | |
if (proposal.upvotes <= proposal.downvotes) | |
revert("Insufficient votes"); | |
payTo(proposal.beneficiary, proposal.amount); | |
proposal.paid = true; | |
proposal.executor = msg.sender; | |
daoBalance -= proposal.amount; | |
emit Action( | |
msg.sender, | |
STAKEHOLDER_ROLE, | |
"PAYMENT TRANSFERED", | |
proposal.beneficiary, | |
proposal.amount | |
); | |
return true; | |
} | |
function contribute() payable external { | |
if (!hasRole(STAKEHOLDER_ROLE, msg.sender)) { | |
uint256 totalContribution = | |
contributors[msg.sender] + msg.value; | |
if (totalContribution >= 5 ether) { | |
stakeholders[msg.sender] = totalContribution; | |
contributors[msg.sender] += msg.value; | |
_setupRole(STAKEHOLDER_ROLE, msg.sender); | |
_setupRole(CONTRIBUTOR_ROLE, msg.sender); | |
} else { | |
contributors[msg.sender] += msg.value; | |
_setupRole(CONTRIBUTOR_ROLE, msg.sender); | |
} | |
} else { | |
contributors[msg.sender] += msg.value; | |
stakeholders[msg.sender] += msg.value; | |
} | |
daoBalance += msg.value; | |
emit Action( | |
msg.sender, | |
STAKEHOLDER_ROLE, | |
"CONTRIBUTION RECEIVED", | |
address(this), | |
msg.value | |
); | |
} | |
function getProposals() | |
external | |
view | |
returns (ProposalStruct[] memory props) | |
{ | |
props = new ProposalStruct[](totalProposals); | |
for (uint256 i = 0; i < totalProposals; i++) { | |
props[i] = raisedProposals[i]; | |
} | |
} | |
function getProposal(uint256 proposalId) | |
external | |
view | |
returns (ProposalStruct memory) | |
{ | |
return raisedProposals[proposalId]; | |
} | |
function getVotesOf(uint256 proposalId) | |
external | |
view | |
returns (VotedStruct[] memory) | |
{ | |
return votedOn[proposalId]; | |
} | |
function getStakeholderVotes() | |
external | |
view | |
stakeholderOnly("Unauthorized: not a stakeholder") | |
returns (uint256[] memory) | |
{ | |
return stakeholderVotes[msg.sender]; | |
} | |
function getStakeholderBalance() | |
external | |
view | |
stakeholderOnly("Unauthorized: not a stakeholder") | |
returns (uint256) | |
{ | |
return stakeholders[msg.sender]; | |
} | |
function isStakeholder() external view returns (bool) { | |
return stakeholders[msg.sender] > 0; | |
} | |
function getContributorBalance() | |
external | |
view | |
contributorOnly("Denied: User is not a contributor") | |
returns (uint256) | |
{ | |
return contributors[msg.sender]; | |
} | |
function isContributor() external view returns (bool) { | |
return contributors[msg.sender] > 0; | |
} | |
function getBalance() external view returns (uint256) { | |
return contributors[msg.sender]; | |
} | |
function payTo( | |
address to, | |
uint256 amount | |
) internal returns (bool) { | |
(bool success,) = payable(to).call{value: amount}(""); | |
require(success, "Payment failed"); | |
return true; | |
} | |
} |
In the project you just cloned, head to src >> contract directory and create a file named DominionDAO.sol
, then paste the above codes inside of it.
Explanation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
Solidity requires a license identifier to compile your code, else it will produce a warning asking you to specify one. Also, Solidity requires that you specify the version of the compiler for your smart contract. That is what the word pragma
represents.
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
In the above code block, we are utilizing two openzeppelin
'
s
smart contracts for specifying roles and guarding our smart contract against reentrancy attacks.
bytes32 private immutable CONTRIBUTOR_ROLE = keccak256("CONTRIBUTOR");
bytes32 private immutable STAKEHOLDER_ROLE = keccak256("STAKEHOLDER");
uint32 immutable MIN_VOTE_DURATION = 1 weeks;
uint256 totalProposals;
uint256 public daoBalance;
We set up some state variables for stakeholder and contributor roles and specified the minimum vote duration to be one week. And we also initialized the total proposal counter and a variable to keep a record of our available balance.
mapping(uint256 => ProposalStruct) private raisedProposals;
mapping(address => uint256[]) private stakeholderVotes;
mapping(uint256 => VotedStruct[]) private votedOn;
mapping(address => uint256) private contributors;
mapping(address => uint256) private stakeholders;
raisedProposals
keep track of all proposals submitted to our smart contract. stakeholderVotes
as its name imply keep track of votes made by stakeholders. votedOn
keeps track of all the votes associated with a proposal. While contributors keep track of anyone who donated to our platform, stakeholders on the other hand keep track of people that have contributed up to 1 ether
.
struct ProposalStruct {
uint256 id;
uint256 amount;
uint256 duration;
uint256 upvotes;
uint256 downvotes;
string title;
string description;
bool passed;
bool paid;
address payable beneficiary;
address proposer;
address executor;
}
struct VotedStruct {
address voter;
uint256 timestamp;
bool choosen;
}
proposalStruct
describes the content of each proposal whereas votedStruct
describes the content of each vote.
event Action(
address indexed initiator,
bytes32 role,
string message,
address indexed beneficiary,
uint256 amount
);
This is a dynamic event called Action. This will help us enrich the information logged out per transaction.
modifier stakeholderOnly(string memory message) {
require(hasRole(STAKEHOLDER_ROLE, msg.sender), message);
_;
}
modifier contributorOnly(string memory message) {
require(hasRole(CONTRIBUTOR_ROLE, msg.sender), message);
_;
}
The above modifiers help us identify users by role and also prevent them from accessing some unauthorized resources.
function createProposal(
string calldata title,
string calldata description,
address beneficiary,
uint256 amount
)external
stakeholderOnly("Proposal Creation Allowed for Stakeholders only")
{
uint256 proposalId = totalProposals++;
ProposalStruct storage proposal = raisedProposals[proposalId];
proposal.id = proposalId;
proposal.proposer = payable(msg.sender);
proposal.title = title;
proposal.description = description;
proposal.beneficiary = payable(beneficiary);
proposal.amount = amount;
proposal.duration = block.timestamp + MIN_VOTE_DURATION;
emit Action(
msg.sender,
CONTRIBUTOR_ROLE,
"PROPOSAL RAISED",
beneficiary,
amount
);
}
The above function takes a proposal's title, description, amount, and the beneficiary’s wallet address and creates a proposal. The function only permits stakeholders to create proposals. Stakeholders are users who have made at least a contribution of 1 ether
.
function performVote(uint256 proposalId, bool choosen)
external
stakeholderOnly("Unauthorized: Stakeholders only")
{
ProposalStruct storage proposal = raisedProposals[proposalId];
handleVoting(proposal);
if (choosen) proposal.upvotes++;
else proposal.downvotes++;
stakeholderVotes[msg.sender].push(proposal.id);
votedOn[proposal.id].push(
VotedStruct(
msg.sender,
block.timestamp,
choosen
)
);
emit Action(
msg.sender,
STAKEHOLDER_ROLE,
"PROPOSAL VOTE",
proposal.beneficiary,
proposal.amount
);
}
This function accepts two arguments, a proposal Id, and a preferred choice represented by a Boolean value. True means you accepted the vote and False represents a rejection.
function handleVoting(ProposalStruct storage proposal) private {
if (
proposal.passed ||
proposal.duration <= block.timestamp
) {
proposal.passed = true;
revert("Proposal duration expired");
}
uint256[] memory tempVotes = stakeholderVotes[msg.sender];
for (uint256 votes = 0; votes < tempVotes.length; votes++) {
if (proposal.id == tempVotes[votes])
revert("Double voting not allowed");
}
}
This function performs the actual voting including checking if a user is a stakeholder and qualified to vote.
function payBeneficiary(uint256 proposalId)
external
stakeholderOnly("Unauthorized: Stakeholders only")
returns (bool)
{
ProposalStruct storage proposal = raisedProposals[proposalId];
require(daoBalance >= proposal.amount, "Insufficient fund");
require(block.timestamp > proposal.duration, "Proposal still ongoing");
if (proposal.paid) revert("Payment sent before");
if (proposal.upvotes <= proposal.downvotes)
revert("Insufficient votes");
payTo(proposal.beneficiary, proposal.amount);
proposal.paid = true;
proposal.executor = msg.sender;
daoBalance -= proposal.amount;
emit Action(
msg.sender,
STAKEHOLDER_ROLE,
"PAYMENT TRANSFERED",
proposal.beneficiary,
proposal.amount
);
return true;
}
This function is responsible for paying the beneficiary attached to a proposal based on certain criteria.
- One, the beneficiary must not already be paid.
- Two, the proposal duration must have expired.
- Three, the available balance must be able to pay the beneficiary.
- Four, there must be no tie in the number of votes.
function contribute() payable external {
if (!hasRole(STAKEHOLDER_ROLE, msg.sender)) {
uint256 totalContribution =
contributors[msg.sender] + msg.value;
if (totalContribution >= 5 ether) {
stakeholders[msg.sender] = totalContribution;
contributors[msg.sender] += msg.value;
_setupRole(STAKEHOLDER_ROLE, msg.sender);
_setupRole(CONTRIBUTOR_ROLE, msg.sender);
} else {
contributors[msg.sender] += msg.value;
_setupRole(CONTRIBUTOR_ROLE, msg.sender);
}
} else {
contributors[msg.sender] += msg.value;
stakeholders[msg.sender] += msg.value;
}
daoBalance += msg.value;
emit Action(
msg.sender,
STAKEHOLDER_ROLE,
"CONTRIBUTION RECEIVED",
address(this),
msg.value
);
}
This function is responsible for collecting contributions from donors and those interested in becoming stakeholders.
function getProposals()
external
view
returns (ProposalStruct[] memory props)
{
props = new ProposalStruct[](totalProposals);
for (uint256 i = 0; i < totalProposals; i++) {
props[i] = raisedProposals[i];
}
}
This function retrieves an array of proposals recorded on this smart contract.
function getProposal(uint256 proposalId)
external
view
returns (ProposalStruct memory)
{
return raisedProposals[proposalId];
}
This function retrieves a particular proposal by Id.
function getVotesOf(uint256 proposalId)
external
view
returns (VotedStruct[] memory)
{
return votedOn[proposalId];
}
This returns a list of votes associated with a particular proposal.
function getStakeholderVotes()
external
view
stakeholderOnly("Unauthorized: not a stakeholder")
returns (uint256[] memory)
{
return stakeholderVotes[msg.sender];
}
This returns the list of stakeholders on the smart contract and only a stakeholder can call this function.
function getStakeholderBalance()
external
view
stakeholderOnly("Unauthorized: not a stakeholder")
returns (uint256)
{
return stakeholders[msg.sender];
}
This returns the amount of money contributed by stakeholders.
function isStakeholder() external view returns (bool) {
return stakeholders[msg.sender] > 0;
}
Returns True or False if a user is a stakeholder.
function getContributorBalance()
external
view
contributorOnly("Denied: User is not a contributor")
returns (uint256)
{
return contributors[msg.sender];
}
This returns the balance of a contributor and is only accessible to the contributor.
function isContributor() external view returns (bool) {
return contributors[msg.sender] > 0;
}
This checks if a user is a contributor or not and it is represented with True or False.
function getBalance() external view returns (uint256) {
return contributors[msg.sender];
}
Returns the balance of the calling user regardless of his role.
function payTo(
address to,
uint256 amount
) internal returns (bool) {
(bool success,) = payable(to).call{value: amount}("");
require(success, "Payment failed");
return true;
}
This function performs a payment with both a specified amount and account.
Configuring the Deployment Script
One more thing to do with for the smart contract is to configure the deployment script.
On the project head to the migrations folder >> 2_deploy_contracts.js and update it with the code snippet below.
const DominionDAO = artifacts.require('DominionDAO')
module.exports = async function (deployer) {
await deployer.deploy(DominionDAO)
}
Fantastic, we just finished up with the smart contract for our application, it's time to start building the Dapp interface.
Developing the Frontend
The front end comprises many components and parts. We will be creating all the components, views, and the rest of the peripherals.
Header Component
This component captures information about the current user and carries a theme toggling button for light and dark modes. And if you wondered how I did that, it was through Tailwind CSS, see the code below.
import { useState, useEffect } from 'react' | |
import { FaUserSecret } from 'react-icons/fa' | |
import { MdLightMode } from 'react-icons/md' | |
import { FaMoon } from 'react-icons/fa' | |
import { Link } from 'react-router-dom' | |
import { connectWallet } from '../Dominion' | |
import { useGlobalState, truncate } from '../store' | |
const Header = () => { | |
const [theme, setTheme] = useState(localStorage.theme) | |
const themeColor = theme === 'dark' ? 'light' : 'dark' | |
const darken = theme === 'dark' ? true : false | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
useEffect(() => { | |
const root = window.document.documentElement | |
root.classList.remove(themeColor) | |
root.classList.add(theme) | |
localStorage.setItem('theme', theme) | |
}, [themeColor, theme]) | |
const toggleLight = () => { | |
const root = window.document.documentElement | |
root.classList.remove(themeColor) | |
root.classList.add(theme) | |
localStorage.setItem('theme', theme) | |
setTheme(themeColor) | |
} | |
return ( | |
<header className="sticky top-0 z-50 dark:text-blue-500"> | |
<nav className="navbar navbar-expand-lg shadow-md py-2 relative flex items-center w-full justify-between bg-white dark:bg-[#212936]"> | |
<div className="px-6 w-full flex flex-wrap items-center justify-between"> | |
<div className="navbar-collapse collapse grow flex flex-row justify-between items-center p-2"> | |
<Link | |
to={'/'} | |
className="flex flex-row justify-start items-center space-x-3" | |
> | |
<FaUserSecret className="cursor-pointer" size={25} /> | |
<span className="invisible md:visible dark:text-gray-300"> | |
Dominion | |
</span> | |
</Link> | |
<div className="flex flex-row justify-center items-center space-x-5"> | |
{darken ? ( | |
<MdLightMode | |
className="cursor-pointer" | |
size={25} | |
onClick={toggleLight} | |
/> | |
) : ( | |
<FaMoon | |
className="cursor-pointer" | |
size={25} | |
onClick={toggleLight} | |
/> | |
)} | |
{connectedAccount ? ( | |
<button | |
className="px-4 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 dark:text-blue-500 | |
dark:border dark:border-blue-500 dark:bg-transparent" | |
> | |
{truncate(connectedAccount, 4, 4, 11)} | |
</button> | |
) : ( | |
<button | |
className="px-4 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 dark:text-blue-500 | |
dark:border dark:border-blue-500 dark:bg-transparent" | |
onClick={connectWallet} | |
> | |
Connect Wallet | |
</button> | |
)} | |
</div> | |
</div> | |
</div> | |
</nav> | |
</header> | |
) | |
} | |
export default Header |
Banner Component
This component contains information about the DAO's current state, such as the total balance and the number of open proposals.
This component also includes the ability to use the contribute function to generate a new proposal. Look at the code below.
Proposals Component
This component contains a list of proposals in our smart contract. Also, enables you to filter between closed and open proposals. At the expiry of a proposal, a payout button becomes available which gives a stakeholder the option of paying out the amount associated with the proposal. See the code below.
import Identicon from 'react-identicons' | |
import { useState } from 'react' | |
import { Link } from 'react-router-dom' | |
import { truncate, useGlobalState, daysRemaining } from '../store' | |
import { payoutBeneficiary } from '../Dominion' | |
import { toast } from 'react-toastify' | |
const Proposals = () => { | |
const [data] = useGlobalState('proposals') | |
const [proposals, setProposals] = useState(data) | |
const deactive = `bg-transparent | |
text-blue-600 font-medium text-xs leading-tight | |
uppercase hover:bg-blue-700 focus:bg-blue-700 | |
focus:outline-none focus:ring-0 active:bg-blue-600 | |
transition duration-150 ease-in-out overflow-hidden | |
border border-blue-600 hover:text-white focus:text-white` | |
const active = `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 overflow-hidden | |
border border-blue-600` | |
const getAll = () => setProposals(data) | |
const getOpened = () => | |
setProposals( | |
data.filter( | |
(proposal) => new Date().getTime() < Number(proposal.duration + '000') | |
) | |
) | |
const getClosed = () => | |
setProposals( | |
data.filter( | |
(proposal) => new Date().getTime() > Number(proposal.duration + '000') | |
) | |
) | |
const handlePayout = (id) => { | |
payoutBeneficiary(id).then((res) => { | |
if (!!!res.code) { | |
toast.success('Beneficiary successfully Paid Out!') | |
window.location.reload() | |
} | |
}) | |
} | |
return ( | |
<div className="flex flex-col p-8"> | |
<div className="flex flex-row justify-center items-center" role="group"> | |
<button | |
aria-current="page" | |
className={`rounded-l-full px-6 py-2.5 ${active}`} | |
onClick={getAll} | |
> | |
All | |
</button> | |
<button | |
aria-current="page" | |
className={`px-6 py-2.5 ${deactive}`} | |
onClick={getOpened} | |
> | |
Open | |
</button> | |
<button | |
aria-current="page" | |
className={`rounded-r-full px-6 py-2.5 ${deactive}`} | |
onClick={getClosed} | |
> | |
Closed | |
</button> | |
</div> | |
<div className="overflow-x-auto sm:-mx-6 lg:-mx-8"> | |
<div className="py-2 inline-block min-w-full sm:px-6 lg:px-8"> | |
<div className="h-[calc(100vh_-_20rem)] overflow-y-auto shadow-md rounded-md"> | |
<table className="min-w-full"> | |
<thead className="border-b dark:border-gray-500"> | |
<tr> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Created By | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Title | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Expires | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Action | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
{proposals.map((proposal) => ( | |
<tr | |
key={proposal.id} | |
className="border-b dark:border-gray-500" | |
> | |
<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={proposal.proposer.toLowerCase()} | |
size={25} | |
className="h-10 w-10 object-contain rounded-full mr-3" | |
/> | |
<span>{truncate(proposal.proposer, 4, 4, 11)}</span> | |
</div> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
{proposal.title.substring(0, 80) + '...'} | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
{new Date().getTime() > Number(proposal.duration + '000') | |
? 'Expired' | |
: daysRemaining(proposal.duration)} | |
</td> | |
<td | |
className="flex justify-start items-center space-x-3 | |
text-sm font-light px-6 py-4 whitespace-nowrap" | |
> | |
<Link | |
to={'/proposal/' + proposal.id} | |
className="dark:border rounded-full px-6 py-2.5 dark:border-blue-600 | |
dark:text-blue-600 dark:bg-transparent font-medium text-xs leading-tight | |
uppercase hover:border-blue-700 focus:border-blue-700 | |
focus:outline-none focus:ring-0 active:border-blue-800 | |
transition duration-150 ease-in-out text-white bg-blue-600" | |
> | |
View | |
</Link> | |
{new Date().getTime() > | |
Number(proposal.duration + '000') ? ( | |
proposal.upvotes > proposal.downvotes ? ( | |
!proposal.paid ? ( | |
<button | |
className="dark:border rounded-full px-6 py-2.5 dark:border-red-600 | |
dark:text-red-600 dark:bg-transparent font-medium text-xs leading-tight | |
uppercase hover:border-red-700 focus:border-red-700 | |
focus:outline-none focus:ring-0 active:border-red-800 | |
transition duration-150 ease-in-out text-white bg-red-600" | |
onClick={() => handlePayout(proposal.id)} | |
> | |
Payout | |
</button> | |
) : ( | |
<button | |
className="dark:border rounded-full px-6 py-2.5 dark:border-green-600 | |
dark:text-green-600 dark:bg-transparent font-medium text-xs leading-tight | |
uppercase hover:border-green-700 focus:border-green-700 | |
focus:outline-none focus:ring-0 active:border-green-800 | |
transition duration-150 ease-in-out text-white bg-green-600" | |
> | |
Paid | |
</button> | |
) | |
) : ( | |
<button | |
className="dark:border rounded-full px-6 py-2.5 dark:border-red-600 | |
dark:text-red-600 dark:bg-transparent font-medium text-xs leading-tight | |
uppercase hover:border-red-700 focus:border-red-700 | |
focus:outline-none focus:ring-0 active:border-red-800 | |
transition duration-150 ease-in-out text-white bg-red-600" | |
> | |
Rejected | |
</button> | |
) | |
) : null} | |
</td> | |
</tr> | |
))} | |
</tbody> | |
</table> | |
</div> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default Proposals |
The Proposal Details Component
This component displays information about the current proposal, including the cost. This component allows stakeholders to accept or reject a proposal.
The proposer can form a group, and other platform users can engage in web3.0-style anonymous chat.
This component also includes a bar chat that allows you to see the ratio of acceptees to rejectees. Look at the code below.
import moment from 'moment' | |
import { useEffect, useState } from 'react' | |
import { useParams, useNavigate } from 'react-router-dom' | |
import { toast } from 'react-toastify' | |
import { getGroup, createNewGroup, joinGroup } from '../CometChat' | |
import { | |
BarChart, | |
Bar, | |
CartesianGrid, | |
XAxis, | |
YAxis, | |
Legend, | |
Tooltip, | |
} from 'recharts' | |
import { getProposal, voteOnProposal } from '../Dominion' | |
import { useGlobalState } from '../store' | |
const ProposalDetails = () => { | |
const { id } = useParams() | |
const navigator = useNavigate() | |
const [proposal, setProposal] = useState(null) | |
const [group, setGroup] = useState(null) | |
const [data, setData] = useState([]) | |
const [isStakeholder] = useGlobalState('isStakeholder') | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const [currentUser] = useGlobalState('currentUser') | |
useEffect(() => { | |
retrieveProposal() | |
getGroup(`pid_${id}`).then((group) => { | |
if (!!!group.code) setGroup(group) | |
console.log(group) | |
}) | |
}, [id]) | |
const retrieveProposal = () => { | |
getProposal(id).then((res) => { | |
setProposal(res) | |
setData([ | |
{ | |
name: 'Voters', | |
Acceptees: res?.upvotes, | |
Rejectees: res?.downvotes, | |
}, | |
]) | |
}) | |
} | |
const onVote = (choice) => { | |
if (new Date().getTime() > Number(proposal.duration + '000')) { | |
toast.warning('Proposal expired!') | |
return | |
} | |
voteOnProposal(id, choice).then((res) => { | |
if (!!!res.code) { | |
toast.success('Voted successfully!') | |
window.location.reload() | |
} | |
}) | |
} | |
const daysRemaining = (days) => { | |
const todaysdate = moment() | |
days = Number((days + '000').slice(0)) | |
days = moment(days).format('YYYY-MM-DD') | |
days = moment(days) | |
days = days.diff(todaysdate, 'days') | |
return days == 1 ? '1 day' : days + ' days' | |
} | |
const onEnterChat = () => { | |
if (group.hasJoined) { | |
navigator(`/chat/${`pid_${id}`}`) | |
} else { | |
joinGroup(`pid_${id}`).then((res) => { | |
if (!!res) { | |
navigator(`/chat/${`pid_${id}`}`) | |
console.log('Success joining: ', res) | |
} else { | |
console.log('Error Joining Group: ', res) | |
} | |
}) | |
} | |
} | |
const onCreateGroup = () => { | |
createNewGroup(`pid_${id}`, proposal.title).then((group) => { | |
if (!!!group.code) { | |
toast.success('Group created successfully!') | |
setGroup(group) | |
} else { | |
console.log('Error Creating Group: ', group) | |
} | |
}) | |
} | |
return ( | |
<div className="p-8"> | |
<h2 className="font-semibold text-3xl mb-5">{proposal?.title}</h2> | |
<p> | |
This proposal is to payout <strong>{proposal?.amount} Eth</strong> and | |
currently have{' '} | |
<strong>{proposal?.upvotes + proposal?.downvotes} votes</strong> and | |
will expire in <strong>{daysRemaining(proposal?.duration)}</strong> | |
</p> | |
<hr className="my-6 border-gray-300" /> | |
<p>{proposal?.description}</p> | |
<div className="flex flex-row justify-start items-center w-full mt-4 overflow-auto"> | |
<BarChart width={730} height={250} data={data}> | |
<CartesianGrid strokeDasharray="3 3" /> | |
<XAxis dataKey="name" /> | |
<YAxis /> | |
<Tooltip /> | |
<Legend /> | |
<Bar dataKey="Acceptees" fill="#2563eb" /> | |
<Bar dataKey="Rejectees" fill="#dc2626" /> | |
</BarChart> | |
</div> | |
<div | |
className="flex flex-row justify-start items-center space-x-3 mt-4" | |
role="group" | |
> | |
{isStakeholder ? ( | |
<> | |
<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 dark:text-gray-300 | |
dark:border dark:border-gray-500 dark:bg-transparent" | |
data-mdb-ripple="true" | |
data-mdb-ripple-color="light" | |
onClick={() => onVote(true)} | |
> | |
Accept | |
</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 | |
dark:border dark:border-gray-500 dark:bg-transparent" | |
data-mdb-ripple="true" | |
data-mdb-ripple-color="light" | |
onClick={() => onVote(false)} | |
> | |
Reject | |
</button> | |
{currentUser && | |
currentUser.uid.toLowerCase() == proposal?.proposer.toLowerCase() && | |
!group ? ( | |
<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 | |
dark:border dark:border-blue-500" | |
data-mdb-ripple="true" | |
data-mdb-ripple-color="light" | |
onClick={onCreateGroup} | |
> | |
Create Group | |
</button> | |
) : null} | |
</> | |
) : null} | |
{currentUser && currentUser.uid.toLowerCase() == connectedAccount.toLowerCase() && !!!group?.code && group != null ? ( | |
<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 | |
dark:border dark:border-blue-500" | |
data-mdb-ripple="true" | |
data-mdb-ripple-color="light" | |
onClick={onEnterChat} | |
> | |
Chat | |
</button> | |
) : null} | |
{proposal?.proposer.toLowerCase() != connectedAccount.toLowerCase() && | |
!!!group ? ( | |
<button | |
type="button" | |
className="inline-block px-6 py-2.5 bg-blue-600 | |
dark:bg-transparent text-white font-medium text-xs | |
leading-tight uppercase rounded-full shadow-md | |
hover:border-blue-700 hover:shadow-lg focus:border-blue-700 | |
focus:shadow-lg focus:outline-none focus:ring-0 | |
active:border-blue-800 active:shadow-lg transition | |
duration-150 ease-in-out dark:text-blue-500 | |
dark:border dark:border-blue-500 disabled:bg-blue-300" | |
data-mdb-ripple="true" | |
data-mdb-ripple-color="light" | |
disabled | |
> | |
Group N/A | |
</button> | |
) : null} | |
</div> | |
</div> | |
) | |
} | |
export default ProposalDetails |
Voters Component
This component simply lists out the stakeholders that have voted on a proposal. The component also affords a user the chance to filter between rejectees and acceptees. See the code below.
import Identicon from 'react-identicons' | |
import moment from 'moment' | |
import { useState, useEffect } from 'react' | |
import { useParams } from 'react-router-dom' | |
import { truncate } from '../store' | |
import { listVoters } from '../Dominion' | |
const Voters = () => { | |
const [voters, setVoters] = useState([]) | |
const [data, setData] = useState([]) | |
const { id } = useParams() | |
const timeAgo = (timestamp) => moment(Number(timestamp + '000')).fromNow() | |
const deactive = `bg-transparent | |
text-blue-600 font-medium text-xs leading-tight | |
uppercase hover:bg-blue-700 focus:bg-blue-700 | |
focus:outline-none focus:ring-0 active:bg-blue-600 | |
transition duration-150 ease-in-out overflow-hidden | |
border border-blue-600 hover:text-white focus:text-white` | |
const active = `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 overflow-hidden | |
border border-blue-600` | |
useEffect(() => { | |
listVoters(id).then((res) => { | |
setVoters(res) | |
setData(res) | |
}) | |
}, [id]) | |
const getAll = () => setVoters(data) | |
const getAccepted = () => setVoters(data.filter((vote) => vote.choosen)) | |
const getRejected = () => setVoters(data.filter((vote) => !vote.choosen)) | |
return ( | |
<div className="flex flex-col p-8"> | |
<div className="flex flex-row justify-center items-center" role="group"> | |
<button | |
aria-current="page" | |
className={`rounded-l-full px-6 py-2.5 ${active}`} | |
onClick={getAll} | |
> | |
All | |
</button> | |
<button | |
aria-current="page" | |
className={`px-6 py-2.5 ${deactive}`} | |
onClick={getAccepted} | |
> | |
Acceptees | |
</button> | |
<button | |
aria-current="page" | |
className={`rounded-r-full px-6 py-2.5 ${deactive}`} | |
onClick={getRejected} | |
> | |
Rejectees | |
</button> | |
</div> | |
<div className="overflow-x-auto sm:-mx-6 lg:-mx-8"> | |
<div className="py-2 inline-block min-w-full sm:px-6 lg:px-8"> | |
<div className="h-[calc(100vh_-_20rem)] overflow-y-auto shadow-md rounded-md"> | |
<table className="min-w-full"> | |
<thead className="border-b dark:border-gray-500"> | |
<tr> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Voter | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Voted | |
</th> | |
<th | |
scope="col" | |
className="text-sm font-medium px-6 py-4 text-left" | |
> | |
Vote | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
{voters.map((voter, i) => ( | |
<tr | |
key={i} | |
className="border-b dark:border-gray-500 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={voter.voter.toLowerCase()} | |
size={25} | |
className="h-10 w-10 object-contain rounded-full mr-3" | |
/> | |
<span>{truncate(voter.voter, 4, 4, 11)}</span> | |
</div> | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
{timeAgo(voter.timestamp)} | |
</td> | |
<td className="text-sm font-light px-6 py-4 whitespace-nowrap"> | |
{voter.choosen ? ( | |
<button | |
className="border-2 rounded-full px-6 py-2.5 border-blue-600 | |
text-blue-600 font-medium text-xs leading-tight | |
uppercase hover:border-blue-700 focus:border-blue-700 | |
focus:outline-none focus:ring-0 active:border-blue-800 | |
transition duration-150 ease-in-out" | |
> | |
Accepted | |
</button> | |
) : ( | |
<button | |
className="border-2 rounded-full px-6 py-2.5 border-red-600 | |
text-red-600 font-medium text-xs leading-tight | |
uppercase hover:border-red-700 focus:border-red-700 | |
focus:outline-none focus:ring-0 active:border-red-800 | |
transition duration-150 ease-in-out" | |
> | |
Rejected | |
</button> | |
)} | |
</td> | |
</tr> | |
))} | |
</tbody> | |
</table> | |
</div> | |
</div> | |
</div> | |
<div className="mt-4 text-center"> | |
{voters.length >= 10 ? ( | |
<button | |
aria-current="page" | |
className="rounded-full px-6 py-2.5 bg-blue-600 | |
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 dark:text-gray-300 | |
dark:border dark:border-gray-500 dark:bg-transparent" | |
> | |
Load More | |
</button> | |
) : null} | |
</div> | |
</div> | |
) | |
} | |
export default Voters |
Messages Component
With the power of CometChat SDK combined with this component, users can engage in a one-to-many chat anonymously. Contributors and stakeholders can discuss a proposal further in their decision-making process here. All users maintain their anonymity and are represented by their Identicons.
import Identicon from 'react-identicons' | |
import { useState, useEffect } from 'react' | |
import { useNavigate } from 'react-router-dom' | |
import { truncate, useGlobalState } from '../store' | |
import { getMessages, sendMessage, CometChat } from '../CometChat' | |
const Messages = ({ gid }) => { | |
const navigator = useNavigate() | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const [message, setMessage] = useState('') | |
const [messages, setMessages] = useState([]) | |
useEffect(() => { | |
getMessages(gid).then((msgs) => { | |
if (!!!msgs.code) | |
setMessages(msgs.filter((msg) => msg.category == 'message')) | |
}) | |
listenForMessage(gid) | |
}, [gid]) | |
const listenForMessage = (listenerID) => { | |
CometChat.addMessageListener( | |
listenerID, | |
new CometChat.MessageListener({ | |
onTextMessageReceived: (message) => { | |
setMessages((prevState) => [...prevState, message]) | |
scrollToEnd() | |
}, | |
}) | |
) | |
} | |
const handleMessage = (e) => { | |
e.preventDefault() | |
sendMessage(gid, message).then((msg) => { | |
if (!!!msg.code) { | |
setMessages((prevState) => [...prevState, msg]) | |
setMessage('') | |
scrollToEnd() | |
} | |
}) | |
} | |
const scrollToEnd = () => { | |
const elmnt = document.getElementById('messages-container') | |
elmnt.scrollTop = elmnt.scrollHeight | |
} | |
const dateToTime = (date) => { | |
let hours = date.getHours() | |
let minutes = date.getMinutes() | |
let ampm = hours >= 12 ? 'pm' : 'am' | |
hours = hours % 12 | |
hours = hours ? hours : 12 | |
minutes = minutes < 10 ? '0' + minutes : minutes | |
let strTime = hours + ':' + minutes + ' ' + ampm | |
return strTime | |
} | |
return ( | |
<div className="p-8"> | |
<div className="flex flex-row justify-start"> | |
<button | |
className="px-4 py-2.5 bg-transparent hover:text-white | |
font-bold 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" | |
onClick={() => navigator(`/proposal/${gid.substr(4)}`)} | |
> | |
Exit Chat | |
</button> | |
</div> | |
<div | |
id="messages-container" | |
className="h-[calc(100vh_-_16rem)] overflow-y-auto sm:pr-4 my-3" | |
> | |
{messages.map((message, i) => | |
message.sender.uid.toLowerCase() != connectedAccount.toLowerCase() ? ( | |
<div key={i} className="flex flex-row justify-start my-2"> | |
<div className="flex flex-col bg-transparent w-80 p-3 px-5 rounded-3xl shadow-md"> | |
<div className="flex flex-row justify-start items-center space-x-2"> | |
<Identicon | |
string={message.sender.uid.toLowerCase()} | |
size={25} | |
className="h-10 w-10 object-contain shadow-md rounded-full mr-3" | |
/> | |
<span>@{truncate(message.sender.uid, 4, 4, 11)}</span> | |
<small>{dateToTime(new Date(message.sentAt * 1000))}</small> | |
</div> | |
<small className="leading-tight my-2">{message.text}</small> | |
</div> | |
</div> | |
) : ( | |
<div key={i} className="flex flex-row justify-end my-2"> | |
<div className="flex flex-col bg-transparent w-80 p-3 px-5 rounded-3xl shadow-md shadow-blue-300"> | |
<div className="flex flex-row justify-start items-center space-x-2"> | |
<Identicon | |
string={connectedAccount.toLowerCase()} | |
size={25} | |
className="h-10 w-10 object-contain shadow-md rounded-full mr-3" | |
/> | |
<span>@you</span> | |
<small>{dateToTime(new Date(message.sentAt * 1000))}</small> | |
</div> | |
<small className="leading-tight my-2">{message.text}</small> | |
</div> | |
</div> | |
) | |
)} | |
</div> | |
<form onSubmit={handleMessage} className="flex flex-row"> | |
<input | |
className="w-full bg-transparent rounded-lg p-4 | |
focus:ring-0 focus:outline-none border-gray-500" | |
type="text" | |
placeholder="Write a message..." | |
value={message} | |
onChange={(e) => setMessage(e.target.value)} | |
required | |
/> | |
<button type="submit" hidden> | |
send | |
</button> | |
</form> | |
</div> | |
) | |
} | |
export default Messages |
Create Proposal Component
This component simply lets you raise a proposal by supplying information on the fields seen in the image above. See the code below.
import { useState } from 'react' | |
import { FaTimes } from 'react-icons/fa' | |
import { raiseProposal } from '../Dominion' | |
import { setGlobalState, useGlobalState } from '../store' | |
import { toast } from 'react-toastify' | |
const CreateProposal = () => { | |
const [createModal] = useGlobalState('createModal') | |
const [title, setTitle] = useState('') | |
const [amount, setAmount] = useState('') | |
const [beneficiary, setBeneficiary] = useState('') | |
const [description, setDescription] = useState('') | |
const handleSubmit = (e) => { | |
e.preventDefault() | |
if (!title || !description || !beneficiary || !amount) return | |
const proposal = { title, description, beneficiary, amount } | |
raiseProposal(proposal).then((proposed) => { | |
if (proposed) { | |
toast.success('Proposal created, reloading in progress...') | |
closeModal() | |
window.location.reload() | |
} | |
}) | |
} | |
const closeModal = () => { | |
setGlobalState('createModal', 'scale-0') | |
resetForm() | |
} | |
const resetForm = () => { | |
setTitle('') | |
setAmount('') | |
setBeneficiary('') | |
setDescription('') | |
} | |
return ( | |
<div | |
className={`fixed top-0 left-0 w-screen h-screen flex items-center | |
justify-center bg-black bg-opacity-50 transform z-50 | |
transition-transform duration-300 ${createModal}`} | |
> | |
<div className="bg-white dark:bg-[#212936] shadow-xl shadow-[#122643] dark:shadow-gray-500 rounded-xl w-11/12 md:w-2/5 h-7/12 p-6"> | |
<form className="flex flex-col"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-semibold">Raise Proposal</p> | |
<button | |
type="button" | |
onClick={closeModal} | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<FaTimes /> | |
</button> | |
</div> | |
<div className="flex flex-row justify-between items-center border border-gray-500 dark:border-gray-500 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="text" | |
name="title" | |
placeholder="Title" | |
onChange={(e) => setTitle(e.target.value)} | |
value={title} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center border border-gray-500 dark:border-gray-500 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="text" | |
name="amount" | |
placeholder="e.g 2.5 Eth" | |
onChange={(e) => setAmount(e.target.value)} | |
value={amount} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center border border-gray-500 dark:border-gray-500 rounded-xl mt-5"> | |
<input | |
className="block w-full text-sm | |
bg-transparent border-0 | |
focus:outline-none focus:ring-0" | |
type="text" | |
name="beneficiary" | |
placeholder="Beneficiary Address" | |
onChange={(e) => setBeneficiary(e.target.value)} | |
value={beneficiary} | |
required | |
/> | |
</div> | |
<div className="flex flex-row justify-between items-center border border-gray-500 dark:border-gray-500 rounded-xl mt-5"> | |
<textarea | |
className="block w-full text-sm resize-none | |
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 | |
className="rounded-lg px-6 py-2.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 mt-5" | |
onClick={handleSubmit} | |
> | |
Submit Proposal | |
</button> | |
</form> | |
</div> | |
</div> | |
) | |
} | |
export default CreateProposal |
Authentication Component
This component helps you participate in the chat features. You need to create an account or login in if you’ve already signed up. By logging in, you can be able to participate in a group chat and have some anonymous talk with other participants in a proposal in a web3.0 style. See the code below.
import { FaTimes } from 'react-icons/fa' | |
import { loginWithCometChat, signInWithCometChat } from '../CometChat' | |
import { setGlobalState, useGlobalState } from '../store' | |
import { toast } from 'react-toastify' | |
const ChatLogin = () => { | |
const [loginModal] = useGlobalState('loginModal') | |
const [connectedAccount] = useGlobalState('connectedAccount') | |
const handleSignUp = () => { | |
signInWithCometChat(connectedAccount, connectedAccount).then((user) => { | |
if (!!!user.code) { | |
toast.success('Account created, now click the login button.') | |
} else { | |
toast.error(user.message) | |
} | |
}) | |
} | |
const handleLogin = () => { | |
loginWithCometChat(connectedAccount).then((user) => { | |
if (!!!user.code) { | |
setGlobalState('currentUser', user) | |
toast.success('Logged in successful!') | |
closeModal() | |
} else { | |
toast.error(user.message) | |
} | |
}) | |
} | |
const closeModal = () => { | |
setGlobalState('loginModal', '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 z-50 | |
transition-transform duration-300 ${loginModal}`} | |
> | |
<div className="bg-white dark:bg-[#212936] shadow-xl shadow-[#122643] dark:shadow-gray-500 rounded-xl w-11/12 md:w-2/5 h-7/12 p-6"> | |
<div className="flex flex-col"> | |
<div className="flex flex-row justify-between items-center"> | |
<p className="font-semibold">Authenticate</p> | |
<button | |
type="button" | |
onClick={closeModal} | |
className="border-0 bg-transparent focus:outline-none" | |
> | |
<FaTimes /> | |
</button> | |
</div> | |
<div className="my-2 font-light"> | |
<span> | |
Once you login, you will be enabled to chat with other | |
stakeholders to make a well-informed voting. | |
</span> | |
</div> | |
<div | |
className="flex flex-row justify-between items-center mt-2" | |
role="group" | |
> | |
<button | |
className="rounded-lg px-6 py-2.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 mt-5" | |
onClick={handleLogin} | |
> | |
Login | |
</button> | |
<button | |
className="rounded-lg px-6 py-2.5 bg-transparent | |
text-blue-600 font-medium text-xs leading-tight | |
uppercase hover:bg-blue-700 hover:text-white focus:bg-blue-700 | |
focus:outline-none focus:ring-0 active:bg-blue-800 | |
transition duration-150 ease-in-out mt-5 | |
border-blue-600" | |
onClick={handleSignUp} | |
> | |
Create Account | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default ChatLogin |
Fantastic, let’s make sure the views are well represented…
The Home View
This view includes the header, banner, and proposals components for providing an exceptional DAO user experience. We also used the power of Tailwind CSS to achieve this look. Look at the code below.
import Banner from '../components/Banner'
import ChatLogin from '../components/ChatLogin'
import CreateProposal from '../components/CreateProposal'
import Header from '../components/Header'
import Proposals from '../components/Proposals'
const Home = () => {
return (
<>
<Header />
<Banner />
<Proposals />
<CreateProposal />
<ChatLogin />
</>
)
}
export default Home
The Proposal View
This view couples together the header, proposal details, and voters component for rendering a smooth presentation of a singular component. See the code below.
import Header from '../components/Header'
import ProposalDetails from '../components/ProposalDetails'
import Voters from '../components/Voters'
const Proposal = () => {
return (
<>
<Header />
<ProposalDetails />
<Voters />
</>
)
}
export default Proposal
The Chat View
Lastly, the chat view incorporates the header and messages component for rendering a quality chat interface. See the code below.
import { useParams, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { getGroup } from '../CometChat'
import { toast } from 'react-toastify'
import Header from '../components/Header'
import Messages from '../components/Messages'
const Chat = () => {
const { gid } = useParams()
const navigator = useNavigate()
const [group, setGroup] = useState(null)
useEffect(() => {
getGroup(gid).then((group) => {
if (!!!group.code) {
setGroup(group)
} else {
toast.warning('Please join the group first!')
navigator(`/proposal/${gid.substr(4)}`)
}
})
}, [gid])
return (
<>
<Header />
<Messages gid={gid} />
</>
)
}
export default Chat
Amazing, don’t forget to update the App.jsx
file too.
The App Component
Replace the App component with the code below.
import { useEffect, useState } from 'react'
import { Routes, Route } from 'react-router-dom'
import { loadWeb3 } from './Dominion'
import { ToastContainer } from 'react-toastify'
import { isUserLoggedIn } from './CometChat'
import Home from './views/Home'
import Proposal from './views/Proposal'
import Chat from './views/Chat'
import 'react-toastify/dist/ReactToastify.min.css'
const App = () => {
const [loaded, setLoaded] = useState(false)
useEffect(() => {
loadWeb3().then((res) => {
if (res) setLoaded(true)
})
isUserLoggedIn()
}, [])
return (
<div className="min-h-screen bg-white text-gray-900 dark:bg-[#212936] dark:text-gray-300">
{loaded ? (
<Routes>
<Route path="/" element={<Home />} />
<Route path="proposal/:id" element={<Proposal />} />
<Route path="chat/:gid" element={<Chat />} />
</Routes>
) : null}
<ToastContainer
position="top-center"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
</div>
)
}
export default App
On the src >> directory paste the following codes in there respective files.
Index.jsx File
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App'
import { initCometChat } from './CometChat'
initCometChat().then(() => {
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
)
})
Index.css File
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap');
* html {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Open Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
CometChat.jsx
import Web3 from 'web3' | |
import { setGlobalState, getGlobalState } from './store' | |
import DominionDAO from './abis/DominionDAO.json' | |
const { ethereum } = window | |
const connectWallet = async () => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
const accounts = await ethereum.request({ method: 'eth_requestAccounts' }) | |
setGlobalState('connectedAccount', accounts[0]) | |
} catch (error) { | |
console.log(JSON.stringify(error)) | |
} | |
} | |
const raiseProposal = async ({ title, description, beneficiary, amount }) => { | |
try { | |
amount = window.web3.utils.toWei(amount.toString(), 'ether') | |
const contract = getGlobalState('contract') | |
const account = getGlobalState('connectedAccount') | |
let proposal = await contract.methods | |
.createProposal(title, description, beneficiary, amount) | |
.send({ from: account }) | |
return proposal | |
} catch (error) { | |
console.log(error.message) | |
return error | |
} | |
} | |
const performContribute = async (amount) => { | |
try { | |
amount = window.web3.utils.toWei(amount.toString(), 'ether') | |
const contract = getGlobalState('contract') | |
const account = getGlobalState('connectedAccount') | |
let balance = await contract.methods | |
.contribute() | |
.send({ from: account, value: amount }) | |
balance = window.web3.utils.fromWei( | |
balance.events.Action.returnValues.amount | |
) | |
return balance | |
} catch (error) { | |
console.log(error.message) | |
return error | |
} | |
} | |
const retrieveProposal = async (id) => { | |
const web3 = window.web3 | |
try { | |
const contract = getGlobalState('contract') | |
const proposal = await contract.methods.getProposal(id).call().wait() | |
return { | |
id: proposal.id, | |
amount: web3.utils.fromWei(proposal.amount), | |
title: proposal.title, | |
description: proposal.description, | |
paid: proposal.paid, | |
passed: proposal.passed, | |
proposer: proposal.proposer, | |
upvotes: Number(proposal.upvotes), | |
downvotes: Number(proposal.downvotes), | |
beneficiary: proposal.beneficiary, | |
executor: proposal.executor, | |
duration: proposal.duration, | |
} | |
} catch (error) { | |
console.log(error) | |
} | |
} | |
const reconstructProposal = (proposal) => { | |
return { | |
id: proposal.id, | |
amount: window.web3.utils.fromWei(proposal.amount), | |
title: proposal.title, | |
description: proposal.description, | |
paid: proposal.paid, | |
passed: proposal.passed, | |
proposer: proposal.proposer, | |
upvotes: Number(proposal.upvotes), | |
downvotes: Number(proposal.downvotes), | |
beneficiary: proposal.beneficiary, | |
executor: proposal.executor, | |
duration: proposal.duration, | |
} | |
} | |
const getProposal = async (id) => { | |
try { | |
const proposals = getGlobalState('proposals') | |
return proposals.find((proposal) => proposal.id == id) | |
} catch (error) { | |
console.log(error) | |
} | |
} | |
const voteOnProposal = async (proposalId, supported) => { | |
try { | |
const contract = getGlobalState('contract') | |
const account = getGlobalState('connectedAccount') | |
const vote = await contract.methods | |
.performVote(proposalId, supported) | |
.send({ from: account }) | |
return vote | |
} catch (error) { | |
console.log(error) | |
return error | |
} | |
} | |
const listVoters = async (id) => { | |
try { | |
const contract = getGlobalState('contract') | |
const votes = await contract.methods.getVotesOf(id).call() | |
return votes | |
} catch (error) { | |
console.log(error) | |
} | |
} | |
const payoutBeneficiary = async (id) => { | |
try { | |
const contract = getGlobalState('contract') | |
const account = getGlobalState('connectedAccount') | |
const balance = await contract.methods | |
.payBeneficiary(id) | |
.send({ from: account }) | |
return balance | |
} catch (error) { | |
return error | |
} | |
} | |
const loadWeb3 = async () => { | |
try { | |
if (!ethereum) return alert('Please install Metamask') | |
window.web3 = new Web3(ethereum) | |
await ethereum.request({ method: 'eth_requestAccounts' }) | |
window.web3 = new Web3(window.web3.currentProvider) | |
const web3 = window.web3 | |
const accounts = await web3.eth.getAccounts() | |
setGlobalState('connectedAccount', accounts[0]) | |
const networkId = await web3.eth.net.getId() | |
const networkData = DominionDAO.networks[networkId] | |
if (networkData) { | |
const contract = new web3.eth.Contract( | |
DominionDAO.abi, | |
networkData.address | |
) | |
const isStakeholder = await contract.methods | |
.isStakeholder() | |
.call({ from: accounts[0] }) | |
const proposals = await contract.methods.getProposals().call() | |
const balance = await contract.methods.daoBalance().call() | |
const mybalance = await contract.methods | |
.getBalance() | |
.call({ from: accounts[0] }) | |
setGlobalState('contract', contract) | |
setGlobalState('balance', web3.utils.fromWei(balance)) | |
setGlobalState('mybalance', web3.utils.fromWei(mybalance)) | |
setGlobalState('isStakeholder', isStakeholder) | |
setGlobalState('proposals', structuredProposals(proposals)) | |
} else { | |
window.alert('DominionDAO contract not deployed to detected network.') | |
} | |
return true | |
} catch (error) { | |
alert('Please connect your metamask wallet!') | |
console.log(error) | |
return false | |
} | |
} | |
const structuredProposals = (proposals) => { | |
const web3 = window.web3 | |
return proposals | |
.map((proposal) => ({ | |
id: proposal.id, | |
amount: web3.utils.fromWei(proposal.amount), | |
title: proposal.title, | |
description: proposal.description, | |
paid: proposal.paid, | |
passed: proposal.passed, | |
proposer: proposal.proposer, | |
upvotes: Number(proposal.upvotes), | |
downvotes: Number(proposal.downvotes), | |
beneficiary: proposal.beneficiary, | |
executor: proposal.executor, | |
duration: proposal.duration, | |
})) | |
.reverse() | |
} | |
export { | |
loadWeb3, | |
connectWallet, | |
performContribute, | |
raiseProposal, | |
retrieveProposal, | |
voteOnProposal, | |
getProposal, | |
listVoters, | |
payoutBeneficiary, | |
} |
Starting Up the Development Environment
STEP 1:
Spin up some test account with ganache-cli
using the command below:
ganache-cli -a
This will create some test accounts with 100 fake ethers loaded into each account, of course, these are for testing purposes only. See the image below:
STEP 2:
Add a local test network with Metamask as seen in the image below.
STEP 3:
Click on the account icon and select import account.
Copy about five of the private keys and add them one after the other to your local test network. See the image below.
Observe the new account added to your local test network with 100 ETH preloaded. Make sure you add about five accounts so you can do a maximum test. See the image below.
Smart Contract Deployment
Now open a new terminal and run the command below.
truffle migrate
# or
truffle migrate --network rinkeby
The above command will deploy your smart contract to your local or the Infuria rinkeby test network.
Next, open up another terminal and spin up the react app with yarn start
.
Watch my FREE web3 tutorials on Youtube now.
Conclusion
Hurray, we’ve just completed an amazing tutorial for developing a decentralized autonomous organization.
If you enjoyed this tutorial and would like to have me as your private mentor, kindly book your classes with me.
Till next time, all the best.
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 on his website.
Top comments (6)
bro nice project man really cool I just hove question actually two is there anyway I can make it that wen a user buys an nft he or she has the choice to either list the nft or keep it and is there anyway I can get percentage fee from sales like for example let's say you sell your nft just like as opensea takes %2.5 percentage is there anyway I can also do that.. thanks for your help in advance I will really like if e help me ❤️
Yes, that is totally implementable. ☺
how do i go about it i dm you on twitter check
I do have a private class and a consultancy service in the link below.
You can lear or ask me any questions there when you book a time with me.
Thanks
buymeacoffee.com/web3classes
That's awesome 😎 bro nice when I have the money I will really love 😘 to buy it but another thing you can also create a YouTube channel were you make videos like this you will go viral easily because you are very talented. Another thing bro I live in port Harcourt as you ❤️❤️
Thanks man, my YouTube videos are coming soon.