1. 区块链资讯

使用 Nextjs、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

欧易okx交易所下载

欧易交易所又称欧易OKX,是世界领先的数字资产交易所,主要面向全球用户提供比特币、莱特币、以太币等数字资产的现货和衍生品交易服务,通过使用区块链技术为全球交易者提供高级金融服务。

APP下载   官网注册


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

介绍

想象一个技术与民主交叉的世界,去中心化系统的力量与人民的声音相遇。这就是投票的未来,您可以帮助塑造它。

在本指南中,我们将教您如何使用 Next.js、TypeScript、Tailwind CSS 和 CometChat 构建自己的去中心化投票 dapp。这些尖端技术将使您能够创建一个任何人都可以使用的安全、用户友好且有吸引力的投票系统。

无论您是编码初学者还是经验丰富的开发人员,本指南都适合您。我们将首先解释去中心化投票的基础知识,然后引导您逐步完成构建自己的 dapp 的过程。

读完本指南后,您将具备创建可以改变世界的去中心化投票 dapp 所需的技能。

你将学到什么

  • 如何为 Dapp 开发设置开发工作区。
  • 如何使用 Next.js、TypeScript、Tailwind CSS 和 CometChat 构建去中心化投票 dapp
  • 如何使用智能合约逻辑保护您的 Dapp
  • 如何使用 Tailwind CSS 使您的 Dapp 用户友好
  • 如何使用 TypeScript 增强 Dapp 代码库
  • 如何将您的 Dapp 与 NextJs SSR 集成
  • 如何使用实时聊天吸引用户使用您的 Dapp

本指南的目标读者
本指南适用于任何想要学习如何构建去中心化投票 dapp 的人。无论您是编码初学者还是经验丰富的开发人员,您都会在本指南中找到有用的内容。

我们很高兴能帮助您构建投票的未来。让我们开始吧!

先决条件

您需要安装以下工具才能与我一起构建:

  • Node.js
  • Yarn
  • MetaMask
  • React
  • Solidity
  • CometChat SDK
  • Tailwind CSS


安装依赖项

克隆入门工具包并使用以下命令在 VS Code 中打开它:

git clone https://github.com/Daltonic/tailwind_ethers_starter_kit dappVotecd dappVote

接下来,package.json使用下面的代码片段更新。

{  "name": "starter_kit",  "description": "A Next.js starter that includes all you need to build amazing projects",  "version": "1.0.0",  "private": true,  "author": "darlington gospel<darlingtongospel@gmail.com>",  "license": "MIT",  "keywords": [    "nextjs",    "starter",    "typescript"  ],  "scripts": {    "dev": "next",    "build": "next build",    "start": "next start",    "export": "next build && next export",    "lint": "next lint",    "format": "prettier --ignore-path .gitignore "pages/**/*.+(ts|js|tsx)" --write",    "postinstall": "husky install"  },  "lint-staged": {    "./src/**/*.{ts,js,jsx,tsx}": [      "yarn lint --fix",      "yarn format"    ]  },  "dependencies": {    "@cometchat-pro/chat": "3.0.13",    "@headlessui/react": "1.7.17",    "@openzeppelin/test-helpers": "0.5.16",    "@reduxjs/toolkit": "1.9.3",    "ethers": "^5.4.7",    "next": "13.1.2",    "react": "18.2.0",    "react-dom": "18.2.0",    "react-icons": "4.8.0",    "react-identicons": "1.2.5",    "react-redux": "8.0.5",    "react-toastify": "9.1.2",    "sharp": "0.32.5"  },  "devDependencies": {    "@emotion/react": "11.10.5",    "@emotion/styled": "11.10.5",    "@ethersproject/abi": "^5.4.7",    "@ethersproject/providers": "^5.4.7",    "@faker-js/faker": "7.6.0",    "@nomicfoundation/hardhat-chai-matchers": "^1.0.0",    "@nomicfoundation/hardhat-network-helpers": "^1.0.0",    "@nomicfoundation/hardhat-toolbox": "^2.0.0",    "@nomiclabs/hardhat-ethers": "^2.0.0",    "@nomiclabs/hardhat-etherscan": "^3.0.0",    "@nomiclabs/hardhat-waffle": "2.0.3",    "@openzeppelin/contracts": "4.8.1",    "@typechain/ethers-v5": "^10.1.0",    "@typechain/hardhat": "^6.1.2",    "@types/node": "18.11.18",    "@types/react": "18.0.26",    "@types/react-dom": "18.0.10",    "@typescript-eslint/eslint-plugin": "5.48.1",    "@typescript-eslint/parser": "5.48.1",    "autoprefixer": "10.4.13",    "chai": "^4.2.0",    "dotenv": "16.0.3",    "eslint": "8.32.0",    "eslint-config-alloy": "4.9.0",    "eslint-config-next": "13.1.2",    "hardhat": "2.12.7",    "hardhat-gas-reporter": "^1.0.8",    "husky": "8.0.3",    "lint-staged": "13.1.0",    "postcss": "8.4.21",    "prettier": "2.8.3",    "solidity-coverage": "^0.8.0",    "tailwindcss": "3.2.4",    "typechain": "^8.1.0",    "typescript": "4.9.4"  }}

yarn install接下来,在终端中运行命令来安装该项目的依赖项。

配置 CometChat SDK

要配置CometChat SDK,请按照以下步骤操作。完成后,请确保将生成的密钥保存为环境变量以供将来使用。

第 1 步:
前往
CometChat Dashboard 并创建一个帐户。


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

第 2 步:注册后才能
登录
CometChat仪表板。


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

第 3 步:
从仪表板添加一个名为
Play-To-Earn的新应用程序。


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

步骤 4:
从列表中选择您刚刚创建的应用程序。


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp


从快速入门中将APP_ID、REGION、 和AUTH_KEY, 复制到您的.env文件中。请参阅图像和代码片段。


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

将占位符键替换NEXT_PUBLIC_COMET_CHAT为适当的值。

NEXT_PUBLIC_COMET_CHAT_APP_ID=****************NEXT_PUBLIC_COMET_CHAT_AUTH_KEY=******************************NEXT_PUBLIC_COMET_CHAT_REGION=**

该.env文件应创建在项目的根目录下。

配置 Hardhat 脚本

导航到项目的根目录并打开“ hardhat.config.js”文件。使用提供的设置替换文件的现有内容。

require('@nomiclabs/hardhat-waffle')require('dotenv').config()module.exports = {  defaultNetwork: 'localhost',  networks: {    localhost: {      url: 'http://127.0.0.1:8545',    },  },  solidity: {    version: '0.8.11',    settings: {      optimizer: {        enabled: true,        runs: 200,      },    },  },  paths: {    sources: './src/contracts',    artifacts: './src/abis',  },  mocha: {    timeout: 40000,  },}


此代码为您的项目配置 Hardhat。它通过导入必要的插件、设置网络(默认为 localhost)、指定 Solidity 编译器版本、定义合约和工件的路径以及设置 Mocha 测试的超时来实现这一点。

智能合约文件

接下来的部分概述了为 DappVotes 项目制作智能合约文件的过程。在深入研究以下步骤之前,请contracts在该项目的根目录下创建一个名为 的新文件夹,并在其中创建一个名为DappVotes.sol.

接下来,在刚刚创建的文件中执行以下操作:

  1. 首先启动一个名为 的新合同DappVotes,遵守 MIT 许可标准。
  2. 使用 OpenZeppelin 的 Counters 库来促进民意调查和参赛者计数器的管理。
  3. 定义两个 Counter 实例:totalPolls和totalContestants,私下跟踪民意调查和参赛者的总数。

这是智能合约逻辑,我们将了解每个函数和变量的作用。

//SPDX-License-Identifier: MITpragma solidity >=0.7.0 <0.9.0;import '@openzeppelin/contracts/utils/Counters.sol';contract DappVotes {  using Counters for Counters.Counter;  Counters.Counter private totalPolls;  Counters.Counter private totalContestants;  struct PollStruct {    uint id;    string image;    string title;    string description;    uint votes;    uint contestants;    bool deleted;    address director;    uint startsAt;    uint endsAt;    uint timestamp;    address[] voters;    string[] avatars;  }  struct ContestantStruct {    uint id;    string image;    string name;    address voter;    uint votes;    address[] voters;  }  mapping(uint => bool) pollExist;  mapping(uint => PollStruct) polls;  mapping(uint => mapping(address => bool)) voted;  mapping(uint => mapping(address => bool)) contested;  mapping(uint => mapping(uint => ContestantStruct)) contestants;  event Voted(address indexed voter, uint timestamp);  function createPoll(    string memory image,    string memory title,    string memory description,    uint startsAt,    uint endsAt  ) public {    require(bytes(title).length > 0, 'Title cannot be empty');    require(bytes(description).length > 0, 'Description cannot be empty');    require(bytes(image).length > 0, 'Image URL cannot be empty');    require(startsAt > 0, 'Start date must be greater than 0');    require(endsAt > startsAt, 'End date must be greater than start date');    totalPolls.increment();    PollStruct memory poll;    poll.id = totalPolls.current();    poll.title = title;    poll.description = description;    poll.image = image;    poll.startsAt = startsAt;    poll.endsAt = endsAt;    poll.director = msg.sender;    poll.timestamp = currentTime();    polls[poll.id] = poll;    pollExist[poll.id] = true;  }  function updatePoll(    uint id,    string memory image,    string memory title,    string memory description,    uint startsAt,    uint endsAt  ) public {    require(pollExist[id], 'Poll not found');    require(polls[id].director == msg.sender, 'Unauthorized entity');    require(bytes(title).length > 0, 'Title cannot be empty');    require(bytes(description).length > 0, 'Description cannot be empty');    require(bytes(image).length > 0, 'Image URL cannot be empty');    require(!polls[id].deleted, 'Polling already deleted');    require(polls[id].votes < 1, 'Poll has votes already');    require(endsAt > startsAt, 'End date must be greater than start date');    polls[id].title = title;    polls[id].description = description;    polls[id].startsAt = startsAt;    polls[id].endsAt = endsAt;    polls[id].image = image;  }  function deletePoll(uint id) public {    require(pollExist[id], 'Poll not found');    require(polls[id].director == msg.sender, 'Unauthorized entity');    require(polls[id].votes < 1, 'Poll has votes already');    polls[id].deleted = true;  }  function getPoll(uint id) public view returns (PollStruct memory) {    return polls[id];  }  function getPolls() public view returns (PollStruct[] memory Polls) {    uint available;    for (uint i = 1; i <= totalPolls.current(); i++) {        if(!polls[i].deleted) available++;    }    Polls = new PollStruct[](available);    uint index;    for (uint i = 1; i <= totalPolls.current(); i++) {        if(!polls[i].deleted) {            Polls[index++] = polls[i];        }    }  }  function contest(uint id, string memory name, string memory image) public {    require(pollExist[id], 'Poll not found');    require(bytes(name).length > 0, 'name cannot be empty');    require(bytes(image).length > 0, 'image cannot be empty');    require(polls[id].votes < 1, 'Poll has votes already');    require(!contested[id][msg.sender], 'Already contested');    totalContestants.increment();    ContestantStruct memory contestant;    contestant.name = name;    contestant.image = image;    contestant.voter = msg.sender;    contestant.id = totalContestants.current();    contestants[id][contestant.id] = contestant;    contested[id][msg.sender] = true;    polls[id].avatars.push(image);    polls[id].contestants++;  }  function getContestant(uint id, uint cid) public view returns (ContestantStruct memory) {    return contestants[id][cid];  }  function getContestants(uint id) public view returns (ContestantStruct[] memory Contestants) {    uint available;    for (uint i = 1; i <= totalContestants.current(); i++) {        if(contestants[id][i].id == i) available++;    }    Contestants = new ContestantStruct[](available);    uint index;    for (uint i = 1; i <= totalContestants.current(); i++) {        if(contestants[id][i].id == i) {            Contestants[index++] = contestants[id][i];        }    }  }  function vote(uint id, uint cid) public {    require(pollExist[id], 'Poll not found');    require(!voted[id][msg.sender], 'Already voted');    require(!polls[id].deleted, 'Polling not available');    require(polls[id].contestants > 1, 'Not enough contestants');    require(      currentTime() >= polls[id].startsAt && currentTime() < polls[id].endsAt,      'Voting must be in session'    );    polls[id].votes++;    polls[id].voters.push(msg.sender);    contestants[id][cid].votes++;    contestants[id][cid].voters.push(msg.sender);    voted[id][msg.sender] = true;    emit Voted(msg.sender, currentTime());  }  function currentTime() internal view returns (uint256) {    return (block.timestamp * 1000) + 1000;  }}

DappVotes 合约包含构成其功能的基本结构:

  1. PollStruct:封装投票详细信息的结构,包括 ID、图像 URL、标题、描述、投票数、参赛者数、删除状态、导演地址、开始和结束时间戳等。
  2. ContestantStruct:此结构保存有关参赛者的信息,例如他们的 ID、图像 URL、姓名、关联选民、投票计数和选民地址数组。

映射在管理合约数据方面发挥着至关重要的作用:

  1. pollExist:将轮询 ID 链接到指示其存在的布尔值的映射。
  2. polls:将轮询 ID 连接到各自PollStruct数据的映射,记录全面的轮询信息。
  3. voted:链接民意调查 ID 和选民地址的映射,以指示选民是否已投票。
  4. contested:连接投票ID和参赛者地址的映射,以指示参赛者是否参加过比赛。
  5. contestants:将投票 ID 和参赛者 ID 关联到各自ContestantStruct数据的嵌套映射,存储与参赛者相关的详细信息。

Voted为了促进用户交互和透明度,每当投票时合约都会发出事件,捕获投票者的地址和当前时间戳。

该合约包含各种功能,可以创建、管理和检索民意调查和参赛者数据:

  1. createPoll:此功能通过使用相关信息初始化 a 来帮助创建新的民意调查PollStruct,确保提供的数据有效。
  2. updatePoll:允许投票负责人更新其详细信息,确保授权和有效输入。
  3. deletePoll:在满足某些条件的情况下,投票主管可以将其标记为已删除。
  4. getPoll:使用特定投票的 ID 检索有关特定投票的详细信息。
  5. getPolls:返回活动民意调查的数组,不包括已删除的民意调查。
  6. contest:允许用户通过在合约中添加参赛者信息来参与投票。
  7. getContestant:检索投票中特定参赛者的详细信息。
  8. getContestants:返回特定投票的参赛者数组。
  9. vote:允许用户在投票中为参赛者投票,考虑资格、时间和条件。
  10. currentTime:一个内部实用函数,返回调整精度的当前时间戳。

通过执行这些步骤,您将为 DappVotes 智能合约建立一个功能结构,准备无缝管理民意调查、参赛者和投票交互。


测试脚本

DappVotes 测试脚本经过精心设计,旨在全面评估和验证 DappVotes 智能合约的功能和行为。以下是脚本中涵盖的主要测试和功能的系统细分:

  1. 测试设置:
  2. 在执行任何测试之前,脚本会准备必要的合约实例并设置用于测试目的的地址。
  3. 它初始化将在整个测试用例中使用的参数和变量。
  4. 合约已部署,并分配了多个签名者(地址)来模拟用户交互。
  5. 投票管理:
  6. 本节包含 DappVotes 智能合约中民意调查创建、更新和删除的测试。
  7. 在该Success类别下,一系列测试评估不同的场景,以确认这些轮询管理功能的成功执行。
  8. 该should confirm poll creation success测试通过检查创建前后的民意调查列表并确认创建的民意调查的属性来验证民意调查的创建。
  9. 该should confirm poll update success测试演示了民意调查属性的成功更新并验证了更改。
  10. 测试should confirm poll deletion success通过验证删除前后的民意调查列表,并确认民意调查的删除状态,确保民意调查的正确删除。
  11. 投票管理失败:
  12. 本节包含民意调查创建、更新和删除失败的负面测试场景。
  13. 下面的测试Failure确认,当尝试无效或未经授权的操作时,合约会正确恢复并显示适当的错误消息。
  14. 投票比赛:
  15. 在本节中,测试用例重点关注民意调查中的竞争,其中涉及作为参赛者参加。
  16. 下Success,should confirm contest entry success测试验证参赛者能否成功参与投票,并准确记录参赛者人数。它还检查参赛者信息的检索。
  17. 这些Failure测试解决了竞赛参赛失败的场景,例如尝试对不存在的民意调查进行竞赛或提交不完整的数据。
  18. 民意调查投票:
  19. 本节评估在投票中为特定参赛者投票的过程。
  20. 在 下Success,should confirm contest entry success测试展示了参赛者的成功投票,并验证了选票计数、选民地址和相关头像的准确性。
  21. 这些Failure测试解决了投票失败的情况,例如尝试在不存在的民意调查中投票或在已删除的民意调查中投票。

通过这种有组织且详细的分解,解释了 DappVotes 测试脚本的关键功能,说明了每个测试场景的目的和预期结果。测试脚本执行后,将全面验证 DappVotes 智能合约的行为。

在项目的根目录下,创建一个名为“ test ”的文件夹(如果不存在),将下面的代码复制并粘贴到其中。

const { expect } = require('chai')const { expectRevert } = require('@openzeppelin/test-helpers')describe('Contracts', () => {  let contract, result  const description = 'Lorem Ipsum'  const title = 'Republican Primary Election'  const image = 'https://image.png'  const starts = Date.now() - 10 * 60 * 1000  const ends = Date.now() + 10 * 60 * 1000  const pollId = 1  const contestantId = 1  const avater1 = 'https://avatar1.png'  const name1 = 'Nebu Ballon'  const avater2 = 'https://avatar2.png'  const name2 = 'Kad Neza'  beforeEach(async () => {    const Contract = await ethers.getContractFactory('DappVotes')    ;[deployer, contestant1, contestant2, voter1, voter2, voter3] = await ethers.getSigners()    contract = await Contract.deploy()    await contract.deployed()  })  describe('Poll Management', () => {    describe('Success', () => {      it('should confirm poll creation success', async () => {        result = await contract.getPolls()        expect(result).to.have.lengthOf(0)        await contract.createPoll(image, title, description, starts, ends)        result = await contract.getPolls()        expect(result).to.have.lengthOf(1)        result = await contract.getPoll(pollId)        expect(result.title).to.be.equal(title)        expect(result.director).to.be.equal(deployer.address)      })      it('should confirm poll update success', async () => {        await contract.createPoll(image, title, description, starts, ends)        result = await contract.getPoll(pollId)        expect(result.title).to.be.equal(title)        await contract.updatePoll(pollId, image, 'New Title', description, starts, ends)        result = await contract.getPoll(pollId)        expect(result.title).to.be.equal('New Title')      })      it('should confirm poll deletion success', async () => {        await contract.createPoll(image, title, description, starts, ends)        result = await contract.getPolls()        expect(result).to.have.lengthOf(1)        result = await contract.getPoll(pollId)        expect(result.deleted).to.be.equal(false)        await contract.deletePoll(pollId)        result = await contract.getPolls()        expect(result).to.have.lengthOf(0)        result = await contract.getPoll(pollId)        expect(result.deleted).to.be.equal(true)      })    })    describe('Failure', () => {      it('should confirm poll creation failures', async () => {        await expectRevert(          contract.createPoll('', title, description, starts, ends),          'Image URL cannot be empty'        )        await expectRevert(          contract.createPoll(image, title, description, 0, ends),          'Start date must be greater than 0'        )      })      it('should confirm poll update failures', async () => {        await expectRevert(          contract.updatePoll(100, image, 'New Title', description, starts, ends),          'Poll not found'        )      })      it('should confirm poll deletion failures', async () => {        await expectRevert(contract.deletePoll(100), 'Poll not found')      })    })  })  describe('Poll Contest', () => {    beforeEach(async () => {      await contract.createPoll(image, title, description, starts, ends)    })    describe('Success', () => {      it('should confirm contest entry success', async () => {        result = await contract.getPoll(pollId)        expect(result.contestants.toNumber()).to.be.equal(0)        await contract.connect(contestant1).contest(pollId, name1, avater1)        await contract.connect(contestant2).contest(pollId, name2, avater2)        result = await contract.getPoll(pollId)        expect(result.contestants.toNumber()).to.be.equal(2)        result = await contract.getContestants(pollId)        expect(result).to.have.lengthOf(2)      })    })    describe('Failure', () => {      it('should confirm contest entry failure', async () => {        await expectRevert(contract.contest(100, name1, avater1), 'Poll not found')        await expectRevert(contract.contest(pollId, '', avater1), 'name cannot be empty')        await contract.connect(contestant1).contest(pollId, name1, avater1)        await expectRevert(          contract.connect(contestant1).contest(pollId, name1, avater1),          'Already contested'        )      })    })  })  describe('Poll Voting', () => {    beforeEach(async () => {      await contract.createPoll(image, title, description, starts, ends)      await contract.connect(contestant1).contest(pollId, name1, avater1)      await contract.connect(contestant2).contest(pollId, name2, avater2)    })    describe('Success', () => {      it('should confirm contest entry success', async () => {        result = await contract.getPoll(pollId)        expect(result.votes.toNumber()).to.be.equal(0)        await contract.connect(contestant1).vote(pollId, contestantId)        await contract.connect(contestant2).vote(pollId, contestantId)        result = await contract.getPoll(pollId)        expect(result.votes.toNumber()).to.be.equal(2)        expect(result.voters).to.have.lengthOf(2)        expect(result.avatars).to.have.lengthOf(2)        result = await contract.getContestants(pollId)        expect(result).to.have.lengthOf(2)        result = await contract.getContestant(pollId, contestantId)        expect(result.voters).to.have.lengthOf(2)        expect(result.voter).to.be.equal(contestant1.address)      })    })    describe('Failure', () => {      it('should confirm contest entry failure', async () => {        await expectRevert(contract.vote(100, contestantId), 'Poll not found')        await contract.deletePoll(pollId)        await expectRevert(contract.vote(pollId, contestantId), 'Polling not available')      })    })  })})


运行以下命令来运行本地区块链服务器并测试智能合约:

  • yarn hardhat node
  • yarn hardhat test

通过在两个单独的终端上运行这些命令,您将测试该智能合约的所有基本功能。

部署脚本

DappVotes 部署脚本旨在使用 Hardhat 开发环境将 DappVotes 智能合约部署到以太坊网络。以下是该脚本的概述:

  1. 进口声明:
  2. 该脚本导入所需的依赖项,包括ethers以太坊交互和fs文件系统操作。
  3. 主功能:
  4. 该main()函数充当部署脚本的入口点,并被定义为异步函数。
  5. 部署参数:
  6. 脚本指定contract_name参数,该参数代表要部署的智能合约的名称。
  7. 合约部署:
  8. 该脚本使用该ethers.getContractFactory()方法来获取指定的合同工厂contract_name。
  9. deploy()它通过调用合约工厂上的方法来部署合约,该方法返回合约实例。
  10. 部署的合约实例存储在contract变量中。
  11. 合约部署确认:
  12. deployed()该脚本通过等待合约实例上的函数来等待部署得到确认。
  13. 将合约地址写入文件:
  14. 该脚本生成一个包含已部署合约地址的 JSON 对象。
  15. contractAddress.json它将这个 JSON 对象写入到目录中指定的文件中artifacts,如果该目录不存在,则创建该目录。
  16. 文件写入过程中的任何错误都会被捕获并记录。
  17. 记录部署的合约地址:
  18. 如果合约部署和文件写入过程成功,部署的合约地址将记录到控制台。
  19. 错误处理:
  20. 部署过程或文件写入期间发生的任何错误都会被捕获并记录到控制台。
  21. 进程退出代码设置为 1 以指示发生错误。

此 DappVotes 部署脚本简化了部署智能合约的过程,并生成一个包含已部署合约地址的 JSON 文件,以便在项目中进一步使用。

在项目的根目录中,创建一个名为“ scripts ”的文件夹,并在其中创建另一个文件deploy.js(如果尚不存在)。将下面的代码复制并粘贴到其中。

const { ethers } = require('hardhat')const fs = require('fs')async function main() {  const contract_name = 'DappVotes'  const Contract = await ethers.getContractFactory(contract_name)  const contract = await Contract.deploy()  await contract.deployed()  const address = JSON.stringify({ address: contract.address }, null, 4)  fs.writeFile('./artifacts/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})

要执行脚本,请yarn hardhat run scripts/deploy.js在终端中运行,确保您的区块链节点已在另一个终端中运行。


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

开发前端

要开始开发应用程序的前端,我们将components在该项目的根目录下创建一个名为的新文件夹。该文件夹将包含我们项目所需的所有组件。

对于下面列出的每个组件,您需要在components文件夹内创建相应的文件并将其代码粘贴到其中。

导航栏组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该Navbar组件提供导航和钱包连接。它显示一个标题为“DappVotes”的导航栏和一个连接钱包的按钮。该按钮在悬停时会更改外观。该组件使用 Redux 和区块链服务进行钱包连接和状态管理。请遵守以下代码:

import { connectWallet, truncate } from '@/services/blockchain'import { RootState } from '@/utils/types'import Link from 'next/link'import React from 'react'import { useSelector } from 'react-redux'const Navbar = () => {  const { wallet } = useSelector((states: RootState) => states.globalStates)  return (    <nav      className="h-[80px] flex justify-between items-center border border-gray-400       px-5 rounded-full"    >      <Link href="/" className="text-[20px] text-blue-800 sm:text-[24px]">        Dapp<span className="text-white font-bold">Votes</span>      </Link>      {wallet ? (        <button          className="h-[48px] w-[130px]           sm:w-[148px] px-3 rounded-full text-sm font-bold          transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"        >          {truncate({ text: wallet, startChars: 4, endChars: 4, maxLength: 11 })}        </button>      ) : (        <button          className="h-[48px] w-[130px]           sm:w-[148px] px-3 rounded-full text-sm font-bold          transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"          onClick={connectWallet}        >          Connect wallet        </button>      )}    </nav>  )}export default Navbar


横幅组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该Banner组件是一个 React 组件,它显示一个带有主标题和描述的居中横幅。标题强调了“无操纵投票”的概念。该描述解释了选美比赛的性质。

描述下方有一个标有“创建投票”的按钮。单击此按钮后,如果连接了钱包,则会触发打开创建投票模式的操作。如果未连接钱包,则会显示警告消息,提醒用户连接钱包。该组件使用 Redux 进行状态管理,并使用 React Toastify 来显示通知。请参阅下面的代码:

import { globalActions } from '@/store/globalSlices'import { RootState } from '@/utils/types'import React from 'react'import { useDispatch, useSelector } from 'react-redux'import { toast } from 'react-toastify'const Banner = () => {  const dispatch = useDispatch()  const { setCreateModal } = globalActions  const { wallet } = useSelector((states: RootState) => states.globalStates)  const onPressCreate = () => {    if (wallet === '') return toast.warning('Connect wallet first!')    dispatch(setCreateModal('scale-100'))  }  return (    <main className="mx-auto text-center space-y-8">      <h1 className="text-[45px] font-[600px] text-center leading-none">Vote Without Rigging</h1>      <p className="text-[16px] font-[500px] text-center">        A beauty pageantry is a competition that has traditionally focused on judging and ranking        the physical...      </p>      <button        className="text-black h-[45px] w-[148px] rounded-full transition-all duration-300        border border-gray-400 bg-white hover:bg-opacity-20 hover:text-white"        onClick={onPressCreate}      >        Create poll      </button>    </main>  )}export default Banner


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该CreatePoll组件为用户提供了创建民意调查的模式表单。它收集民意调查详细信息的输入,例如标题、开始和结束日期、横幅 URL 和描述。提交后,它会验证、转换数据、用动画显示创建状态并处理错误。用户友好的界面有助于在应用程序内创建民意调查。请参阅下面的代码:

import { createPoll } from '@/services/blockchain'import { globalActions } from '@/store/globalSlices'import { PollParams, RootState } from '@/utils/types'import React, { ChangeEvent, FormEvent, useState } from 'react'import { FaTimes } from 'react-icons/fa'import { useDispatch, useSelector } from 'react-redux'import { toast } from 'react-toastify'const CreatePoll: React.FC = () => {  const dispatch = useDispatch()  const { setCreateModal } = globalActions  const { wallet, createModal } = useSelector((states: RootState) => states.globalStates)  const [poll, setPoll] = useState<PollParams>({    image: '',    title: '',    description: '',    startsAt: '',    endsAt: '',  })  const handleSubmit = async (e: FormEvent) => {    e.preventDefault()    if (!poll.image || !poll.title || !poll.description || !poll.startsAt || !poll.endsAt) return    if (wallet === '') return toast.warning('Connect wallet first!')    poll.startsAt = new Date(poll.startsAt).getTime()    poll.endsAt = new Date(poll.endsAt).getTime()    await toast.promise(      new Promise<void>((resolve, reject) => {        createPoll(poll)          .then((tx) => {            closeModal()            console.log(tx)            resolve(tx)          })          .catch((error) => reject(error))      }),      {        pending: 'Approve transaction...',        success: 'Poll created successfully ',        error: 'Encountered error ',      }    )  }  const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {    const { name, value } = e.target    setPoll((prevState) => ({      ...prevState,      [name]: value,    }))  }  const closeModal = () => {    dispatch(setCreateModal('scale-0'))    setPoll({      image: '',      title: '',      description: '',      startsAt: '',      endsAt: '',    })  }  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-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] 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">Add Poll</p>            <button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">              <FaTimes />            </button>          </div>          <form            onSubmit={handleSubmit}            className="flex flex-col justify-center items-start rounded-xl mt-5 mb-5"          >            <div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">              <input                placeholder="Poll Title"                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="title"                value={poll.title}                onChange={handleChange}                required              />            </div>            <div              className="py-4 w-full border border-[#212D4A] rounded-full              flex items-center px-4 mb-3 mt-2 space-x-2 relative"            >              <span                className="bg-[#1B5CFE] bg-opacity-20 text-[#4C6AD7]                absolute left-[2.5px] py-3 rounded-full px-5 w-48"              >                <span className="text-transparent">.</span>              </span>              <input                className="bg-transparent outline-none w-full placeholder-transparent text-sm"                name="startsAt"                type="datetime-local"                placeholder="Start Date"                value={poll.startsAt}                onChange={handleChange}                required              />            </div>            <div              className="py-4 w-full border border-[#212D4A] rounded-full              flex items-center px-4 mb-3 mt-2 space-x-2 relative"            >              <span                className="bg-[#1B5CFE] bg-opacity-20 text-[#4C6AD7]                absolute left-[2.5px] py-3 rounded-full px-5 w-48"              >                <span className="text-transparent">.</span>              </span>              <input                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="endsAt"                type="datetime-local"                value={poll.endsAt}                onChange={handleChange}                required              />            </div>            <div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">              <input                placeholder="Banner URL"                type="url"                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="image"                accept="image/*"                value={poll.image}                onChange={handleChange}                required              />            </div>            <div className="py-4 w-full border border-[#212D4A] rounded-xl flex items-center px-4 h-20 mt-2">              <textarea                placeholder="Poll Description"                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="description"                value={poll.description}                onChange={handleChange}                required              />            </div>            <button              className="h-[48px] w-full block mt-2 px-3 rounded-full text-sm font-bold              transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"            >              Create Poll            </button>          </form>        </div>      </div>    </div>  )}export default CreatePoll

投票组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该Polls组件以网格布局显示民意调查列表。每个民意调查都包括标题、简短描述、开始日期、民意调查主管地址和“输入”按钮。投票头像显示在信息旁边。单击“输入”按钮会将用户重定向到投票的详细页面。该组件还使用辅助函数来格式化和截断文本。该Polls组件将一组PollStruct对象作为 prop,并通过它们进行映射以创建Poll具有相关轮询数据的单独组件。请参阅下面的代码:

/* eslint-disable @next/next/no-img-element */import { formatDate, truncate } from '@/services/blockchain'import { PollStruct } from '@/utils/types'import { useRouter } from 'next/router'import React from 'react'const Polls: React.FC<{ polls: PollStruct[] }> = ({ polls }) => {  return (    <div>      <h1 className="text-center text-[34px] font-[550px] mb-5">Start Voting</h1>      <div className="grid grid-cols-1 xl:grid-cols-2 pb-7 gap-[62px] sm:w-2/3 xl:w-5/6 mx-auto">        {polls.map((poll, i) => (          <Poll key={i} poll={poll} />        ))}      </div>    </div>  )}const Poll: React.FC<{ poll: PollStruct }> = ({ poll }) => {  const navigate = useRouter()  return (    <div className="grid grid-cols-1 md:grid-cols-2 mx-auto w-full">      <div        className="h-[392px] gap-[10px] md:w-[580px] md:h-[280px]            grid grid-cols-1 md:flex justify-start w-full"      >        <div className="w-full flex justify-between space-y-0 sm:space-y-2 sm:flex-col md:w-[217px]">          {[...poll.avatars, '/assets/images/question.jpeg', '/assets/images/question.jpeg']            .slice(0, 2)            .map((avatar, i) => (              <img                key={i}                src={avatar}                alt={poll.title}                className="w-[160px] md:w-full                h-[135px] rounded-[20px] object-cover"              />            ))}        </div>        <div          className="w-full h-[257px] gap-[14px] rounded-[24px] space-y-5                md:w-[352px] md:h-[280px] bg-[#151515] px-[15px] py-[18px] md:px-[22px]"        >          <h1 className="text-[18px] font-[600px] capitalize">            {truncate({ text: poll.title, startChars: 30, endChars: 0, maxLength: 33 })}          </h1>          <p className="text-[14px] font-[400px]">            {truncate({ text: poll.description, startChars: 104, endChars: 0, maxLength: 107 })}          </p>          <div className="flex justify-between items-center gap-[8px]">            <div              className="h-[26px] bg-[#2c2c2c] rounded-full py-[4px] px-[12px]                text-[12px] font-[400px]"            >              {formatDate(poll.startsAt)}            </div>            <div className="h-[32px] w-[119px] gap-[5px] flex items-center">              <div className="h-[32px] w-[32px] rounded-full bg-[#2c2c2c]" />              <p className="text-[12px] font-[400px]">                {truncate({ text: poll.director, startChars: 4, endChars: 4, maxLength: 11 })}              </p>            </div>          </div>          <button            onClick={() => navigate.push('/polls/' + poll.id)}            className="h-[44px] w-full rounded-full transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"          >            Enter          </button>        </div>      </div>    </div>  )}export default Polls

页脚组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该Footer组件显示网页的社交媒体图标和版权信息。这些图标链接到 LinkedIn、YouTube、GitHub 和 Twitter 个人资料。版权文本显示当前年份和消息“With Love ❤️ by Daltonic”。该组件的布局响应灵敏且具有视觉吸引力。

import React from 'react'import { FaGithub, FaLinkedinIn, FaTwitter, FaYoutube } from 'react-icons/fa'const Footer = () => {  return (    <footer      className="w-full h-[192px] py-[37px]      rounded-t-[24px] flex flex-col items-center justify-center      bg-white bg-opacity-20 px-5"    >      <div className="flex justify-center items-center space-x-4">        <FaLinkedinIn size={27} />        <FaYoutube size={27} />        <FaGithub size={27} />        <FaTwitter size={27} />      </div>      <hr className="w-full sm:w-[450px] border-t border-gray-400 mt-3" />      <p className="text-sm font-[500px] mt-5">©️{new Date().getFullYear()}</p>      <p className="text-sm font-[500px]">With Love ❤️ by Daltonic</p>    </footer>  )}export default Footer

详细信息 组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该Details组件显示有关投票的详细信息,包括投票图像、标题、描述、开始和结束日期、主管、投票和参赛者计数以及编辑和删除按钮(如果用户是主管并且没有投票)。如果没有投票,它还会显示“竞赛”按钮,这将打开竞赛模式。该组件使用 Redux 进行全局状态管理,使用ImageNext.js 的组件进行响应式图像。见下图:

import { formatDate, truncate } from '@/services/blockchain'import { globalActions } from '@/store/globalSlices'import { PollStruct, RootState } from '@/utils/types'import Image from 'next/image'import React from 'react'import { MdModeEdit, MdDelete } from 'react-icons/md'import { useDispatch, useSelector } from 'react-redux'import { toast } from 'react-toastify'const Details: React.FC<{ poll: PollStruct }> = ({ poll }) => {  const dispatch = useDispatch()  const { setContestModal, setUpdateModal, setDeleteModal } = globalActions  const { wallet } = useSelector((states: RootState) => states.globalStates)  const onPressContest = () => {    if (wallet === '') return toast.warning('Connect wallet first!')    dispatch(setContestModal('scale-100'))  }  return (    <>      <div        className="w-full h-[240px] rounded-[24px]        flex items-center justify-center overflow-hidden"      >        <Image          className="w-full h-full object-cover"          width={3000}          height={500}          src={poll.image}          alt={poll.title}        />      </div>      <div        className="flex flex-col items-center justify-center space-y-6        mt-5 w-full md:max-w-[736px] mx-auto"      >        <h1 className="text-[47px] font-[600px] text-center leading-none">{poll.title}</h1>        <p className="text-[16px] font-[500px] text-center">{poll.description}</p>        <div className=" h-[136px] gap-[16px] flex flex-col items-center mt-4">          <div            className="h-[36px] py-[6px] px-[12px] rounded-full gap-[4px] border             border-gray-400 bg-white bg-opacity-20"          >            <p className="text-[14px] font-[500px] text-center md:text-[16px]">              {formatDate(poll.startsAt)} - {formatDate(poll.endsAt)}            </p>          </div>          <div            className="flex items-center justify-center w-[133px] h-[32px]                 py-[20px] rounded-[10px] gap-[12px]"          >            <div className="w-[32px] h-[32px] rounded-full bg-[#1B5CFE]" />            <p className="text-[14px] font-[500px]">              {truncate({ text: poll.director, startChars: 4, endChars: 4, maxLength: 11 })}            </p>          </div>          <div className="h-[36px] gap-[4px] flex justify-center items-center">            <button              className="py-[6px] px-[12px] border border-gray-400 bg-white bg-opacity-20              rounded-full text-[12px] md:text-[16px]"            >              {poll.votes} votes            </button>            <button              className="py-[6px] px-[12px]               border border-gray-400 bg-white bg-opacity-20 rounded-full text-[12px] md:text-[16px]"            >              {poll.contestants} contestants            </button>            {wallet && wallet === poll.director && poll.votes < 1 && (              <button                className="py-[6px] px-[12px]               border border-gray-400 bg-white bg-opacity-20 rounded-full               text-[12px] md:text-[16px] gap-[8px] flex justify-center items-center"                onClick={() => dispatch(setUpdateModal('scale-100'))}              >                <MdModeEdit size={20} className="text-[#1B5CFE]" />                Edit poll              </button>            )}            {wallet && wallet === poll.director && poll.votes < 1 && (              <button                className="py-[6px] px-[12px]               border border-gray-400 bg-white bg-opacity-20 rounded-full               text-[12px] md:text-[16px] gap-[8px] flex justify-center items-center"                onClick={() => dispatch(setDeleteModal('scale-100'))}              >                <MdDelete size={20} className="text-[#fe1b1b]" />                Delete poll              </button>            )}          </div>          {poll.votes < 1 && (            <button              className="text-black h-[45px] w-[148px] rounded-full transition-all duration-300              border border-gray-400 bg-white hover:bg-opacity-20 hover:text-white py-2"              onClick={onPressContest}            >              Contest            </button>          )}        </div>      </div>    </>  )}export default Details

更新投票组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该UpdatePoll组件提供了一个模式表单来编辑现有民意调查的详细信息。用户可以修改投票的图像、标题、描述、开始日期和结束日期。该组件获取并显示所选轮询的当前数据。

提交后,它会验证输入,更新区块链上的民意调查信息,并通过 Toast 通知提供交易状态反馈。该组件有效地与区块链服务、Redux 状态和用户输入交互,以促进轮询更新。请参阅下面的代码:

import { formatTimestamp, updatePoll } from '@/services/blockchain'import { globalActions } from '@/store/globalSlices'import { PollParams, PollStruct, RootState } from '@/utils/types'import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react'import { FaTimes } from 'react-icons/fa'import { useDispatch, useSelector } from 'react-redux'import { toast } from 'react-toastify'const UpdatePoll: React.FC<{ pollData: PollStruct }> = ({ pollData }) => {  const dispatch = useDispatch()  const { setUpdateModal } = globalActions  const { wallet, updateModal } = useSelector((states: RootState) => states.globalStates)  const [poll, setPoll] = useState<PollParams>({    image: '',    title: '',    description: '',    startsAt: '',    endsAt: '',  })  useEffect(() => {    if (pollData) {      const { image, title, description, startsAt, endsAt } = pollData      setPoll({        image,        title,        description,        startsAt: formatTimestamp(startsAt),        endsAt: formatTimestamp(endsAt),      })    }  }, [pollData])  const handleUpdate = async (e: FormEvent) => {    e.preventDefault()    if (!poll.image || !poll.title || !poll.description || !poll.startsAt || !poll.endsAt) return    if (wallet === '') return toast.warning('Connect wallet first!')    poll.startsAt = new Date(poll.startsAt).getTime()    poll.endsAt = new Date(poll.endsAt).getTime()    await toast.promise(      new Promise<void>((resolve, reject) => {        updatePoll(pollData.id, poll)          .then((tx) => {            closeModal()            console.log(tx)            resolve(tx)          })          .catch((error) => reject(error))      }),      {        pending: 'Approve transaction...',        success: 'Poll updated successfully ',        error: 'Encountered error ',      }    )  }  const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {    const { name, value } = e.target    setPoll((prevState) => ({      ...prevState,      [name]: value,    }))  }  const closeModal = () => {    dispatch(setUpdateModal('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 ${updateModal}`}    >      <div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] 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">Edit Poll</p>            <button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">              <FaTimes />            </button>          </div>          <form            onSubmit={handleUpdate}            className="flex flex-col justify-center items-start rounded-xl mt-5 mb-5"          >            <div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">              <input                placeholder="Poll Title"                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="title"                value={poll.title}                onChange={handleChange}                required              />            </div>            <div              className="py-4 w-full border border-[#212D4A] rounded-full              flex items-center px-4 mb-3 mt-2 space-x-2 relative"            >              <span                className="bg-[#1B5CFE] bg-opacity-20 text-[#4C6AD7]                absolute left-[2.5px] py-3 rounded-full px-5 w-48"              >                <span className="text-transparent">.</span>              </span>              <input                className="bg-transparent outline-none w-full placeholder-transparent text-sm"                name="startsAt"                type="datetime-local"                placeholder="Start Date"                value={poll.startsAt}                onChange={handleChange}                required              />            </div>            <div              className="py-4 w-full border border-[#212D4A] rounded-full              flex items-center px-4 mb-3 mt-2 space-x-2 relative"            >              <span                className="bg-[#1B5CFE] bg-opacity-20 text-[#4C6AD7]                absolute left-[2.5px] py-3 rounded-full px-5 w-48"              >                <span className="text-transparent">.</span>              </span>              <input                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="endsAt"                type="datetime-local"                value={poll.endsAt}                onChange={handleChange}                required              />            </div>            <div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">              <input                placeholder="Banner URL"                type="url"                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="image"                accept="image/*"                value={poll.image}                onChange={handleChange}                required              />            </div>            <div className="py-4 w-full border border-[#212D4A] rounded-xl flex items-center px-4 h-20 mt-2">              <textarea                placeholder="Poll Description"                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="description"                value={poll.description}                onChange={handleChange}                required              />            </div>            <button              className="h-[48px] w-full block mt-2 px-3 rounded-full text-sm font-bold              transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"            >              Update Poll            </button>          </form>        </div>      </div>    </div>  )}export default UpdatePoll

删除投票组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该DeletePoll组件提供了删除特定民意调查的确认模式。当用户单击删除按钮时,该组件与区块链服务交互以删除所选民意调查的数据。它利用 Redux 状态进行用户身份验证和模式状态管理。

删除投票后,该组件将用户重定向到主页,并通过 Toast 通知提供交易状态反馈。该组件有效地处理民意调查删除、与区块链服务交互、管理模式显示并提供用户通知。请参阅下面的代码:

import { deletePoll } from '@/services/blockchain'import { globalActions } from '@/store/globalSlices'import { PollStruct, RootState } from '@/utils/types'import { BsTrash3Fill } from 'react-icons/bs'import React from 'react'import { FaTimes } from 'react-icons/fa'import { useDispatch, useSelector } from 'react-redux'import { toast } from 'react-toastify'import { useRouter } from 'next/router'const DeletePoll: React.FC<{ poll: PollStruct }> = ({ poll }) => {  const dispatch = useDispatch()  const { setDeleteModal } = globalActions  const { wallet, deleteModal } = useSelector((states: RootState) => states.globalStates)  const router = useRouter()  const handleDelete = async () => {    if (wallet === '') return toast.warning('Connect wallet first!')    await toast.promise(      new Promise<void>((resolve, reject) => {        deletePoll(poll.id)          .then((tx) => {            closeModal()            console.log(tx)            router.push('/')            resolve(tx)          })          .catch((error) => reject(error))      }),      {        pending: 'Approve transaction...',        success: 'Poll deleted successfully ',        error: 'Encountered error ',      }    )  }  const closeModal = () => {    dispatch(setDeleteModal('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 ${deleteModal}`}    >      <div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] rounded-xl w-11/12 md:w-2/5 h-7/12 p-6">        <div className="flex flex-col">          <div className="flex flex-row justify-between items-center">            <p className="font-semibold">Delete Poll</p>            <button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">              <FaTimes />            </button>          </div>          <div className="flex flex-col justify-center items-center rounded-xl mt-5 mb-5">            <div className="flex flex-col justify-center items-center rounded-xl my-5 space-y-2">              <BsTrash3Fill className="text-red-600" size={50} />              <h4 className="text-[22.65px]">Delete Poll</h4>              <p className="text-[14px]">Are you sure you want to delete this question?</p>              <small className="text-xs italic">{poll?.title}</small>            </div>            <button              className="h-[48px] w-full block mt-2 px-3 rounded-full text-sm font-bold              transition-all duration-300 bg-red-600 hover:bg-red-500"              onClick={handleDelete}            >              Delete Poll            </button>          </div>        </div>      </div>    </div>  )}export default DeletePoll

该ContestPoll组件显示一个模式表单,供用户参加特定民意调查的竞赛。用户输入参赛者姓名和头像 URL。提交后,它会验证输入,启动竞赛交易,并用动画显示交易状态。该组件与应用程序的区块链服务和 Redux 状态管理交互,提供无缝的竞赛输入体验。请参阅下面的代码:

import { contestPoll } from '@/services/blockchain'import { globalActions } from '@/store/globalSlices'import { PollStruct, RootState } from '@/utils/types'import React, { ChangeEvent, FormEvent, useState } from 'react'import { FaTimes } from 'react-icons/fa'import { useDispatch, useSelector } from 'react-redux'import { toast } from 'react-toastify'const ContestPoll: React.FC<{ poll: PollStruct }> = ({ poll }) => {  const dispatch = useDispatch()  const { setContestModal } = globalActions  const { wallet, contestModal } = useSelector((states: RootState) => states.globalStates)  const [contestant, setContestant] = useState({    name: '',    image: '',  })  const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {    const { name, value } = e.target    setContestant((prevState) => ({      ...prevState,      [name]: value,    }))  }  const handleSubmit = async (e: FormEvent) => {    e.preventDefault()    if (!contestant.name || !contestant.image) return    if (wallet === '') return toast.warning('Connect wallet first!')    await toast.promise(      new Promise<void>((resolve, reject) => {        contestPoll(poll.id, contestant.name, contestant.image)          .then((tx) => {            closeModal()            console.log(tx)            resolve(tx)          })          .catch((error) => reject(error))      }),      {        pending: 'Approve transaction...',        success: 'Poll contested successfully ',        error: 'Encountered error ',      }    )  }  const closeModal = () => {    dispatch(setContestModal('scale-0'))    setContestant({      name: '',      image: '',    })  }  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 ${contestModal}`}    >      <div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] 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">Become a Contestant</p>            <button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">              <FaTimes />            </button>          </div>          <form            onClick={handleSubmit}            className="flex flex-col justify-center items-start rounded-xl mt-5 mb-5"          >            <div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">              <input                placeholder="Contestant Name"                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="name"                value={contestant.name}                onChange={handleChange}                required              />            </div>            <div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">              <input                placeholder="Avater URL"                type="url"                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="image"                accept="image/*"                value={contestant.image}                onChange={handleChange}                required              />            </div>            <button              className="h-[48px] w-full block mt-2 px-3 rounded-full text-sm font-bold                transition-all duration-300 bg-[#1B5CFE] hover:bg-blue-500"            >              Contest Now            </button>          </form>        </div>      </div>    </div>  )}export default ContestPoll


参赛者组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该Contestants组件呈现投票的参赛者列表,包括他们的图像、姓名、选民信息、投票按钮和投票计数。它使用contestants数组和poll对象来呈现每个参赛者的信息。

每个Contestant子组件代表一个单独的参赛者,包括以下内容:

  • 图片:参赛者的形象。
  • 姓名:参赛者的姓名。
  • 选民信息:为参赛者投票的选民钱包地址的截断版本。
  • 投票按钮:为参赛者投票的按钮。如果用户已经投票、投票尚未开始或投票已结束,则该按钮将被禁用。
  • 得票数:参赛者获得的票数。

该组件还包括错误处理和视觉反馈,以防止在特定情况下投票。该组件允许用户查看并参与投票中参赛者的投票过程。请参阅下面的代码:

import { truncate, voteCandidate } from '@/services/blockchain'import { ContestantStruct, PollStruct, RootState } from '@/utils/types'import Image from 'next/image'import React from 'react'import { BiUpvote } from 'react-icons/bi'import { useSelector } from 'react-redux'import { toast } from 'react-toastify'const Contestants: React.FC<{ contestants: ContestantStruct[]; poll: PollStruct }> = ({  contestants,  poll,}) => {  return (    <div className="space-y-2">      <h1 className="text-center text-[48px] font-[600px]">Contestants</h1>      <div className="grid grid-cols-1 xl:grid-cols-2 pb-7 gap-[62px] sm:w-2/3 xl:w-11/12 mx-auto">        {contestants.map((contestant, i) => (          <Contestant poll={poll} contestant={contestant} key={i} />        ))}      </div>    </div>  )}const Contestant: React.FC<{ contestant: ContestantStruct; poll: PollStruct }> = ({  contestant,  poll,}) => {  const { wallet } = useSelector((states: RootState) => states.globalStates)  const voteContestant = async () => {    if (wallet === '') return toast.warning('Connect wallet first!')    await toast.promise(      new Promise<void>((resolve, reject) => {        voteCandidate(poll.id, contestant.id)          .then((tx) => {            console.log(tx)            resolve(tx)          })          .catch((error) => reject(error))      }),      {        pending: 'Approve transaction...',        success: 'Poll contested successfully ',        error: 'Encountered error ',      }    )  }  return (    <div className="flex justify-start items-center space-x-2 md:space-x-8 mt-5 md:mx-auto">      <div className="w-[187px] sm:w-[324px] h-[229px] sm:h-[180px] rounded-[24px] overflow-hidden">        <Image          className="w-full h-full object-cover"          width={3000}          height={500}          src={contestant.image}          alt={contestant.name}        />      </div>      <div        className="bg-[#151515] h-[229px] w-[186px] sm:w-[253px] sm:h-fit rounded-[24px]        space-y-2 flex justify-center items-center flex-col pt-2 pb-2 px-3"      >        <h1 className="text-[16px] sm:text-[20px] font-[600px] capitalize">{contestant.name}</h1>        <div          className="flex items-center justify-center w-full          rounded-[10px] space-x-2"        >          <div className="w-[32px] h-[32px] rounded-full bg-[#2C2C2C]" />          <p className="text-[14px] font-[500px]">            {truncate({ text: contestant.voter, startChars: 4, endChars: 4, maxLength: 11 })}          </p>        </div>        <button          onClick={voteContestant}          disabled={            wallet              ? contestant.voters.includes(wallet) ||                Date.now() < poll.startsAt ||                Date.now() >= poll.endsAt              : true          }          className={`w-[158px] sm:w-[213px] h-[48px] rounded-[30.5px] ${            (wallet && poll.voters.includes(wallet)) ||            Date.now() < poll.startsAt ||            Date.now() >= poll.endsAt              ? 'bg-[#B0BAC9] cursor-not-allowed'              : 'bg-[#1B5CFE]'          }`}        >          {wallet && contestant.voters.includes(wallet) ? 'Voted' : 'Vote'}        </button>        <div className="w-[86px] h-[32px] flex items-center justify-center gap-3">          <div className="w-[32px] h-[32px] rounded-[9px] py-[8px] px-[9px] bg-[#0E1933]">            <BiUpvote size={20} className="text-[#1B5CFE]" />          </div>          <p className="text-[14px] font-[600px]">{contestant.votes} vote</p>        </div>      </div>    </div>  )}export default Contestants


聊天按钮组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该**ChatButton**组件根据用户的身份验证状态和民意调查的组状态为各种与聊天相关的操作提供动态下拉菜单。

用户可以执行注册、登录、创建群组、加入群组、查看聊天和注销等操作。这些操作与 CometChat 服务交互以进行用户和组管理,并且该组件通过 toast 通知确保正确的反馈。

它还使用 Redux 管理用户和组数据的全局状态,增强用户与应用程序内聊天功能的交互。请参阅下面的代码:

import React from 'react'import { FaUserPlus } from 'react-icons/fa'import { RiArrowDropDownLine } from 'react-icons/ri'import { FiLogIn } from 'react-icons/fi'import { HiLogin, HiUserGroup, HiChat } from 'react-icons/hi'import { SiGnuprivacyguard } from 'react-icons/si'import { Menu } from '@headlessui/react'import { toast } from 'react-toastify'import {  createNewGroup,  joinGroup,  logOutWithCometChat,  loginWithCometChat,  signUpWithCometChat,} from '../services/chat'import { useDispatch, useSelector } from 'react-redux'import { globalActions } from '@/store/globalSlices'import { PollStruct, RootState } from '@/utils/types'const ChatButton: React.FC<{ poll: PollStruct; group: any }> = ({ poll, group }) => {  const dispatch = useDispatch()  const { setCurrentUser, setChatModal, setGroup } = globalActions  const { wallet, currentUser } = useSelector((states: RootState) => states.globalStates)  const handleSignUp = async () => {    if (wallet === '') return toast.warning('Connect wallet first!')    await toast.promise(      new Promise((resolve, reject) => {        signUpWithCometChat(wallet)          .then((user) => resolve(user))          .catch((error) => {            alert(JSON.stringify(error))            reject(error)          })      }),      {        pending: 'Signning up...',        success: 'Signed up successfully, please login ',        error: 'Encountered error ',      }    )  }  const handleLogin = async () => {    if (wallet === '') return toast.warning('Connect wallet first!')    await toast.promise(      new Promise((resolve, reject) => {        loginWithCometChat(wallet)          .then((user) => {            dispatch(setCurrentUser(JSON.parse(JSON.stringify(user))))            resolve(user)          })          .catch((error) => {            alert(JSON.stringify(error))            reject(error)          })      }),      {        pending: 'Logging...',        success: 'Logged in successfully ',        error: 'Encountered error ',      }    )  }  const handleLogout = async () => {    if (wallet === '') return toast.warning('Connect wallet first!')    await toast.promise(      new Promise((resolve, reject) => {        logOutWithCometChat()          .then(() => {            dispatch(setCurrentUser(null))            resolve(null)          })          .catch((error) => {            alert(JSON.stringify(error))            reject(error)          })      }),      {        pending: 'Leaving...',        success: 'Logged out successfully ',        error: 'Encountered error ',      }    )  }  const handleCreateGroup = async () => {    if (wallet === '') return toast.warning('Connect wallet first!')    await toast.promise(      new Promise((resolve, reject) => {        createNewGroup(`guid_${poll.id}`, poll.title)          .then((group) => {            dispatch(setGroup(JSON.parse(JSON.stringify(group))))            resolve(group)          })          .catch((error) => {            alert(JSON.stringify(error))            reject(error)          })      }),      {        pending: 'Creating group...',        success: 'Group created successfully ',        error: 'Encountered error ',      }    )  }  const handleJoinGroup = async () => {    if (wallet === '') return toast.warning('Connect wallet first!')    await toast.promise(      new Promise((resolve, reject) => {        joinGroup(`guid_${poll.id}`)          .then((group) => {            dispatch(setGroup(JSON.parse(JSON.stringify(group))))            resolve(group)          })          .catch((error) => {            alert(JSON.stringify(error))            reject(error)          })      }),      {        pending: 'Joining group...',        success: 'Group joined successfully ',        error: 'Encountered error ',      }    )  }  return (    <Menu as="div" className="inline-block text-left mx-auto fixed right-5 bottom-[80px]">      <Menu.Button        className="bg-[#1B5CFE] hover:bg-blue-700 text-white font-bold        rounded-full transition-all duration-300 p-3 focus:outline-none          focus-visible:ring-2 focus-visible:ring-white          focus-visible:ring-opacity-75 shadow-md shadow-black"        as="button"      >        <RiArrowDropDownLine size={20} />      </Menu.Button>      <Menu.Items        className="absolute right-0 bottom-14 mt-2 w-56 origin-top-right          divide-y divide-gray-100 rounded-md bg-white shadow-lg shadow-black          ing-1 ring-black ring-opacity-5 focus:outline-none"      >        {!currentUser ? (          <>            <Menu.Item>              {({ active }) => (                <button                  className={`flex justify-start items-center space-x-1 ${                    active ? 'bg-gray-200 text-black' : 'text-red-500'                  } group flex w-full items-center rounded-md px-2 py-2 text-sm`}                  onClick={handleSignUp}                >                  <SiGnuprivacyguard size={17} />                  <span>SignUp</span>                </button>              )}            </Menu.Item>            <Menu.Item>              {({ active }) => (                <button                  className={`flex justify-start items-center space-x-1 ${                    active ? 'bg-gray-200 text-black' : 'text-gray-900'                  } group flex w-full items-center rounded-md px-2 py-2 text-sm`}                  onClick={handleLogin}                >                  <FiLogIn size={17} />                  <span>Login</span>                </button>              )}            </Menu.Item>          </>        ) : (          <>            {!group && wallet === poll.director && (              <Menu.Item>                {({ active }) => (                  <button                    className={`flex justify-start items-center space-x-1 ${                      active ? 'bg-gray-200 text-black' : 'text-gray-900'                    } group flex w-full items-center rounded-md px-2 py-2 text-sm`}                    onClick={() => handleCreateGroup()}                  >                    <HiUserGroup size={17} />                    <span>Create Group</span>                  </button>                )}              </Menu.Item>            )}            {group && !group.hasJoined && wallet !== poll.director && (              <Menu.Item>                {({ active }) => (                  <button                    className={`flex justify-start items-center space-x-1 ${                      active ? 'bg-gray-200 text-black' : 'text-gray-900'                    } group flex w-full items-center rounded-md px-2 py-2 text-sm`}                    onClick={() => handleJoinGroup()}                  >                    <FaUserPlus size={17} />                    <span>Join Group</span>                  </button>                )}              </Menu.Item>            )}            {group?.hasJoined && (              <Menu.Item>                {({ active }) => (                  <button                    className={`flex justify-start items-center space-x-1 ${                      active ? 'bg-gray-200 text-black' : 'text-gray-900'                    } group flex w-full items-center rounded-md px-2 py-2 text-sm`}                    onClick={() => dispatch(setChatModal('scale-100'))}                  >                    <HiChat size={17} />                    <span>Chats</span>                  </button>                )}              </Menu.Item>            )}            <Menu.Item>              {({ active }) => (                <button                  className={`flex justify-start items-center space-x-1 ${                    active ? 'bg-gray-200 text-black' : 'text-gray-900'                  } group flex w-full items-center rounded-md px-2 py-2 text-sm`}                  onClick={handleLogout}                >                  <HiLogin size={17} />                  <span>Logout</span>                </button>              )}            </Menu.Item>          </>        )}      </Menu.Items>    </Menu>  )}export default ChatButton


聊天模态组件


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该ChatModal组件为群组内的实时聊天提供了一个用户友好的界面。它获取并显示现有消息,侦听新消息,并允许用户发送和接收消息。它还提供消息时间戳、发件人标识和标识符。该组件与 Redux 集成来管理聊天模式的显示状态,并通过 toast 通知维护正确的用户体验。请参阅下面的代码:

import Identicon from 'react-identicons'import { globalActions } from '@/store/globalSlices'import { RootState } from '@/utils/types'import React, { FormEvent, useEffect, useState } from 'react'import { FaTimes } from 'react-icons/fa'import { useDispatch, useSelector } from 'react-redux'import { truncate } from '@/services/blockchain'import { getMessages, listenForMessage, sendMessage } from '@/services/chat'import { toast } from 'react-toastify'const ChatModal: React.FC<{ group: any }> = ({ group }) => {  const dispatch = useDispatch()  const { setChatModal } = globalActions  const { wallet, chatModal } = useSelector((states: RootState) => states.globalStates)  const [message, setMessage] = useState<string>('')  const [messages, setMessages] = useState<any[]>([])  const [shouldAutoScroll, setShouldAutoScroll] = useState<boolean>(true)  useEffect(() => {    const handleListing = () => {      listenForMessage(group?.guid).then((msg) => {        setMessages((prevMsgs) => [...prevMsgs, msg])        setShouldAutoScroll(true)      })    }    const handleMessageRetrieval = () => {      getMessages(group?.guid).then((msgs) => {        setMessages(msgs as any[])        setShouldAutoScroll(true)      })    }    setTimeout(async () => {      if (typeof window !== 'undefined') {        handleMessageRetrieval()        handleListing()      }    }, 500)  }, [group?.guid])  useEffect(() => {    if (shouldAutoScroll) {      scrollToEnd()    }  }, [messages, shouldAutoScroll])  const handleSubmit = async (e: FormEvent) => {    e.preventDefault()    if (!message) return    if (wallet === '') return toast.warning('Connect wallet first!')    await sendMessage(group?.guid, message)      .then((msg) => {        setMessages((prevMsgs) => [...prevMsgs, msg])        setShouldAutoScroll(true)        scrollToEnd()        setMessage('')      })      .catch((error) => console.log(error))  }  const scrollToEnd = () => {    const elmnt: HTMLElement | null = document.getElementById('messages-container')    if (elmnt) elmnt.scrollTop = elmnt.scrollHeight  }  const closeModal = () => {    dispatch(setChatModal('scale-0'))    setMessage('')    scrollToEnd()  }  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 ${chatModal}`}    >      <div className="bg-[#0c0c10] text-[#BBBBBB] shadow-lg shadow-[#1B5CFE] 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">Chat</p>            <button onClick={closeModal} className="border-0 bg-transparent focus:outline-none">              <FaTimes />            </button>          </div>          <div            id="messages-container"            className="flex flex-col justify-center items-start rounded-xl my-5 pt-5 max-h-[20rem] overflow-y-auto"          >            <div className="py-4" />            {messages.map((msg: any, i: number) => (              <Message                text={msg.text}                owner={msg.sender.uid}                time={Number(msg.sendAt + '000')}                you={wallet === msg.sender.uid}                key={i}              />            ))}          </div>          <form            onSubmit={handleSubmit}            className="flex flex-col justify-center items-start rounded-xl mt-5 mb-5"          >            <div className="py-4 w-full border border-[#212D4A] rounded-full flex items-center px-4 mb-3 mt-2">              <input                placeholder="Send message..."                className="bg-transparent outline-none w-full placeholder-[#929292] text-sm"                name="message"                value={message}                onChange={(e) => setMessage(e.target.value)}                required              />            </div>          </form>        </div>      </div>    </div>  )}const Message = ({ text, time, owner, you }) => {  return (    <div className="flex justify-start space-x-4 px-6 mb-4 w-full">      <div className="flex justify-start items-center w-full">        <Identicon          className="w-12 h-12 rounded-full object-cover mr-4 shadow-md bg-gray-400"          string={owner}          size={30}        />        <div className="w-full">          <h3 className="text-md font-bold">            {you ? '@You' : truncate({ text: owner, startChars: 4, endChars: 4, maxLength: 11 })}          </h3>          <p className="text-gray-500 text-xs font-semibold space-x-2 w-4/5">{text}</p>        </div>      </div>    </div>  )}export default ChatModal


CometChat NoSSR 组件

最后是组件,CometChatNoSSR它初始化 CometChat 并在客户端检查用户的身份验证状态。它将用户数据分派到 Redux 以便在应用程序中进一步使用。请参阅下面的代码:

import { initCometChat, checkAuthState } from '@/services/chat'import { useEffect } from 'react'import { globalActions } from '@/store/globalSlices'import { useDispatch } from 'react-redux'const CometChatNoSSR = () => {  const { setCurrentUser } = globalActions  const dispatch = useDispatch()  useEffect(() => {    setTimeout(async () => {      if (typeof window !== 'undefined') {        await initCometChat()        checkAuthState().then((user) => {          dispatch(setCurrentUser(JSON.parse(JSON.stringify(user))))        })      }    }, 500)  }, [dispatch, setCurrentUser])  return null}export default CometChatNoSSR

现在我们已经涵盖了该应用程序中的所有组件,是时候开始连接不同的页面了。我们先从主页开始。

要开始开发应用程序的页面,请转到pages项目根目录的文件夹。该文件夹将包含我们项目所需的所有页面。

主页


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该Home组件呈现该应用程序的主页。它使用 Redux 来管理全局状态,从服务器获取轮询数据,并定义页面的 HTML 结构。此页面将导航栏、横幅、CreatePoll、投票和页脚组件捆绑在一起。

要跟进此组件,请将文件夹index.tsx中的文件内容替换pages为以下代码:

import Banner from '@/components/Banner'import CreatePoll from '@/components/CreatePoll'import Footer from '@/components/Footer'import Navbar from '@/components/Navbar'import Polls from '@/components/Polls'import { getPolls } from '@/services/blockchain'import { globalActions } from '@/store/globalSlices'import { PollStruct, RootState } from '@/utils/types'import Head from 'next/head'import { useEffect } from 'react'import { useDispatch, useSelector } from 'react-redux'export default function Home({ pollsData }: { pollsData: PollStruct[] }) {  const dispatch = useDispatch()  const { setPolls } = globalActions  const { polls } = useSelector((states: RootState) => states.globalStates)  useEffect(() => {    dispatch(setPolls(pollsData))  }, [dispatch, setPolls, pollsData])  return (    <>      <Head>        <title>Available Polls</title>        <link rel="icon" href="/favicon.ico" />      </Head>      <div className="min-h-screen relative backdrop-blur">        <div          className="absolute inset-0 before:absolute before:inset-0        before:w-full before:h-full before:bg-[url('/assets/images/bg.jpeg')]        before:blur-sm before:z-[-1] before:bg-no-repeat before:bg-cover"        />        <section className="relative px-5 py-10 space-y-16 text-white sm:p-10">          <Navbar />          <Banner />          <Polls polls={polls} />          <Footer />        </section>        <CreatePoll />      </div>    </>  )}export const getServerSideProps = async () => {  const pollsData: PollStruct[] = await getPolls()  return {    props: { pollsData: JSON.parse(JSON.stringify(pollsData)) },  }}

投票页面


使用 Next.js、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

该Polls组件是一个动态页面,显示有关特定投票的详细信息。它使用 Redux 来管理全局状态,从服务器获取投票和参赛者数据,并定义页面的 HTML 结构。

该组件捆绑了**Footer**、**Navbar**、**Details**、**Contestants**、**UpdatePoll**、**DeletePoll**、**ContestPoll**、**ChatModal**和**ChatButton**,构成了投票页面的结构。

polls要继续,请在目录内创建一个名为inside的新文件夹pages。然后,创建一个名为[id].tsx. 确保使用准确的模式,因为 Next.js 创建动态页面需要此模式。请参阅下面的代码:

import Footer from '@/components/Footer'import Navbar from '@/components/Navbar'import Details from '@/components/Details'import Contestants from '@/components/Contestants'import Head from 'next/head'import ContestPoll from '@/components/ContestPoll'import { GetServerSidePropsContext } from 'next'import { getContestants, getPoll } from '@/services/blockchain'import { ContestantStruct, PollStruct, RootState } from '@/utils/types'import { useDispatch, useSelector } from 'react-redux'import { globalActions } from '@/store/globalSlices'import { useEffect } from 'react'import UpdatePoll from '@/components/UpdatePoll'import DeletePoll from '@/components/DeletePoll'import ChatButton from '@/components/ChatButton'import ChatModal from '@/components/ChatModal'import { getGroup } from '@/services/chat'import { useRouter } from 'next/router'export default function Polls({  pollData,  contestantData,}: {  pollData: PollStruct  contestantData: ContestantStruct[]}) {  const dispatch = useDispatch()  const { setPoll, setContestants, setGroup } = globalActions  const { poll, contestants, currentUser, group } = useSelector(    (states: RootState) => states.globalStates  )  const router = useRouter()  const { id } = router.query  useEffect(() => {    dispatch(setPoll(pollData))    dispatch(setContestants(contestantData))    const fetchData = async () => {      if (typeof window !== 'undefined') {        setTimeout(async () => {          const groupData = await getGroup(`guid_${id}`)          if (groupData) dispatch(setGroup(JSON.parse(JSON.stringify(groupData))))        }, 500)      }    }    fetchData()  }, [    dispatch,    setPoll,    setContestants,    setGroup,    contestantData,    pollData,    id,    currentUser,    group,  ])  return (    <>      {poll && (        <Head>          <title>Poll | {poll.title}</title>          <link rel="icon" href="/favicon.ico" />        </Head>      )}      <div className="min-h-screen relative backdrop-blur">        <div          className="absolute inset-0 before:absolute before:inset-0          before:w-full before:h-full before:bg-[url('/assets/images/bg.jpeg')]          before:blur-sm before:z-[-1] before:bg-no-repeat before:bg-cover"        />        <section className="relative px-5 py-10 space-y-16 text-white sm:p-10">          <Navbar />          {poll && <Details poll={poll} />}          {poll && contestants && <Contestants poll={poll} contestants={contestants} />}          <Footer />        </section>        {poll && (          <>            <UpdatePoll pollData={poll} />            <DeletePoll poll={poll} />            <ContestPoll poll={poll} />            <ChatModal group={group} />            <ChatButton poll={poll} group={group} />          </>        )}      </div>    </>  )}export const getServerSideProps = async (context: GetServerSidePropsContext) => {  const { id } = context.query  const pollData = await getPoll(Number(id))  const contestantData = await getContestants(Number(id))  return {    props: {      pollData: JSON.parse(JSON.stringify(pollData)),      contestantData: JSON.parse(JSON.stringify(contestantData)),    },  }}

Redux store

跨组件和页面管理来自区块链和智能合约的数据可能很困难。这就是我们使用 Redux Toolkit 来管理所有组件和脚本中的数据的原因。这是 Redux Toolkit 的独特设置:

  • 创建全局状态脚本来管理所有状态
  • 创建全局操作脚本来管理所有状态突变
  • 将状态和操作捆绑在全局切片脚本中
  • 将全局切片配置为存储服务
  • 将商店提供商包装到您的应用程序中

第 1 步:定义 Redux 状态在项目根目录下
创建一个名为的文件夹。store创建另一个名为statesinside 的store文件夹。globalState.ts在文件夹内创建一个名为的文件states,并将以下代码粘贴到其中。

import { GlobalState } from '@/utils/types'export const globalStates: GlobalState = {  wallet: '',  createModal: 'scale-0',  updateModal: 'scale-0',  deleteModal: 'scale-0',  contestModal: 'scale-0',  chatModal: 'scale-0',  polls: [],  poll: null,  group: null,  contestants: [],  currentUser: null,}

这段代码定义了 Redux 的初始状态。它包括wallet用户以太坊钱包信息、各种模式的可见性状态、选定民意调查数据、polls聊天组信息、民意调查参赛者以及登录用户详细信息的数组等属性。这些属性存储可以跨应用程序全局访问和更新的数据。pollgroupcontestantscurrentUser

第 2 步:定义 Redux 操作
创建另一个名为actionsinside 的store文件夹。globalActions.ts在文件夹内创建一个名为的文件actions,并将以下代码粘贴到其中。

import { ContestantStruct, GlobalState, PollStruct } from '@/utils/types'import { PayloadAction } from '@reduxjs/toolkit'export const globalActions = {  setWallet: (state: GlobalState, action: PayloadAction<string>) => {    state.wallet = action.payload  },  setCreateModal: (state: GlobalState, action: PayloadAction<string>) => {    state.createModal = action.payload  },  setUpdateModal: (state: GlobalState, action: PayloadAction<string>) => {    state.updateModal = action.payload  },  setDeleteModal: (state: GlobalState, action: PayloadAction<string>) => {    state.deleteModal = action.payload  },  setContestModal: (state: GlobalState, action: PayloadAction<string>) => {    state.contestModal = action.payload  },  setChatModal: (state: GlobalState, action: PayloadAction<string>) => {    state.chatModal = action.payload  },  setPolls: (state: GlobalState, action: PayloadAction<PollStruct[]>) => {    state.polls = action.payload  },  setPoll: (state: GlobalState, action: PayloadAction<PollStruct>) => {    state.poll = action.payload  },  setGroup: (state: GlobalState, action: PayloadAction<any>) => {    state.group = action.payload  },  setContestants: (state: GlobalState, action: PayloadAction<ContestantStruct[]>) => {    state.contestants = action.payload  },  setCurrentUser: (state: GlobalState, action: PayloadAction<any>) => {    state.currentUser = action.payload  },}

这些 Redux 操作定义了修改全局状态的函数。它们从操作接收状态对象和有效负载。每个操作对应于一个特定的状态属性并将其设置为有效负载的值。这些操作在整个应用程序中用于更新各种全局状态属性,例如wallet、modalstates、polls、group、contestants和currentUser,从而根据需要动态更改应用程序的状态。

步骤 3:将状态和操作捆绑在切片中创建一个在文件夹内
调用的文件,并将以下代码粘贴到其中。globalSlices.tsstore

import { createSlice } from '@reduxjs/toolkit'import { globalActions as GlobalActions } from './actions/globalActions'import { globalStates as GlobalStates } from './states/globalState'export const globalSlices = createSlice({  name: 'global',  initialState: GlobalStates,  reducers: GlobalActions,})export const globalActions = globalSlices.actionsexport default globalSlices.reducer

这个名为 Redux 的切片global结合了初始状态和来自globalActions和 的减速器GlobalStates。它为全局状态创建动作和减速器。该globalActions对象包含操作创建者,并globalSlices.reducer根据这些操作处理状态更新,从而简化了global应用程序切片的状态管理。

步骤 4:将切片配置为存储服务在文件夹内
创建一个名为的文件,并将以下代码粘贴到其中。index.tsstore

import { configureStore } from '@reduxjs/toolkit'import globalSlices from './globalSlices'export const store = configureStore({  reducer: {    globalStates: globalSlices,  },})

此 Redux 存储是使用配置的@reduxjs/toolkit。它将globalSlices减速器合并到存储中,允许在整个应用程序中访问全局状态管理。该configureStore函数使用指定的减速器初始化存储,从而通过 Redux 实现高效的状态处理。

TypeScript 接口

该应用程序中使用的接口在项目根目录下的文件夹type.d.ts中的文件中定义。utils该文件定义了应用程序中使用的数据结构。创建一个名为 的文件夹utils,并在其中创建一个名为 的文件type.d.ts,并将以下代码粘贴到其中。

export interface TruncateParams {  text: string  startChars: number  endChars: number  maxLength: number}export interface PollParams {  image: string  title: string  description: string  startsAt: number | string  endsAt: number | string}export interface PollStruct {  id: number  image: string  title: string  description: string  votes: number  contestants: number  deleted: boolean  director: string  startsAt: number  endsAt: number  timestamp: number  avatars: string[]  voters: string[]}export interface ContestantStruct {  id: number  image: string  name: string  voter: string  votes: number  voters: string[]}export interface GlobalState {  wallet: string  createModal: string  updateModal: string  deleteModal: string  contestModal: string  chatModal: string  polls: PollStruct[]  poll: PollStruct | null  group: PollStruct | null  contestants: ContestantStruct[]  currentUser: PollStruct | null}export interface RootState {  globalStates: GlobalState}
  1. TruncateParams:包含截断文本的参数,包括输入文本、开头和结尾保留的字符数以及最大长度。
  2. PollParams:代表创建投票的参数,包括图片、标题、描述、开始和结束时间。
  3. PollStruct:描述投票对象的结构,具有 id、图像、标题、描述、投票、参赛者计数、删除状态、导演、开始和结束时间、时间戳、头像和投票者等属性。
  4. ContestantStruct:表示投票中参赛者的结构,包括 id、图像、姓名、选民、投票以及投票给他们的选民列表等属性。
  5. GlobalState:定义全局应用程序状态的形状,包括钱包属性、模式状态(创建、更新、删除、竞赛、聊天)、投票相关数据(投票、投票、组、参赛者、当前用户)。
  6. RootState:指定根状态结构,主要包含属性globalStates,封装了全局应用程序状态。

应用程序服务

要将 web3 和聊天功能添加到我们的应用程序中,我们需要创建一些服务。在项目的根目录创建一个名为的文件夹services,并在其中创建以下脚本:

  • blockchain.ts:该脚本将连接到以太坊区块链并管理与 web3 相关的任务。
  • chat.ts:此脚本将处理与聊天相关的任务,例如连接到 CometChat 和发送/接收消息。

确保将以下代码复制并粘贴到各自的文件中。

区块链服务
该区块链服务使用以太坊与智能合约进行交互。它包括连接以太坊钱包、创建、更新和删除民意调查、参与民意调查、为参赛者投票以及获取民意调查相关数据等功能。请参阅下面的代码:

import { store } from '@/store'import { ethers } from 'ethers'import { globalActions } from '@/store/globalSlices'import address from '@/artifacts/contractAddress.json'import abi from '@/artifacts/contracts/DappVotes.sol/DappVotes.json'import { ContestantStruct, PollParams, PollStruct, TruncateParams } from '@/utils/types'import { logOutWithCometChat } from './chat'const { setWallet, setPolls, setPoll, setContestants, setCurrentUser } = globalActionsconst ContractAddress = address.addressconst ContractAbi = abi.abilet ethereum: anylet tx: anyif (typeof window !== 'undefined') {  ethereum = (window as any).ethereum}const getEthereumContract = async () => {  const accounts = await ethereum?.request?.({ method: 'eth_accounts' })  const provider = accounts?.[0]    ? new ethers.providers.Web3Provider(ethereum)    : new ethers.providers.JsonRpcProvider(process.env.NEXT_APP_RPC_URL)  const wallet = accounts?.[0] ? null : ethers.Wallet.createRandom()  const signer = provider.getSigner(accounts?.[0] ? undefined : wallet?.address)  const contract = new ethers.Contract(ContractAddress, ContractAbi, signer)  return contract}const connectWallet = async () => {  try {    if (!ethereum) return reportError('Please install Metamask')    const accounts = await ethereum.request?.({ method: 'eth_requestAccounts' })    store.dispatch(setWallet(accounts?.[0]))  } catch (error) {    reportError(error)  }}const checkWallet = async () => {  try {    if (!ethereum) return reportError('Please install Metamask')    const accounts = await ethereum.request?.({ method: 'eth_accounts' })    ethereum.on('chainChanged', () => {      window.location.reload()    })    ethereum.on('accountsChanged', async () => {      store.dispatch(setWallet(accounts?.[0]))      await checkWallet()      await logOutWithCometChat()      store.dispatch(setCurrentUser(null))    })    if (accounts?.length) {      store.dispatch(setWallet(accounts[0]))    } else {      store.dispatch(setWallet(''))      reportError('Please connect wallet, no accounts found.')    }  } catch (error) {    reportError(error)  }}const createPoll = async (data: PollParams) => {  if (!ethereum) {    reportError('Please install Metamask')    return Promise.reject(new Error('Metamask not installed'))  }  try {    const contract = await getEthereumContract()    const { image, title, description, startsAt, endsAt } = data    const tx = await contract.createPoll(image, title, description, startsAt, endsAt)    await tx.wait()    const polls = await getPolls()    store.dispatch(setPolls(polls))    return Promise.resolve(tx)  } catch (error) {    reportError(error)    return Promise.reject(error)  }}const updatePoll = async (id: number, data: PollParams) => {  if (!ethereum) {    reportError('Please install Metamask')    return Promise.reject(new Error('Metamask not installed'))  }  try {    const contract = await getEthereumContract()    const { image, title, description, startsAt, endsAt } = data    const tx = await contract.updatePoll(id, image, title, description, startsAt, endsAt)    await tx.wait()    const poll = await getPoll(id)    store.dispatch(setPoll(poll))    return Promise.resolve(tx)  } catch (error) {    reportError(error)    return Promise.reject(error)  }}const deletePoll = async (id: number) => {  if (!ethereum) {    reportError('Please install Metamask')    return Promise.reject(new Error('Metamask not installed'))  }  try {    const contract = await getEthereumContract()    const tx = await contract.deletePoll(id)    await tx.wait()    return Promise.resolve(tx)  } catch (error) {    reportError(error)    return Promise.reject(error)  }}const contestPoll = async (id: number, name: string, image: string) => {  if (!ethereum) {    reportError('Please install Metamask')    return Promise.reject(new Error('Metamask not installed'))  }  try {    const contract = await getEthereumContract()    const tx = await contract.contest(id, name, image)    await tx.wait()    const poll = await getPoll(id)    store.dispatch(setPoll(poll))    const contestants = await getContestants(id)    store.dispatch(setContestants(contestants))    return Promise.resolve(tx)  } catch (error) {    reportError(error)    return Promise.reject(error)  }}const voteCandidate = async (id: number, cid: number) => {  if (!ethereum) {    reportError('Please install Metamask')    return Promise.reject(new Error('Metamask not installed'))  }  try {    const contract = await getEthereumContract()    const tx = await contract.vote(id, cid)    await tx.wait()    const poll = await getPoll(id)    store.dispatch(setPoll(poll))    const contestants = await getContestants(id)    store.dispatch(setContestants(contestants))    return Promise.resolve(tx)  } catch (error) {    reportError(error)    return Promise.reject(error)  }}const getPolls = async (): Promise<PollStruct[]> => {  const contract = await getEthereumContract()  const polls = await contract.getPolls()  return structurePolls(polls)}const getPoll = async (id: number): Promise<PollStruct> => {  const contract = await getEthereumContract()  const polls = await contract.getPoll(id)  return structurePolls([polls])[0]}const getContestants = async (id: number): Promise<ContestantStruct[]> => {  const contract = await getEthereumContract()  const contestants = await contract.getContestants(id)  return structureContestants(contestants)}const truncate = ({ text, startChars, endChars, maxLength }: TruncateParams): string => {  if (text.length > maxLength) {    let start = text.substring(0, startChars)    let end = text.substring(text.length - endChars, text.length)    while (start.length + end.length < maxLength) {      start = start + '.'    }    return start + end  }  return text}const formatDate = (timestamp: number): string => {  const date = new Date(timestamp)  const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']  const months = [    'Jan',    'Feb',    'Mar',    'Apr',    'May',    'Jun',    'Jul',    'Aug',    'Sep',    'Oct',    'Nov',    'Dec',  ]  const dayOfWeek = daysOfWeek[date.getUTCDay()]  const month = months[date.getUTCMonth()]  const day = date.getUTCDate()  const year = date.getUTCFullYear()  return `${dayOfWeek}, ${month} ${day}, ${year}`}const formatTimestamp = (timestamp: number) => {  const date = new Date(timestamp)  const year = date.getFullYear()  const month = String(date.getMonth() + 1).padStart(2, '0')  const day = String(date.getDate()).padStart(2, '0')  const hours = String(date.getHours()).padStart(2, '0')  const minutes = String(date.getMinutes()).padStart(2, '0')  return `${year}-${month}-${day}T${hours}:${minutes}`}const structurePolls = (polls: any[]): PollStruct[] =>  polls    .map((poll) => ({      id: Number(poll.id),      image: poll.image,      title: poll.title,      description: poll.description,      votes: Number(poll.votes),      contestants: Number(poll.contestants),      deleted: poll.deleted,      director: poll.director.toLowerCase(),      startsAt: Number(poll.startsAt),      endsAt: Number(poll.endsAt),      timestamp: Number(poll.timestamp),      voters: poll.voters.map((voter: string) => voter.toLowerCase()),      avatars: poll.avatars,    }))    .sort((a, b) => b.timestamp - a.timestamp)const structureContestants = (contestants: any[]): ContestantStruct[] =>  contestants    .map((contestant) => ({      id: Number(contestant.id),      image: contestant.image,      name: contestant.name,      voter: contestant.voter.toLowerCase(),      votes: Number(contestant.votes),      voters: contestant.voters.map((voter: string) => voter.toLowerCase()),    }))    .sort((a, b) => b.votes - a.votes)export {  connectWallet,  checkWallet,  truncate,  formatDate,  formatTimestamp,  createPoll,  updatePoll,  deletePoll,  getPolls,  getPoll,  contestPoll,  getContestants,  voteCandidate,}

该服务的关键组件包括:

  • 以太坊连接:如果 MetaMask 不可用,它会使用 MetaMask 或 JSON-RPC 提供程序连接到用户的以太坊钱包。
  • 投票和参赛者操作:它允许创建、更新和删除投票、对投票进行竞争以及为参赛者投票,并进行错误处理。
  • 获取数据:从智能合约中获取投票数据、投票列表和参赛者数据的功能。
  • 实用程序:实用程序功能,例如截断文本、格式化时间戳以及构建民意调查和参赛者数据的一致性。
  • 全局状态管理:它使用 Redux 来管理全局状态,使整个应用程序中的组件能够访问和修改用户的钱包、投票和参赛者等数据。

该服务在应用程序与以太坊区块链的交互中发挥着至关重要的作用,使用户能够安全、透明地参与投票和竞赛。

聊天服务
该聊天服务将 CometChat SDK 集成到应用程序中以实现实时聊天功能。请参阅下面的代码:

let CometChat: anyif (typeof window !== 'undefined') {  import('@cometchat-pro/chat').then((cometChatModule) => {    CometChat = cometChatModule.CometChat  })  console.log('CometChat Loaded...')}const CONSTANTS = {  APP_ID: process.env.NEXT_PUBLIC_COMET_CHAT_APP_ID,  REGION: process.env.NEXT_PUBLIC_COMET_CHAT_REGION,  Auth_Key: process.env.NEXT_PUBLIC_COMET_CHAT_AUTH_KEY,}const initCometChat = async () => {  const appID = CONSTANTS.APP_ID  const region = CONSTANTS.REGION  const appSetting = new CometChat.AppSettingsBuilder()    .subscribePresenceForAllUsers()    .setRegion(region)    .autoEstablishSocketConnection(true)    .build()  CometChat.init(appID, appSetting)    .then(() => console.log('Initialization completed successfully'))    .catch((error: any) => console.log(error))}const loginWithCometChat = async (UID: string) => {  const authKey = CONSTANTS.Auth_Key  return new Promise((resolve, reject) => {    CometChat.login(UID, authKey)      .then((user: any) => resolve(user))      .catch((error: any) => reject(error))  })}const signUpWithCometChat = async (UID: string) => {  const authKey = CONSTANTS.Auth_Key  const user = new CometChat.User(UID)  user.setName(UID)  return new Promise((resolve, reject) => {    CometChat.createUser(user, authKey)      .then((user: any) => resolve(user))      .catch((error: any) => reject(error))  })}const logOutWithCometChat = async () => {  return new Promise((resolve, reject) => {    CometChat.logout()      .then(() => resolve(null))      .catch((error: any) => reject(error))  })}const checkAuthState = async () => {  return new Promise((resolve, reject) => {    CometChat.getLoggedinUser()      .then((user: any) => resolve(user))      .catch((error: any) => reject(error))  })}const createNewGroup = async (GUID: string, groupName: string) => {  const groupType = CometChat.GROUP_TYPE.PUBLIC  const password = ''  const group = new CometChat.Group(GUID, groupName, groupType, password)  return new Promise((resolve, reject) => {    CometChat.createGroup(group)      .then((group: any) => resolve(group))      .catch((error: any) => reject(error))  })}const getGroup = async (GUID: string) => {  return new Promise((resolve, reject) => {    CometChat.getGroup(GUID)      .then((group: any) => resolve(group))      .catch((error: any) => reject(error))  })}const joinGroup = async (GUID: string) => {  const groupType = CometChat.GROUP_TYPE.PUBLIC  const password = ''  return new Promise((resolve, reject) => {    CometChat.joinGroup(GUID, groupType, password)      .then((group: any) => resolve(group))      .catch((error: any) => reject(error))  })}const getMessages = async (GUID: string) => {  const limit = 30  const messagesRequest = new CometChat.MessagesRequestBuilder()    .setGUID(GUID)    .setLimit(limit)    .build()  return new Promise((resolve, reject) => {    messagesRequest      .fetchPrevious()      .then((messages: any[]) => resolve(messages.filter((msg) => msg.type === 'text')))      .catch((error: any) => reject(error))  })}const sendMessage = async (receiverID: string, messageText: string) => {  const receiverType = CometChat.RECEIVER_TYPE.GROUP  const textMessage = new CometChat.TextMessage(receiverID, messageText, receiverType)  return new Promise((resolve, reject) => {    CometChat.sendMessage(textMessage)      .then((message: any) => resolve(message))      .catch((error: any) => reject(error))  })}const listenForMessage = async (listenerID: string) => {  return new Promise((resolve) => {    CometChat.addMessageListener(      listenerID,      new CometChat.MessageListener({        onTextMessageReceived: (message: any) => resolve(message),      })    )  })}export {  initCometChat,  loginWithCometChat,  signUpWithCometChat,  logOutWithCometChat,  checkAuthState,  createNewGroup,  getGroup,  getMessages,  joinGroup,  sendMessage,  listenForMessage,}

它执行几个关键功能:

  1. 初始化:服务使用提供的应用程序 ID 和区域初始化 CometChat,订阅用户状态更新并建立套接字连接。
  2. 身份验证:它提供用户登录和注册的功能,这对于访问聊天功能至关重要。
  3. 用户管理:包括检查用户认证状态、注销等功能。
  4. 群组管理:该服务允许创建新群组、获取群组信息以及加​入群组。
  5. 消息发送:支持群组发送和接收短信,提供实时聊天体验。该listenForMessage功能使应用程序能够对传入的消息做出反应。
  6. 错误处理:在这些功能中,错误处理是为了有效地管理和报告错误而实现的。
  7. 延迟加载: CometChat SDK 延迟加载(仅当应用程序在浏览器中运行时),确保高效的资源利用。

该服务使用户能够在应用程序内进行实时群聊,增强用户体验并促进参与者之间的沟通。

应用程序组件

该组件管理该 NextJs 应用程序中的页面和子组件。它根据showChild状态有条件地渲染子组件。它初始化 CometChat,提供 Redux 存储访问,并通过ToastContainer. 这种条件渲染确保 CometChat 仅在客户端初始化。

前往pages文件夹,打开_app.tsx文件并将其内容替换为以下代码:

import { AppProps } from 'next/app'import '@/styles/global.css'import { Provider } from 'react-redux'import { store } from '@/store'import { ToastContainer } from 'react-toastify'import 'react-toastify/dist/ReactToastify.css'import { useEffect, useState } from 'react'import { checkWallet } from '@/services/blockchain'import CometChatNoSSR from '@/components/CometChatNoSSR'export default function MyApp({ Component, pageProps }: AppProps) {  const [showChild, setShowChild] = useState<boolean>(false)    useEffect(() => {    checkWallet()    setShowChild(true)  }, [])  if (!showChild || typeof window === 'undefined') {    return null  } else {    return (      <Provider store={store}>        <CometChatNoSSR />        <Component {...pageProps} />        <ToastContainer          position="bottom-center"          autoClose={5000}          hideProgressBar={false}          newestOnTop={false}          closeOnClick          rtl={false}          pauseOnFocusLoss          draggable          pauseOnHover          theme="dark"        />      </Provider>    )  }}

在完成项目之前,创建一个名为 的文件夹assets/images,并将在此链接中找到的图像添加到该文件夹​​中。

恭喜您学习了本教程,了解如何使用 Next.js、TypeScript、Tailwind CSS 和 CometChat 构建去中心化投票 dapp!

欧易OKX介绍: 欧易OKX是行业领先的虚拟资产交易所及Web3生态圈,欧易OKX开发出速度与可靠性兼备的虚拟资产应用程序,深受全球逾五千万投资者及专业交易员的青睐。除了交易所服务外,欧易OKX最新推出OKX Web3钱包服务,为用户打通交易 GameFi和 DeFi代币的入口,尽情探索NFT和元宇宙领域。

原文網站: 区块链资讯网 https://www.okex.tw

原文標題: 使用 Nextjs、TypeScript、Tailwind CSS 构建去中心化投票 Dapp

原文網址:https://www.okex.tw/blockchain/316.html