diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70ea6c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/server/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..de78a68 --- /dev/null +++ b/.htaccess @@ -0,0 +1,9 @@ + + +RewriteEngine On +RewriteRule ^$ http://127.0.0.1:3000/ [P,L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ http://127.0.0.1:3000/$1 [P,L] + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b12f3e3 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/api/AuthApi.js b/api/AuthApi.js new file mode 100644 index 0000000..d6758f0 --- /dev/null +++ b/api/AuthApi.js @@ -0,0 +1,31 @@ + +import BaseApi from './BaseApi'; + +class AuthApi extends BaseApi { + + signin(address, signature) { + return this.instance.post( + "/api/auth/signin", { address, signature } + ); + } + + checkJwt(address, jwt) { + return this.instance.post( + "/api/auth/check-jwt", { address }, + { + headers: { + 'Authorization': `Bearer ${jwt}` + } + } + ); + } + + getNonce(address) { + return this.instance.post( + "/api/auth/get-nonce", { address } + ); + } + +} + +export default new AuthApi(); \ No newline at end of file diff --git a/api/BaseApi.js b/api/BaseApi.js new file mode 100644 index 0000000..ed0c853 --- /dev/null +++ b/api/BaseApi.js @@ -0,0 +1,50 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from "axios"; + +export default class BaseApi { + constructor() { + this.instance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_SERVER_URL ? process.env.NEXT_PUBLIC_SERVER_URL : '' + }); + + this.instance.interceptors.request.use( + (config) => { + // Do something before request is sent + const token = this.getToken() + if(config.headers && token !== '') + config.headers["Authorization"] = "bearer " + token; + return config; + }, + error => { + Promise.reject(error); + } + ); + + this.instance.interceptors.response.use( + (response) => { + return response; + }, + async (error) => { + if (error.response.status === 401) { + const refreshToken = localStorage.getItem('refreshToken'); + if(refreshToken != null) { + // const response = await this.refreshAuthToken(refreshToken); + + // store.commit('login', response.data); + // this.setAuthToken(response.data.token); + // error.config.headers['Authorization'] = `bearer ${response.data.token}`; + } + else { + // store.commit('logout'); + } + return axios(error.config); + } else { + return Promise.reject(error); + } + } + ); + } + + getToken() { + return localStorage.getItem('jwt'); + } +} \ No newline at end of file diff --git a/api/ProposalApi.js b/api/ProposalApi.js new file mode 100644 index 0000000..4ec9e8a --- /dev/null +++ b/api/ProposalApi.js @@ -0,0 +1,47 @@ + +import BaseApi from './BaseApi'; + +class ProposalApi extends BaseApi { + + listProposal() { + return this.instance.get( + "/api/proposal" + ); + } + + getProposal(id) { + return this.instance.get( + `/api/proposal/${id}` + ); + } + + createProposal(address, title, description) { + return this.instance.post( + `/api/proposal`, { + address, title, description + } + ); + } + + vote(id, address, vote) { + return this.instance.post( + `/api/proposal/${id}/vote`, { + address, vote + } + ); + } + + getRecentPassed() { + return this.instance.get( + `/api/proposal/recent-passed` + ); + } + + getRemainingTimeForNext() { + return this.instance.get( + `/api/proposal/remain-time` + ); + } +} + +export default new ProposalApi(); \ No newline at end of file diff --git a/assets/image/avatar-me.png b/assets/image/avatar-me.png new file mode 100644 index 0000000..fd5caed Binary files /dev/null and b/assets/image/avatar-me.png differ diff --git a/assets/image/avatar.png b/assets/image/avatar.png new file mode 100644 index 0000000..7c35dac Binary files /dev/null and b/assets/image/avatar.png differ diff --git a/assets/image/bg.jpg b/assets/image/bg.jpg new file mode 100644 index 0000000..c2cbe56 Binary files /dev/null and b/assets/image/bg.jpg differ diff --git a/assets/image/deco01.png b/assets/image/deco01.png new file mode 100644 index 0000000..fb6a2e8 Binary files /dev/null and b/assets/image/deco01.png differ diff --git a/assets/image/deco02.png b/assets/image/deco02.png new file mode 100644 index 0000000..ee24cfe Binary files /dev/null and b/assets/image/deco02.png differ diff --git a/assets/image/deco03.png b/assets/image/deco03.png new file mode 100644 index 0000000..f2fe120 Binary files /dev/null and b/assets/image/deco03.png differ diff --git a/assets/image/hero.png b/assets/image/hero.png new file mode 100644 index 0000000..f1fa347 Binary files /dev/null and b/assets/image/hero.png differ diff --git a/assets/image/logo.png b/assets/image/logo.png new file mode 100644 index 0000000..3d44575 Binary files /dev/null and b/assets/image/logo.png differ diff --git a/assets/image/proposal-icon-01.svg b/assets/image/proposal-icon-01.svg new file mode 100644 index 0000000..9ca5b0e --- /dev/null +++ b/assets/image/proposal-icon-01.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/image/proposal-icon-02.svg b/assets/image/proposal-icon-02.svg new file mode 100644 index 0000000..ceb72f0 --- /dev/null +++ b/assets/image/proposal-icon-02.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/image/proposal-icon-03.svg b/assets/image/proposal-icon-03.svg new file mode 100644 index 0000000..daf3bf1 --- /dev/null +++ b/assets/image/proposal-icon-03.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/image/status.svg b/assets/image/status.svg new file mode 100644 index 0000000..e8f0e6e --- /dev/null +++ b/assets/image/status.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/image/submit-wall.jpg b/assets/image/submit-wall.jpg new file mode 100644 index 0000000..b333034 Binary files /dev/null and b/assets/image/submit-wall.jpg differ diff --git a/assets/styles/_button.scss b/assets/styles/_button.scss new file mode 100644 index 0000000..d1511f5 --- /dev/null +++ b/assets/styles/_button.scss @@ -0,0 +1,68 @@ +.btn { + white-space: nowrap; + outline: 0 !important; + font-size: 24px; + padding: 12px 28px; + border-radius: 50px; +} + +.btn:focus { + outline: none !important; + box-shadow: none !important; +} + +.btn-primary { + background-color: $primary-color; + color: $white-color; +} + +.btn-secondary { + background-color: $white-color; + color: $text-inverse-color; +} + +.btn-primary-gradient { + width: 300px; + font-size: 36px; + font-weight: 600; + padding: 4px 0px; + background: linear-gradient(90deg, #6600ff, #0066ff); + color: $white-color; + border-radius: 12px; + border-width: 0px; + text-align: center; +} + +.btn-secondary-gradient { + width: 300px; + font-size: 36px; + font-weight: 600; + padding: 4px 0px; + background: linear-gradient(90deg, #04bb28, #98ff00); + color: #191c24; + border-radius: 12px; + border-width: 0px; + text-align: center; +} + +.btn-roadmap { + font-size: 24px; + background: transparent; + border: 5px $white-color solid !important; + box-shadow: -3px -3px 0px #00d2ff, 3px 3px 0px #c500ff; + + &:hover { + background: transparent; + transition: all ease-in-out 0.2s; + /* opacity: .8; */ + color: $white-color !important; + } +} + + +@media (max-width: 767px){ + .btn { + font-size: 14px; + padding: 4px 12px; + } +} \ No newline at end of file diff --git a/assets/styles/_input.scss b/assets/styles/_input.scss new file mode 100644 index 0000000..e69de29 diff --git a/assets/styles/_navbar.scss b/assets/styles/_navbar.scss new file mode 100644 index 0000000..c68296f --- /dev/null +++ b/assets/styles/_navbar.scss @@ -0,0 +1,66 @@ +.navbar { + height: $navbar-height; + color: $white-color; + background-color: $navbar-bg-color; + padding: 0px; + + &-container { + height: 100%; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + } + + & svg { + width: 36px; + height: 36px; + display: inline-block; + flex-shrink: 0; + user-select: none; + fill: currentColor; + } +} + +.navbar-header { + margin: 11px 16px; +} + +.navbar-brand img { + width: 160px; +} + +.navbar-collapse.collapse { + background-color: $navbar-bg-color; + z-index: 10; + + @media (max-width: ($screen-xl - 1px)){ + &.show { + display: flex; + justify-content: center; + height: calc(100vh - $navbar-height); + padding: 12px 20px; + background-color: $secondary-color; + border-radius: 30px; + } + + .nav-item { + margin: 12px 0; + } + } +} + +.navbar-dark .navbar-nav a { + color: $white-color !important; + font-size: 20px; + padding: 8px 12px; + +} +.navbar-dark .navbar-toggler { + border: 0px; + padding: 0px; + + &:focus { + box-shadow: none; + } +} \ No newline at end of file diff --git a/assets/styles/_variables.scss b/assets/styles/_variables.scss new file mode 100644 index 0000000..f0336bf --- /dev/null +++ b/assets/styles/_variables.scss @@ -0,0 +1,42 @@ +$bg-main-color: #0d0d0d; +$bg-card-color: #E0E0E0; +$text-main-color: #FFFFFF; +$text-desc-color: #c0bfba; +$text-inverse-color: #1D0D52; + +$primary-color: #7951EC; +$secondary-color: #200B6A; + +$white-color: #FFFFFF; + +$normal-gap: 15px; + +$navbar-height: 146px; +$navbar-bg-color: transparent; + +$primary-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.25) 0%, rgba(255, 255, 255, 0.08) 0.01%, rgba(255, 255, 255, 0.1) 100%); +$secondary-gradient: linear-gradient(251.01deg, #7951EC 4.43%, #200B6A 95.37%); + +$spacer: 1.25rem; +$spacers: ( + 0: 0, + 1: $spacer * .25, + 2: $spacer * .5, + 3: $spacer, + 4: $spacer * 1.5, + 5: $spacer * 3, +); + +$screen-sm: 576px; +$screen-md: 768px; +$screen-lg: 992px; +$screen-xl: 1200px; +$screen-xxl: 1400px; + +$container-max-widths: ( + sm: 540px, + md: 720px, + lg: 960px, + xl: 1140px, + xxl: 1512px +); diff --git a/assets/styles/globals.scss b/assets/styles/globals.scss new file mode 100644 index 0000000..95d89ec --- /dev/null +++ b/assets/styles/globals.scss @@ -0,0 +1,897 @@ +@import 'variables'; + +@import url('https://fonts.googleapis.com/css?family=Poppins:300,400,500,600,700'); + +$theme-colors: ( + primary: $primary-color +); +@import "bootstrap/scss/bootstrap"; +@import 'button'; +@import 'input'; +@import 'navbar'; + + +html, +body { + padding: 0; + margin: 0; + font-family: Poppins, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + + font-size: 24px; + background-color: $bg-main-color; + background-image: url(../image/deco01.png), url(../image/deco02.png), url(../image/deco03.png), url(../image/bg.jpg); + background-position: left top 325px, right top 600px, left top 1100px, center top; + background-size: auto, auto, auto, cover; + background-repeat: no-repeat, no-repeat, no-repeat, repeat; + color: $text-main-color; + min-height: 100vh; + line-height: 1.5; + scroll-snap-type: both mandatory; + // scroll-padding-top: $navbar-height; +} + +a { + color: inherit; + text-decoration: none; + &:hover { + color: inherit; + } +} + +* { + box-sizing: border-box; +} + +h1 { + font-weight: 600; + font-size: 65px; +} + +h2 { + font-weight: 600; + font-size: 46px; +} + +h3 { + font-size: 32px; +} + +h4 { + font-weight: 600; + font-size: 24px; +} + +.loading{ + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 100%; + background-color: #000; + z-index: 999999; +} + +.spinner { + margin: 50vh auto; + width: 50px; + height: 40px; + text-align: center; + font-size: 10px; + + & > div { + background-color: #fff; + height: 100%; + width: 4px; + display: inline-block; + + -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out; + animation: sk-stretchdelay 1.2s infinite ease-in-out; + } + + & .rect2 { + -webkit-animation-delay: -1.1s; + animation-delay: -1.1s; + } + + & .rect3 { + -webkit-animation-delay: -1.0s; + animation-delay: -1.0s; + } + + & .rect4 { + -webkit-animation-delay: -0.9s; + animation-delay: -0.9s; + } + + & .rect5 { + -webkit-animation-delay: -0.8s; + animation-delay: -0.8s; + } +} + +@-webkit-keyframes sk-stretchdelay { + 0%, 40%, 100% { -webkit-transform: scaleY(0.4) } + 20% { -webkit-transform: scaleY(1.0) } +} + +@keyframes sk-stretchdelay { + 0%, 40%, 100% { + transform: scaleY(0.4); + -webkit-transform: scaleY(0.4); + } 20% { + transform: scaleY(1.0); + -webkit-transform: scaleY(1.0); + } +} + +.main-container { + min-height: 100vh; + // padding-top: $navbar-height; +} + +.text-black { + color: #000000; +} +.text-white { + color: $white-color !important; +} +.text-desc { + color: $text-desc-color !important; +} + +.text-large { + font-size: 20px !important; +} + +.text-small { + font-size: 12px !important; +} + +.text-primary { + color: $primary-color; +} + +.text-gradient { + background: $primary-gradient; + width: fit-content; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.text-bold { + font-weight: 700; +} + +.text-semibold { + font-weight: 600; +} + +.section-hero { + background: $primary-gradient; + backdrop-filter: blur(60px); + border-radius: 50px; + padding: 130px 60px 90px 100px; +} + +.hero-image-container { + position: relative; + background: $primary-color; + padding: 15% 10% 0% 10%; + border-radius: 400px 400px 50px 50px; + z-index: 0; + + &::before { + content: ''; + position: absolute; + left: -5%; + top: -5%; + width: 110%; + padding-top: 110%; + border-radius: 50%; + border: 2px solid $primary-color; + z-index: -1; + } + &::after { + content: ''; + position: absolute; + left: -10%; + top: 10%; + width: 110%; + padding-top: 110%; + border-radius: 50%; + border: 2px solid $primary-color; + z-index: -1; + } +} + +.hero-description { + margin: 26px 0 54px 0; +} + +.section-recent { + margin-top: 75px; + + & h1 { + margin-bottom: 34px; + } +} + +.section-how-to { + position: relative; + margin-top: 132px; + + & h1 { + margin-bottom: 76px; + } +} + +.section-proposal { + margin-top: 132px; + + & h1 { + margin-bottom: 50px; + } +} + +.co-card { + background: $primary-gradient; + backdrop-filter: blur(60px); + border-radius: 50px; +} + +.co-card-secondary { + background: $secondary-gradient; + backdrop-filter: blur(60px); + border-radius: 50px; + + & .co-card-title { + font-size: 26px; + padding: 30px 20px 24px 20px; + border-bottom: 1px solid #FFFFFF; + } + & .co-card-body { + padding: 20px 20px 32px 20px; + } +} + +.home-vote-card, +.home-vote-card-header { + height: 230px; + font-size: 36px; + font-weight: 500; +} + +.home-vote-card-header { + width: 164px; + padding: 60px 27px; + font-size: 40px; + font-weight: 600; +} + +.home-vote-card-decorator { + width: 192px; + height: 192px; + background: conic-gradient(from 228.71deg at 51.01% 51.36%, rgba(121, 81, 236, 0.7) 0deg, rgba(32, 11, 106, 0.4) 360deg); + border-radius: 50%; + + &.decorator1 { + position: absolute; + left: 180px; + top: 80px; + transform: rotate(-47.89deg) + } + + &.decorator2 { + position: absolute; + right: 0px; + bottom: -60px; + transform: rotate(90deg) + } +} + +.section-faq { + margin-top: 120px; + + & h1 { + margin-bottom: 47px; + } + + margin-bottom: 120px; +} + +.questions { + font-size: 25px; + font-weight: 500; + border: 1px solid rgba(73, 50, 126); + background: $secondary-gradient; + border-radius: 15px; + padding: 30px 26px; + cursor: pointer; + + & .arrow { + transition: transform 0.5s; + } + + &.open { + border-radius: 15px 15px 0px 0px; + + & .arrow { + transform: rotate(90deg); + } + } +} + +.qa-description { + font-size: 24px; + padding: 24px 34px; + background: $primary-gradient; + border: 1px solid rgba(73, 50, 126); + backdrop-filter: blur(60px); + border-radius: 0px 0px 15px 15px; +} + +.qa-desc-item { + position: relative; + padding-left: 30px; + &::before { + content: ''; + position: absolute; + left: 0px; + top: 10px; + width: 15px; + height: 15px; + border-radius: 50%; + background-color: $primary-color; + } +} + +footer { + padding: 150px 0px; + background: $primary-gradient; + backdrop-filter: blur(60px); +} + +.follow-icon { + & svg { + width: 20px; + height: 20px; + } +} + +.mail-icon svg { + width: 32px; +} + +.social-icon { + display: flex; + align-items: center; + justify-content: center; + width: 43px; + height: 43px; + min-width: 43px; + border-radius: 50%; + background-color: $white-color; + margin-right: 12px; + & svg { + width: 20px; + height: 20px; + } +} + +.recent-proposal-card { + position: relative; + background: $secondary-gradient; + border-radius: 50px; + overflow: hidden; + z-index: 0; + + & .card-content { + padding: 20px 30px; + } + + & .card-tag { + width: 136px; + padding: 0 16px; + text-align: center; + font-size: 36px; + line-height: 46px; + background: #200B6A; + border: 3px solid rgba(255, 255, 255, 0.14); + border-radius: 0px 50px 50px 0px; + margin-bottom: 47px; + } + + &::before { + content: ''; + position: absolute; + left: -40px; + top: -40px; + width: 80px; + height: 80px; + background: rgba(79, 58, 148, 0.46); + border-radius: 20px; + transform: rotate(75deg); + z-index: -1; + } + + &::after { + content: ''; + position: absolute; + top: 120px; + right: -70px; + width: 186px; + height: 186px; + background: rgba(127, 91, 234, 0.6); + border: 14px solid rgba(127, 91, 234, 0.3); + border-radius: 50%; + z-index: -1; + } +} + +.proposal-card { + padding: 20px; + transition: transform .5s; + cursor: pointer; + + &-image-wrapper { + width: 200px; + min-width: 200px; + height: 200px; + padding: 20px; + & img { + width: 100%; + } + } + + &-title { + font-size: 36px; + } + + &-status { + font-size: 36px; + } + + &:hover { + transform: scale(1.03); + } +} + +.proposal-status-tag { + border: 1px solid $white-color; + font-size: 24px; + border-radius: 50px; + padding: 10px 20px; + width: 182px; +} + +.proposal-timer svg { + width: 26px; + height: 26px; +} + +.recent-proposal-title { + font-weight: 500; + font-size: 41px; +} + +.recent-proposal-remaining { + font-weight: 500; + font-size: 18px; +} + +.co-pagination-page { + width: 46px; + height: 46px; + min-width: 46px; + line-height: 46px; + text-align: center; + margin: 0px 4px; + cursor: pointer; + + &.current { + border-radius: 5px; + background-color: $primary-color; + } +} + +.submit-wall-image { + width: 100%; + border-radius: 50px; + margin-top: 20px; + margin-bottom: 90px; +} + +.co-input, +.co-textarea { + width: 100%; + margin-bottom: 0px; + border: 3px solid #D6D6D6; + border-radius: 24px; + background-color: transparent; + font-size: 36px; + padding: 28px 30px; + vertical-align: middle; + color: $white-color; + + &:focus { + outline: 0; + } +} +.input-label { + font-size: 36px; +} + +.input-status { + text-align: right; +} + +.pointer { + cursor: pointer; +} + +.proposal-detail-title { + font-size: 58px; + font-weight: 600; +} + +.btn-vote { + width: 156px; +} + +.vote-badge { + background: #1D0D52; + width: 30px; + height: 30px; + font-size: 16px; + line-height: 30px; + text-align: center; + color: $white-color; + border-radius: 50%; +} + +.co-account { + font-size: 25px; + & > img { + width: 56px; + height: 56px; + min-width: 56px; + border-radius: 50%; + margin-right: 24px; + } +} + +.vote-table { + width: 100%; + & tbody > tr { + & > td { + padding: 14px 46px; + } + border-top: 1px solid $white-color; + } +} + +.label-vote-status { + font-size: 26px; + font-weight: 500; +} + +.co-progress { + & .progress { + height: 15px; + border-radius: 50px; + background-color: #C4C4C4; + + & .progress-bar { + border-radius: 50px; + } + } +} + +.round { + border-radius: 50%; +} + + +.co-modal { + position: fixed; + left: 0px; + right: 0px; + top: 0px; + bottom: 0px; + z-index: 90; + padding: $spacer; + background-color: rgba(0, 0, 0, 0.7); + overflow: auto; + cursor: default; +} + +.co-modal-mask { + position: absolute; + inset: 0%; + z-index: 91; +} + +.co-modal-container { + position: absolute; + z-index: 100; + width: calc(100% - 40px); + max-width: 680px; + margin-left: auto; + margin-right: auto; + background: linear-gradient(105.48deg, rgba(255, 255, 255, 0.45) 0.81%, rgba(255, 255, 255, 0.3) 98.62%); + opacity: 0.95; + backdrop-filter: blur(60px); + border-radius: 8px; + padding: 16px; + + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +.submit-issue-logo { + width: 100px; +} + +.logo-title { + font-size: 42px; + font-weight: 700; + + text-shadow: 1px 3px 8px $primary-color; +} + +@media (max-width: ($screen-lg - 1px)){ + + footer { + text-align: center; + padding: 40px 0px; + } +} + +@media (max-width: ($screen-md - 1px)){ + + body { + font-size: 16px; + } + + h1 { + font-size: 21px; + } + + h2 { + font-size: 28px; + } + + h3 { + font-size: 24px; + } + + h4 { + font-size: 18px; + } + + .co-card { + border-radius: 30px; + } + + .co-card-secondary { + border-radius: 30px; + + & .co-card-title { + font-size: 24px; + padding: 15px 28px 11px 28px; + } + & .co-card-body { + padding: 16px 20px; + } + } + + .section-hero { + background: transparent; + backdrop-filter: unset; + padding: 0px; + } + + .section-recent { + margin-top: 40px; + + & h1 { + margin-bottom: 16px; + } + } + + .section-how-to { + position: relative; + margin-top: 40px; + + & h1 { + margin-bottom: 12px; + } + } + + .section-faq { + margin-top: 40px; + + & h1 { + margin-bottom: 12px; + } + + margin-bottom: 50px; + } + + .hero-image-container { + width: 80%; + margin: 0 auto; + } + + .hero-description { + font-size: 32px; + margin: 16px 0 14px 0; + } + + .home-vote-card, + .home-vote-card-header { + height: 115px; + border-radius: 30px; + font-size: 19px; + } + + .home-vote-card-header { + width: 82px; + padding: 30px 14px; + font-size: 28px; + } + + .recent-proposal-card { + border-radius: 30px 50px; + + & .card-tag { + width: 120px; + font-size: 24px; + line-height: 36px; + text-align: center; + margin-bottom: 28px; + } + } + + .recent-proposal-title { + font-size: 28px; + } + + .recent-proposal-remaining { + font-size: 14px; + } + + .proposal-card { + &-image-wrapper { + width: 100px; + min-width: 100px; + height: 100px; + } + + &-title { + font-size: 14px; + } + + &-status { + font-size: 14px; + } + } + + .proposal-status-tag { + font-size: 14px; + padding: 2px 12px; + width: 80px; + } + + .proposal-timer svg { + width: 12px; + height: 12px; + } + + .follow-icon { + & svg { + width: 16px; + height: 16px; + } + } + + .mail-icon svg { + width: 18px; + } + + .social-icon { + width: 24px; + height: 24px; + min-width: 24px; + margin-right: 6px; + & svg { + width: 10px; + height: 10px; + } + } + + .co-pagination-page { + width: 30px; + height: 30px; + min-width: 30px; + line-height: 30px; + } + + .questions { + font-size: 14px; + padding: 5px 16px; + } + + .qa-description { + font-size: 13px; + padding: 6px 20px; + } + + .qa-desc-item { + padding-left: 15px; + &::before { + top: 4px; + width: 10px; + height: 10px; + } + } + + .input-label { + font-size: 14px; + } + + .co-input, + .co-textarea { + border: 1px solid #D6D6D6; + font-size: 13px; + } + + .co-input { + padding: 4px 10px; + } + + .co-textarea { + padding: 10px 10px; + } + + + .proposal-detail-title { + font-size: 27px; + } + + .btn-vote { + width: 70px; + } + + .co-account { + font-size: 14px; + & > img { + width: 32px; + height: 32px; + min-width: 32px; + margin-right: 14px; + } + } + + .vote-table { + & tbody > tr > td { + padding: 10px 16px; + } + } + + .label-vote-status { + font-size: 14px; + } + + .submit-issue-logo { + width: 60px; + } + + .logo-title { + font-size: 18px; + text-shadow: 1px 1px $primary-color; + } +} \ No newline at end of file diff --git a/components/account.js b/components/account.js new file mode 100644 index 0000000..61d118c --- /dev/null +++ b/components/account.js @@ -0,0 +1,21 @@ +import React from "react" +import imgAvatar from "../assets/image/avatar.png" +import { shortWalletAddr } from "../utils"; + +const Account = ({ + prefix, + address +}) => { + return ( +
+ + + {prefix} + {shortWalletAddr(address)} + +
+ ); +}; + +export default Account; + \ No newline at end of file diff --git a/components/footer.js b/components/footer.js new file mode 100644 index 0000000..46ec93f --- /dev/null +++ b/components/footer.js @@ -0,0 +1,116 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React from "react" +import { Col, Row } from "react-bootstrap"; +import imgLogo from '../assets/image/logo.png' + +const Footer = () => { + return ( + + ); + }; + + export default Footer; + \ No newline at end of file diff --git a/components/home/section-faq.js b/components/home/section-faq.js new file mode 100644 index 0000000..ecd7ff5 --- /dev/null +++ b/components/home/section-faq.js @@ -0,0 +1,77 @@ +import React, {useState, useEffect} from "react" + +const dataQA = [ + { + title: "What is MTVPunks DAO?", + description: ` +
MTVPunks DAO is a community-led entity with no central authority.
+
It is fully autonomous and transparent.
+
MTVPunks DAO runs with the DAO members.
+ ` + }, + { + title: "How can be a MTVPunks member?", + description: "If you own MTVPunks NFT, you can be a MTPunks DAO member." + }, + { + title: "Who can propose suggestion?", + description: " The member who has more than 20 NFTs can post proposal." + }, + { + title: "How many proposals can be posted in a day?", + description: "20 proposals will be posted in a day and a member can post a proposal in a day." + }, +] + +const SectionFAQ = () => { + + const [arrVisible, setArrVisible] = useState([]); + + useEffect(() => { + let newData = []; + for(let i = 0; i < dataQA.length; i++){ + newData.push(false) + } + setArrVisible(newData); + }, []) + + return ( +
+

FAQ

+
+ {dataQA.map((item,idx) => ( +
+
{ + let newData = [...arrVisible]; + newData[idx] = !newData[idx]; + setArrVisible(newData); + }} + > +
+ +
+ + + +
+
+
+
+ ))} +
+
+ ); + }; + + + export default SectionFAQ; + \ No newline at end of file diff --git a/components/home/section-hero.js b/components/home/section-hero.js new file mode 100644 index 0000000..2dceeb0 --- /dev/null +++ b/components/home/section-hero.js @@ -0,0 +1,33 @@ +import React from "react" +import Image from "next/image" +import { Button, Row, Col } from "react-bootstrap"; +import imgHero from "../../assets/image/hero.png" +import Link from "next/link"; + +const SectionHero = () => { + return ( +
+ + +
+ +
+ + +

MTVPunks voting purpose is to set the direction of development by the DAO members.

+

+ Anyone can be MTVPunks DAO members. +

+ + + + +
+
+ ); + }; + + export default SectionHero; + \ No newline at end of file diff --git a/components/home/section-how-to.js b/components/home/section-how-to.js new file mode 100644 index 0000000..2e2ac32 --- /dev/null +++ b/components/home/section-how-to.js @@ -0,0 +1,42 @@ +import React from "react" +import Image from "next/image" +import { Button, Row, Col } from "react-bootstrap"; + +const SectionHowTo = () => { + return ( +
+

How to vote?

+
+
+ + +
+
+ + + + + +
+
+ Sign up with metamask wallet +
+
+ + +
+
+ 123 +
+
+ Vote for your preferred proposal +
+
+ +
+
+ ); + }; + + export default SectionHowTo; + \ No newline at end of file diff --git a/components/home/section-proposal.js b/components/home/section-proposal.js new file mode 100644 index 0000000..bd1471f --- /dev/null +++ b/components/home/section-proposal.js @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from "react" +import Image from "next/image" +import Link from "next/link" +import { useRouter } from "next/router"; +import { Button, Row, Col } from "react-bootstrap"; +import Proposal from "../proposal"; +import Pagination from "../pagination"; +import ProposalApi from "../../api/ProposalApi" +import { notificationWarning } from "../../utils/notification"; + +const MAX_PROPOSAL_PER_PAGE = 3; + +const SectionProposal = () => { + const router = useRouter(); + + const [page, setPage] = useState(1) + const [pages, setPages] = useState(1) + const [proposals, setProposals] = useState([]) + const [pagedProposals, setPagedProposals] = useState([]) + + useEffect(() => { + ProposalApi.listProposal() + .then((resp) => { + const _proposals = resp.data?.data + if( _proposals ) { + setProposals(_proposals) + setPage(1) + } + }) + }, []) + + useEffect(() => { + setPages(Math.floor((proposals.length - 1) / MAX_PROPOSAL_PER_PAGE + 1)) + setPagedProposals(proposals.slice((page - 1) * MAX_PROPOSAL_PER_PAGE, page * MAX_PROPOSAL_PER_PAGE)) + }, [page, proposals]) + + const gotoSubmit = () => { + router.push(`submit`) + } + + return ( +
+ +

Proposal

+ {pagedProposals.map((item) => ( + + + + + + ))} + {pages > 1 && +
+ +
+ } +
+ ); + }; + + export default SectionProposal; + \ No newline at end of file diff --git a/components/home/section-recent.js b/components/home/section-recent.js new file mode 100644 index 0000000..45ceec6 --- /dev/null +++ b/components/home/section-recent.js @@ -0,0 +1,36 @@ +import React, {useState, useEffect} from "react" +import Image from "next/image" +import { Button, Row, Col } from "react-bootstrap"; +import RecentProposal from "../recent-proposal"; +import ProposalApi from "../../api/ProposalApi"; + +const SectionRecent = () => { + + const [proposals, setProposals] = useState([]) + + useEffect(() => { + ProposalApi.getRecentPassed() + .then((resp) => { + const _proposals = resp.data?.data + if( _proposals ) { + setProposals(_proposals) + } + }) + }, []) + + return ( +
+

Recent Passed Proposal

+ + {proposals?.map((item) => ( + + + + ))} + +
+ ); + }; + + export default SectionRecent; + \ No newline at end of file diff --git a/components/layout.js b/components/layout.js new file mode 100644 index 0000000..735d568 --- /dev/null +++ b/components/layout.js @@ -0,0 +1,15 @@ +import {useState, useEffect, useRef} from "react" +import Footer from "./footer" +import Navbar from './navbar' + +export default function Layout({ children }) { + return ( +
+ +
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/components/loading.js b/components/loading.js new file mode 100644 index 0000000..b788b58 --- /dev/null +++ b/components/loading.js @@ -0,0 +1,18 @@ +import React from "react" + +const Loading = () => { + return ( +
+
+
+
+
+
+
+
+
+ ); + }; + + export default Loading; + \ No newline at end of file diff --git a/components/navbar.js b/components/navbar.js new file mode 100644 index 0000000..43b73d4 --- /dev/null +++ b/components/navbar.js @@ -0,0 +1,107 @@ +import React, {useState, useEffect} from "react" +import { Navbar as BNavbar, Nav } from 'react-bootstrap' +import Link from 'next/link' +import { useRouter } from "next/router"; +import imgLogo from '../assets/image/logo.png' +import { Button, Row, Col } from "react-bootstrap" +import useWeb3 from "../shared/hooks/useWeb3" +import useContracts from "../shared/hooks/useContracts" +import { shortWalletAddr } from "../utils" +import addresses from "../shared/addresses" + +const NavBar = () => { + + const { connected, connecting, handleConnect, handleDisconnect, switchNetwork, chainId, walletAddress } = useWeb3() + const { getBalance } = useContracts() + + const [votes, setVotes] = useState() + const handleConnectClick = () => { + if(!connected) + handleConnect() + } + + useEffect(async () => { + if(connected) { + const balance = await getBalance() + setVotes(balance) + } + }, [getBalance]) + + return ( + +
+ +
MTVPunks
+
+ + + + + + + + + + + + + + +
MTVPunks
+
+ + + +
+ + + + +
+
+ ); + }; + + export default NavBar; + \ No newline at end of file diff --git a/components/pagination.js b/components/pagination.js new file mode 100644 index 0000000..735b042 --- /dev/null +++ b/components/pagination.js @@ -0,0 +1,81 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from 'react-bootstrap'; + +const Pagination = function({ + page, + pages, + onChange, + className='' +}) { + + const [arrPages, setArrPages] = useState([]) + + const handleChangePage = (idx) => { + idx = idx < 1 ? 1 : (idx > pages ? pages : idx) + if(idx === page) return; + if(onChange) onChange(idx) + } + + useEffect(() => { + if( pages <= 7 ) { + setArrPages(Array.from({length: pages}, (_, i) => i + 1)) + } else { + let arrNewPages = []; + arrNewPages.push(1) + + if(page > 3) + arrNewPages.push(0) + + if(page > 2) + arrNewPages.push(page - 1) + + if(page !== 1 && page !== pages) + arrNewPages.push(page) + + if(page < pages - 1) + arrNewPages.push(page + 1) + + if(page < pages - 2) + arrNewPages.push(0) + + arrNewPages.push(pages) + + setArrPages(arrNewPages) + } + }, [page, pages]) + + return ( +
+
handleChangePage(page - 1)}> + + + +
+ {arrPages.map((val, idx) => { + if(val === 0) { + return ( +
+ ... +
+ ) + } + return ( +
handleChangePage(val)} + key={`pagination-${val}`} + > + {val} +
+ ) + })} +
handleChangePage(page + 1)}> + + + +
+
+ ); +} + +export default Pagination; diff --git a/components/progress.js b/components/progress.js new file mode 100644 index 0000000..882754c --- /dev/null +++ b/components/progress.js @@ -0,0 +1,20 @@ +import React from "react" +import { ProgressBar } from "react-bootstrap"; + +const Progress = ({ + label, + value +}) => { + return ( +
+
+
{label}
+
{value.toFixed(2)}%
+
+ +
+ ); +}; + +export default Progress; + \ No newline at end of file diff --git a/components/proposal-status.js b/components/proposal-status.js new file mode 100644 index 0000000..a1d62e9 --- /dev/null +++ b/components/proposal-status.js @@ -0,0 +1,26 @@ +import React from "react" + +const STATUS_ACCEPTED = 1 +const STATUS_REJECTED = 2 +const STATUS_NOT_APPLIED = 3 + +const ProposalStatus = ({ + status +}) => { + + const getStatus = () => { + if(status === STATUS_NOT_APPLIED) return 'Not Applied' + if(status === STATUS_ACCEPTED) return 'Passed' + if(status === STATUS_REJECTED) return 'Rejected' + + return 'Active' + } + return ( +
+ {getStatus()} +
+ ); +}; + +export default ProposalStatus; + \ No newline at end of file diff --git a/components/proposal.js b/components/proposal.js new file mode 100644 index 0000000..3db8d4f --- /dev/null +++ b/components/proposal.js @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from "react" +import imgPropIcon01 from "../assets/image/proposal-icon-01.svg" +import imgPropIcon02 from "../assets/image/proposal-icon-02.svg" +import imgPropIcon03 from "../assets/image/proposal-icon-03.svg" +import useContracts from "../shared/hooks/useContracts"; +import { datetime2str, remainingTime } from "../utils"; +import ProposalStatus from "./proposal-status"; + +const STATUS_ACCEPTED = 1 +const STATUS_REJECTED = 2 +const STATUS_NOT_APPLIED = 3 + +const ProposalItem = ({ + title, createdAt, leading, status +}) => { + const { PROPOSAL_LIFETIME } = useContracts() + const [timeRemaining, setTimeRemaining] = useState() + + useEffect(() => { + const startedTime = new Date(createdAt) + const endTime = new Date(startedTime.getTime() + PROPOSAL_LIFETIME) + const now = new Date() + if( createdAt ) { + const remaining = endTime - now.getTime() + if(remaining > 0) { + setTimeRemaining(remainingTime(endTime - now.getTime())) + } else { + setTimeRemaining() + } + } + }, [createdAt]) + + + return ( +
+
+ {status === STATUS_ACCEPTED ? + : + status === STATUS_REJECTED ? + : + + } +
+
+
+ {title} +
+
+ Leading: {leading ? 'Yes' : 'No'} +
+ +
+ +
+
+ + + + +
+ + + {timeRemaining ? `Ends in ${timeRemaining}` : 'Ended'} + +
+
+
+
+ ); +}; + +export default ProposalItem; + \ No newline at end of file diff --git a/components/recent-proposal.js b/components/recent-proposal.js new file mode 100644 index 0000000..2ebb30c --- /dev/null +++ b/components/recent-proposal.js @@ -0,0 +1,33 @@ +import React, { useState, useEffect } from "react" +import useContracts from "../shared/hooks/useContracts" + +const RecentProposal = ({ proposal }) => { + + const [ acceptedPercent, setAcceptedPercent] = useState(0) + const { getVoting } = useContracts() + + useEffect(() => { + async function fetchData() { + const result = await getVoting(proposal.id) + if(result) + setAcceptedPercent(result.total === 0 ? 0 : result.accepted * 100 / result.total) + } + fetchData() + }, [getVoting, proposal]) + + return ( +
+
+
+
{proposal.name}
+
{proposal.finished_at}
+
+
+
+ {acceptedPercent}% +
+
+ ); +}; + +export default RecentProposal; \ No newline at end of file diff --git a/config/default.json b/config/default.json new file mode 100644 index 0000000..911190e --- /dev/null +++ b/config/default.json @@ -0,0 +1,9 @@ +{ + "DB_CONNECTION_STRING": "postgres://doadmin:admin@localhost:5432/mtvpunks", + "DB_HOST": "localhost", + "DB_USER": "root", + "DB_PASSWORD": "", + "DB_DATABASE": "mtvpunks", + "JWT_SECRET": "123456", + "NONCE_SECRET": "123123123123" +} \ No newline at end of file diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..38a7aa5 --- /dev/null +++ b/config/index.js @@ -0,0 +1,3 @@ +const dev = process.env.NODE_ENV !== 'production'; + +export const server = dev ? 'http://localhost:3000' : 'http://localhost:3000'; \ No newline at end of file diff --git a/db.js b/db.js new file mode 100644 index 0000000..ecbf635 --- /dev/null +++ b/db.js @@ -0,0 +1,18 @@ +const config = require('config'); +const promise = require('bluebird'); + +const initOptions = { + promiseLib: promise, // overriding the default (ES6 Promise); + // eslint-disable-next-line no-unused-vars + error(error, e) { + // eslint-disable-next-line no-param-reassign + error.DB_ERROR = true; + }, +}; + +const pgp = require('pg-promise')(initOptions); + +// const db = pgp({ connectionString: config.get('DB_CONNECTION_STRING'), ssl: { rejectUnauthorized: false } }); +const db = pgp({ connectionString: config.get('DB_CONNECTION_STRING') }); + +module.exports = db; diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..ffe116c --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,28 @@ +const jwt = require('jsonwebtoken'); +const config = require('config'); + +export const useAuth = (req, res, next) => { + if(req?.headers === undefined) + return res.status(401).send(); + + const { authorization } = req.headers; + if (!authorization) { + return res.status(401).send(); + } + + const token = authorization.split(' ')[1]; + + jwt.verify(token, config.get('JWT_SECRET'), async (error, payload) => { + if (error) { + return res.status(403).send(); + } + + const { id, address } = payload; + req.userId = id; + req.address = address; + + return next(); + }); + + return res.status(401); +}; diff --git a/middleware/index.js b/middleware/index.js new file mode 100644 index 0000000..681931b --- /dev/null +++ b/middleware/index.js @@ -0,0 +1,11 @@ +export function runMiddleware(req, res, fn) { + return new Promise((resolve, reject) => { + fn(req, res, (result) => { + if (result instanceof Error) { + return reject(result) + } + + return resolve(result) + }) + }) +} \ No newline at end of file diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..ccc562e --- /dev/null +++ b/next.config.js @@ -0,0 +1,18 @@ +const withImages = require('next-images') +const path = require('path') + +module.exports = withImages({ + sassOptions: { + includePaths: [path.join(__dirname, 'assets/styles')], + }, + images: { + loader: 'imgix', + path: '/', + disableStaticImages: true, + }, + eslint: { + // Warning: This allows production builds to successfully complete even if + // your project has ESLint errors. + ignoreDuringBuilds: true, + } +}) \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..4ef28b7 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "mtvpunk-vote", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "export": "next export" + }, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^1.2.36", + "@fortawesome/free-brands-svg-icons": "^5.15.4", + "@fortawesome/free-solid-svg-icons": "^5.15.4", + "@fortawesome/react-fontawesome": "^0.1.16", + "@walletconnect/web3-provider": "^1.6.6", + "axios": "^0.26.1", + "bluebird": "^3.7.2", + "body-parser": "^1.19.2", + "bootstrap": "^5.1.3", + "config": "^3.3.7", + "cors": "^2.8.5", + "crypto": "^1.0.1", + "eth-sig-util": "^3.0.1", + "ethereumjs-util": "^7.1.4", + "ethers": "^5.5.2", + "helmet": "^5.0.2", + "joi": "^17.6.0", + "jsonwebtoken": "^8.5.1", + "next": "12.0.4", + "next-images": "^1.8.2", + "pg-promise": "^10.11.1", + "react": "17.0.2", + "react-bootstrap": "^2.0.2", + "react-dom": "17.0.2", + "react-notifications-component": "^3.4.1", + "sass": "^1.44.0", + "uuid": "^8.3.2", + "web3modal": "^1.9.5" + }, + "devDependencies": { + "eslint": "7", + "eslint-config-next": "12.0.4" + } +} diff --git a/pages/_app.js b/pages/_app.js new file mode 100644 index 0000000..905a295 --- /dev/null +++ b/pages/_app.js @@ -0,0 +1,60 @@ +import React, {useState, useEffect} from "react" +import Router from "next/router"; +import Head from 'next/head' +import Layout from '../components/layout' +import Loading from '../components/loading' +import { ReactNotifications } from 'react-notifications-component' +import { library } from '@fortawesome/fontawesome-svg-core' +import { fab } from '@fortawesome/free-brands-svg-icons' +import { faEnvelope } from '@fortawesome/free-solid-svg-icons'; +import { Web3Provider } from "../shared/context/Web3"; +import { ContractsProvider } from "../shared/context/Contracts"; +import '../assets/styles/globals.scss' +import 'react-notifications-component/dist/theme.css' + +library.add(fab, faEnvelope) + +function MyApp({ Component, pageProps }) { + + const [loading, setLoading] = useState(false); + useEffect(() => { + const start = () => { + setLoading(true); + }; + const end = () => { + setLoading(false); + }; + Router.events.on("routeChangeStart", start); + Router.events.on("routeChangeComplete", end); + Router.events.on("routeChangeError", end); + return () => { + Router.events.off("routeChangeStart", start); + Router.events.off("routeChangeComplete", end); + Router.events.off("routeChangeError", end); + } + }, []) + + return ( + <> + + MPVPunks + + + + {loading ? ( + + ) : ( + + + + + + + + + )} + + ) +} + +export default MyApp diff --git a/pages/api/auth/check-jwt.js b/pages/api/auth/check-jwt.js new file mode 100644 index 0000000..687e36d --- /dev/null +++ b/pages/api/auth/check-jwt.js @@ -0,0 +1,30 @@ +import { runMiddleware } from "../../../middleware" +import { useAuth } from "../../../middleware/auth" +import joi from "joi" + +export default async function handler(req, res) { + if (req.method === 'POST') { + + await runMiddleware(req, res, useAuth) + + const schema = joi.object({ + address: joi.string().trim(true).required() + }); + + const { error } = schema.validate(req.body); + if (error) { + // TODO return better error messages + return res.status(400).json({ errorMessage: 'error with input' }); + } + + const { address } = req.body; + + if(req.address !== address) + return res.status(401).json({ errorMessage: 'error with jwt' }); + + return res.status(200).json({ success: true }); + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/api/auth/get-nonce.js b/pages/api/auth/get-nonce.js new file mode 100644 index 0000000..f1acdda --- /dev/null +++ b/pages/api/auth/get-nonce.js @@ -0,0 +1,23 @@ +import joi from "joi" +import userService from "../../../services/userService" + +export default async function handler(req, res) { + if (req.method === 'POST') { + + const schema = joi.object({ + address: joi.string().trim(true).required() + }); + + const { error } = schema.validate(req.body); + if (error) { + // TODO return better error messages + return res.status(400).json({ errorMessage: 'error with input' }); + } + + const user = await userService.createUser(req.body.address.trim()); + return res.status(200).json({nonce: user.nonce}); + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/api/auth/signin.js b/pages/api/auth/signin.js new file mode 100644 index 0000000..2351517 --- /dev/null +++ b/pages/api/auth/signin.js @@ -0,0 +1,34 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction + +import joi from "joi" +import jwt from "jsonwebtoken" +import config from "config" +import userService from "../../../services/userService" + +export default async function handler(req, res) { + if (req.method === 'POST') { + + const schema = joi.object({ + address: joi.string().trim(true).required(), + signature: joi.string().required(), + }); + + const { error } = schema.validate(req.body); + if (error) { + // TODO return better error messages + return res.status(400).send(); + } + + const user = await userService.verifySignature(req.body.address.trim(), req.body.signature.trim()); + if (user === null) { + return res.status(401).send(); + } + + // TODO set expire time to be 24 hours + const token = jwt.sign({ id: user.id, address: req.body.address }, config.get('JWT_SECRET')); + + return res.status(200).json({ success: true, jwt: token }); + } + + return res.status(405).send({ message: 'Wrong request' }) +} diff --git a/pages/api/proposal/[id]/finish.js b/pages/api/proposal/[id]/finish.js new file mode 100644 index 0000000..a6a6cd1 --- /dev/null +++ b/pages/api/proposal/[id]/finish.js @@ -0,0 +1,27 @@ +import joi from "joi" +import proposalService from "../../../../services/proposalService" + +export default async function handler(req, res) { + const { id } = req.query; + + if (req.method === "POST") { + const schema = joi.object({ + status: joi.number().required(), + }); + + const { error } = schema.validate(req.body); + if (error) { + // TODO return better error messages + return res.status(400).json({ errorMessage: 'error with input' }); + } + + const { status } = req.body; + + await proposalService.setProposalFinished(id, status) + + return res.status(200).send({success: true}) + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/api/proposal/[id]/index.js b/pages/api/proposal/[id]/index.js new file mode 100644 index 0000000..219e281 --- /dev/null +++ b/pages/api/proposal/[id]/index.js @@ -0,0 +1,20 @@ + +import proposalService from "../../../../services/proposalService" + +export default async function handler(req, res) { + + if (req.method === "GET") { + // await runMiddleware(req, res, useAuth) + const { id } = req.query; + + const proposal = await proposalService.getProposal(id) + + if(!proposal) + return res.status(404).send({errorMessage: 'Not Found'}) + + return res.status(200).send(proposal) + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/api/proposal/[id]/vote.js b/pages/api/proposal/[id]/vote.js new file mode 100644 index 0000000..bbd132b --- /dev/null +++ b/pages/api/proposal/[id]/vote.js @@ -0,0 +1,42 @@ +import { runMiddleware } from "../../../../middleware" +import { useAuth } from "../../../../middleware/auth" +import voteService from "../../../../services/voteService" +import joi from "joi" + +export default async function handler(req, res) { + const { id } = req.query; + + if (req.method === "GET") { + const votes = await voteService.getVotesOfProposal(id) + + if(!votes) + return res.status(404).send({errorMessage: 'Not Found'}) + + return res.status(200).send(votes) + } else if (req.method === "POST") { + await runMiddleware(req, res, useAuth) + + const schema = joi.object({ + address: joi.string().trim(true).required(), + vote: joi.number().required(), + }); + + const { error } = schema.validate(req.body); + if (error) { + // TODO return better error messages + return res.status(400).json({ errorMessage: 'error with input' }); + } + + const { address, vote } = req.body; + + if(req.address !== address) + return res.status(401).json({ errorMessage: 'error with jwt' }); + + const voteId = await voteService.voteProposal(req.userId, id, vote) + + return res.status(200).send({success: true, id: voteId}) + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/api/proposal/active.js b/pages/api/proposal/active.js new file mode 100644 index 0000000..57f9ba3 --- /dev/null +++ b/pages/api/proposal/active.js @@ -0,0 +1,12 @@ +import proposalService from "../../../services/proposalService" + +export default async function handler(req, res) { + + if (req.method === "GET") { + const proposals = await proposalService.getActiveProposals() + return res.status(200).send({data: proposals}) + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/api/proposal/index.js b/pages/api/proposal/index.js new file mode 100644 index 0000000..4b4ca47 --- /dev/null +++ b/pages/api/proposal/index.js @@ -0,0 +1,41 @@ +import { runMiddleware } from "../../../middleware" +import { useAuth } from "../../../middleware/auth" +import proposalService from "../../../services/proposalService" +import joi from "joi" + +export default async function handler(req, res) { + + if (req.method === "GET") { + const proposals = await proposalService.listProposals() + return res.status(200).send({data: proposals}) + } else if (req.method === 'POST') { + await runMiddleware(req, res, useAuth) + + const schema = joi.object({ + address: joi.string().trim(true).required(), + title: joi.string().trim(true).required(), + description: joi.string().trim(true).required() + }); + + const { error } = schema.validate(req.body); + if (error) { + // TODO return better error messages + return res.status(400).json({ errorMessage: 'error with input' }); + } + + const { address, title, description } = req.body; + + if(req.address !== address) + return res.status(401).json({ errorMessage: 'error with jwt' }); + + const remainTime = await proposalService.getRemainTimeToSubmit(req.userId) + if(remainTime > 0) + return res.status(400).json({ errorMessage: 'need to wait to submit next proposal' }); + + const id = await proposalService.createProposal(req.userId, title, description) + return res.status(200).send({success: true, id: id}) + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/api/proposal/recent-passed.js b/pages/api/proposal/recent-passed.js new file mode 100644 index 0000000..71e7f80 --- /dev/null +++ b/pages/api/proposal/recent-passed.js @@ -0,0 +1,12 @@ +import proposalService from "../../../services/proposalService" + +export default async function handler(req, res) { + + if (req.method === "GET") { + const proposals = await proposalService.getRecentPassed() + return res.status(200).send({data: proposals}) + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/api/proposal/remain-time.js b/pages/api/proposal/remain-time.js new file mode 100644 index 0000000..c22f52b --- /dev/null +++ b/pages/api/proposal/remain-time.js @@ -0,0 +1,17 @@ +import proposalService from "../../../services/proposalService" +import { runMiddleware } from "../../../middleware" +import { useAuth } from "../../../middleware/auth" + +export default async function handler(req, res) { + + if (req.method === "GET") { + await runMiddleware(req, res, useAuth) + + const time = await proposalService.getRemainTimeToSubmit(req.userId) + + return res.status(200).send({data: time}) + } + + return res.status(405).send({ message: 'Wrong request' }) +} + \ No newline at end of file diff --git a/pages/index.js b/pages/index.js new file mode 100644 index 0000000..a2be8ef --- /dev/null +++ b/pages/index.js @@ -0,0 +1,19 @@ + +import SectionHero from "../components/home/section-hero" +import SectionFAQ from "../components/home/section-faq" +import SectionRecent from "../components/home/section-recent" +import SectionHowTo from "../components/home/section-how-to" +import SectionProposal from "../components/home/section-proposal" + +export default function Home(props) { + + return ( + <> + + + + + + + ) +} diff --git a/pages/proposal/[id].js b/pages/proposal/[id].js new file mode 100644 index 0000000..eb076c8 --- /dev/null +++ b/pages/proposal/[id].js @@ -0,0 +1,255 @@ +import React, {useState, useEffect} from "react" +import { useRouter } from 'next/router' +import { Button, Row, Col, Spinner } from "react-bootstrap" +import ProposalStatus from "../../components/proposal-status" +import Account from "../../components/account" +import { formatMoney, datetime2str } from "../../utils" +import Progress from "../../components/progress" +import { server } from "../../config" +import useContracts from '../../shared/hooks/useContracts' +import useWeb3 from '../../shared/hooks/useWeb3' +import ProposalApi from "../../api/ProposalApi" +import { notificationWarning, notificationSuccess, notificationDanger } from "../../utils/notification" +import imgAvatar from "../../assets/image/avatar.png" +import imgStatus from "../../assets/image/status.svg" + +const STATUS_NOT_APPLIED = 0 +const STATUS_ACCEPTED = 1 +const STATUS_REJECTED = 2 +const PROPOSAL_LIFETIME = 3600 * 24 * 7 * 1000; // 7 days + +export async function getServerSideProps(context) { + const res = await fetch(`${server}/api/proposal/${context.params.id}`) + const data = await res.json() + + if( res.status !== 200 ) { + return { + notFound: true, + }; + } + + const resVote = await fetch(`${server}/api/proposal/${context.params.id}/vote`) + const votes = await resVote.json() + + if( res.status !== 200 ) { + return { + notFound: true, + }; + } + + return { + props: { proposal: data, votes: votes }, // will be passed to the page component as props + } +} + +export default function ProposalDetails({ proposal, votes }) { + const router = useRouter() + const { id } = router.query + const { connected, walletAddress, handleConnect, waitForTransaction } = useWeb3() + const { getTotalSupply, getVoting, getVoteOf, voteProposal, executeVoting } = useContracts() + + const [pending, setPending] = useState(false) + const [pendingNo, setPendingNo] = useState(false) + const [totalSupply, setTotalSupply] = useState() + const [voteStatus, setVoteStatus] = useState() + const [myVoteStatus, setMyVoteStatus] = useState() + + useEffect(async () => { + const supply = await getTotalSupply() + setTotalSupply(supply) + }, [getTotalSupply]) + + useEffect(async () => { + const result = await getVoting(id) + if(result) + setVoteStatus(result) + }, [getVoting]) + + useEffect(async () => { + const result = await getVoteOf(id) + setMyVoteStatus(result) + }, [getVoteOf]) + + // const exeVoting = () => { + // executeVoting(id) + // } + + const voteAgree = () => { + voteProp(STATUS_ACCEPTED) + } + + const voteDisagree = () => { + voteProp(STATUS_REJECTED) + } + + const voteProp = (vote) => { + if(!connected || !walletAddress) { + return handleConnect() + } + if(vote === STATUS_ACCEPTED) + setPending(true) + else + setPendingNo(true) + + voteProposal(id, vote).then((result) => { + if(result?.hash){ + waitForTransaction(result.hash, 1000) + .then(() => { + ProposalApi.vote(id, walletAddress, vote) + .then((resp) => { + setPending(false) + setPendingNo(false) + if(resp.data?.success) { + notificationSuccess("Success to vote proposal") + router.reload() + } else { + notificationDanger("Failed to save database") + } + }) + .catch((error) => { + console.log(error) + setPending(false) + setPendingNo(false) + notificationDanger("Failed to save database") + }) + }) + .catch((error) => { + setPending(false) + setPendingNo(false) + notificationDanger("Failed transaction"); + }) + } else { + setPending(false) + setPendingNo(false) + notificationDanger("Failed to vote proposal") + } + }) + .catch((e) => { + setPending(false) + notificationDanger("Canceled to vote proposal") + }) + } + + return ( +
+ + +
+
+ {proposal.name} +
+ + + + + + + + +
+ {proposal.description} +
+ {myVoteStatus === STATUS_NOT_APPLIED && +
+ + +
+ } +
+ +
+
+

Votes

+
{votes?.length}
+
+ + + {votes?.map((vote) => ( + + + + + ))} + +
+ + + {vote.vote === STATUS_ACCEPTED ? 'Agree' : 'Disagree'} +
+
+ + +
+
Information
+
+
+
Strategie(S)
+
+ + + +
+
+
+
Voting System
+
Weighted voting
+
+
+
Start date
+
{datetime2str(proposal.created_at)}
+
+
+
End date
+
{datetime2str((new Date(proposal.created_at)).getTime() + PROPOSAL_LIFETIME)}
+
+
+
Minted NFTs
+
{formatMoney(totalSupply)}
+
+
+
+ +
+
Vote
+
+ {voteStatus && + <> + +
+ +
+ + } +
+
+ +
+
Your Vote Status
+
+ +
+ { + myVoteStatus === STATUS_ACCEPTED ? + 'You voted for this proposal' : + myVoteStatus === STATUS_REJECTED ? + 'You rejected for this proposal' : + "You haven't applied yet" + } +
+
+
+ +
+
+ ) +} diff --git a/pages/submit.js b/pages/submit.js new file mode 100644 index 0000000..1a527b2 --- /dev/null +++ b/pages/submit.js @@ -0,0 +1,147 @@ +import React, {useState, useEffect} from "react" +import { useRouter } from 'next/router' +import imgWall from "../assets/image/submit-wall.jpg" +import { Button, Spinner } from "react-bootstrap" +import { notificationWarning, notificationSuccess, notificationDanger } from "../utils/notification" +import ProposalApi from "../api/ProposalApi" +import useWeb3 from "../shared/hooks/useWeb3" +import useContracts from "../shared/hooks/useContracts" +import { remainingTime } from "../utils"; + +export default function Submit(props) { + const router = useRouter() + const [question, setQuestion] = useState('') + const [description, setDescription] = useState('') + + const [pending, setPending] = useState(false) + const [modalVisible, setModalVisible] = useState(false) + const [remainTime, setRemainTime] = useState(0) + const { connected, walletAddress, handleConnect } = useWeb3() + const { startVoting } = useContracts() + + useEffect(() => { + ProposalApi.getRemainingTimeForNext() + .then((resp) => { + const _remainTime = resp.data?.data + if(_remainTime > 0) { + setModalVisible(true) + setRemainingTime(_remainTime) + } + }) + .catch((e) => { + setModalVisible(true) + }) + }, []) + + const goBack = () => { + router.back() + } + + const handleSubmit = () => { + if(!connected) { + return handleConnect() + } + if(modalVisible) return; + if(!walletAddress) return; + + if(question.length === 0) { + return notificationWarning("Please input question") + } + + if(description.length === 0) { + return notificationWarning("Please input description") + } + setPending(true) + + startVoting(question).then((result) => { + if(result){ + ProposalApi.createProposal(walletAddress, question, description) + .then((resp) => { + setPending(false) + if(resp.data?.success) { + notificationSuccess("Success to submit proposal") + router.push(`proposal/${resp.data.id}`) + } else { + notificationDanger("Failed to submit proposal") + } + }) + .catch((error) => { + setPending(false) + notificationDanger("Failed to submit proposal") + }) + } else { + setPending(false) + notificationDanger("Failed to submit proposal") + } + }) + .catch((e) => { + setPending(false) + notificationDanger("Canceled to submit proposal") + }) + } + + return ( +
+ {modalVisible && +
+
+
+
+
+ + + + + +
+
+ You have already posted today...
+ {remainTime ? `${remainingTime(remainTime)} until next submit` : ''} +
+ +
+
+ } + +
The question You Would like to ask the community.
+ setQuestion(e.target.value)} + /> +
+ ({question.length} out of 200 characters) +
+
+
Description(markdawn)
+
+ Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, +
+
+