├── .env.example
├── .eslintrc.cjs
├── .gitignore
├── LICENSE
├── README.md
├── bun.lockb
├── docker-compose.yml
├── docs
├── 01_setup.md
├── 02_adding_projects.md
├── 03_creating_badgeholders.md
├── 04_voting.md
├── 06_results.md
├── 07_distribute.md
└── images
│ ├── create_allo_profile.png
│ ├── create_pool.png
│ ├── create_postgres.png
│ ├── create_postgres_details.png
│ ├── deploy_repo.png
│ ├── distribute.png
│ ├── distribute_modal.png
│ ├── github_add_file.png
│ ├── screenshot.png
│ ├── screenshot_landing.png
│ ├── screenshot_landing_project.png
│ ├── stats.png
│ ├── vercel_configure.png
│ ├── vercel_new.png
│ ├── voting_ended_sidebar.png
│ ├── walletconnect_create.png
│ ├── walletconnect_create2.png
│ ├── walletconnect_information.png
│ └── walletconnect_project.png
├── funding.json
├── next.config.js
├── package.json
├── postcss.config.cjs
├── prettier.config.js
├── prisma
└── schema.prisma
├── public
├── .well-known
│ └── walletconnect.txt
├── favicon.svg
└── mockServiceWorker.js
├── src
├── __tests__
│ └── index.spec.tsx
├── components
│ ├── AllocationInput.tsx
│ ├── AllocationList.tsx
│ ├── ConnectButton.tsx
│ ├── ENS.tsx
│ ├── EligibilityDialog.tsx
│ ├── EmptyState.tsx
│ ├── EnsureCorrectNetwork.tsx
│ ├── EnureCorrectNetwork.tsx
│ ├── Footer.tsx
│ ├── Header.tsx
│ ├── ImageUpload.tsx
│ ├── InfiniteLoading.tsx
│ ├── NumberInput.tsx
│ ├── SortByDropdown.tsx
│ ├── SortFilter.tsx
│ ├── Toaster.tsx
│ └── ui
│ │ ├── Alert.tsx
│ │ ├── Avatar.tsx
│ │ ├── BackgroundImage.tsx
│ │ ├── Badge.tsx
│ │ ├── Banner.tsx
│ │ ├── Button.tsx
│ │ ├── Calendar.tsx
│ │ ├── Card.tsx
│ │ ├── Chip.tsx
│ │ ├── DatePicker.tsx
│ │ ├── Dialog.tsx
│ │ ├── Divider.tsx
│ │ ├── Form.tsx
│ │ ├── Heading.tsx
│ │ ├── Link.tsx
│ │ ├── Markdown.tsx
│ │ ├── Progress.tsx
│ │ ├── Skeleton.tsx
│ │ ├── Spinner.tsx
│ │ ├── Table.tsx
│ │ ├── Tag.tsx
│ │ └── index.tsx
├── config.ts
├── env.js
├── features
│ ├── admin
│ │ ├── components
│ │ │ ├── AddAddresses.tsx
│ │ │ ├── CalculationForm.tsx
│ │ │ ├── FormTokenSymbol.tsx
│ │ │ └── ImportVotersCSV.tsx
│ │ └── layouts
│ │ │ └── AdminLayout.tsx
│ ├── applications
│ │ ├── components
│ │ │ ├── ApplicationForm.tsx
│ │ │ ├── ApplicationsToApprove.tsx
│ │ │ ├── ApproveButton.tsx
│ │ │ ├── CeloApplicationForm.tsx
│ │ │ └── DripsApplicationForm.tsx
│ │ ├── hooks
│ │ │ ├── useApplications.ts
│ │ │ ├── useApproveApplication.ts
│ │ │ ├── useApprovedApplications.ts
│ │ │ └── useCreateApplication.ts
│ │ └── types
│ │ │ └── index.ts
│ ├── ballot
│ │ ├── components
│ │ │ ├── BallotAllocationForm.tsx
│ │ │ ├── BallotConfirmation.tsx
│ │ │ ├── BallotOverview.tsx
│ │ │ └── VotingEndsIn.tsx
│ │ ├── hooks
│ │ │ └── useBallot.ts
│ │ └── types
│ │ │ └── index.ts
│ ├── comments
│ │ ├── components
│ │ │ └── ProjectComments.tsx
│ │ └── types
│ │ │ └── index.ts
│ ├── distribute
│ │ ├── components
│ │ │ ├── ConfirmDistributionDialog.tsx
│ │ │ ├── CreatePool.tsx
│ │ │ ├── Distributions.tsx
│ │ │ ├── ExportCSV.tsx
│ │ │ └── ImportCSV.tsx
│ │ ├── hooks
│ │ │ ├── useAllo.ts
│ │ │ ├── useAlloPool.ts
│ │ │ ├── useAlloProfile.ts
│ │ │ └── useDistribute.ts
│ │ ├── types
│ │ │ └── index.ts
│ │ └── utils
│ │ │ └── calculatePayout.tsx
│ ├── filter
│ │ ├── hooks
│ │ │ └── useFilter.ts
│ │ └── types
│ │ │ └── index.ts
│ ├── info
│ │ └── components
│ │ │ └── RoundProgress.tsx
│ ├── profile
│ │ └── components
│ │ │ └── MyApplications.tsx
│ ├── projects
│ │ ├── components
│ │ │ ├── AddToBallot.tsx
│ │ │ ├── BioInfo.tsx
│ │ │ ├── ImpactCategories.tsx
│ │ │ ├── InfoBox.tsx
│ │ │ ├── LinkBox.tsx
│ │ │ ├── ProjectAvatar.tsx
│ │ │ ├── ProjectAwarded.tsx
│ │ │ ├── ProjectBanner.tsx
│ │ │ ├── ProjectContributions.tsx
│ │ │ ├── ProjectDetails.tsx
│ │ │ ├── ProjectImpact.tsx
│ │ │ ├── ProjectItem.tsx
│ │ │ ├── ProjectSelectButton.tsx
│ │ │ ├── Projects.tsx
│ │ │ ├── ProjectsResults.tsx
│ │ │ └── ProjectsSearch.tsx
│ │ └── hooks
│ │ │ ├── useProjects.ts
│ │ │ └── useSelectProjects.ts
│ ├── results
│ │ └── components
│ │ │ └── Chart.tsx
│ ├── rounds
│ │ ├── hooks
│ │ │ ├── useIsShowActualVotes.ts
│ │ │ ├── useRound.ts
│ │ │ └── useRoundState.ts
│ │ └── types
│ │ │ └── index.ts
│ ├── stats
│ │ └── utils
│ │ │ └── generateResultsChartData.ts
│ └── voters
│ │ └── hooks
│ │ ├── useApproveVoters.ts
│ │ ├── useApprovedVoter.ts
│ │ ├── useImportVoters.ts
│ │ └── useVotesCount.ts
├── hooks
│ ├── useEAS.ts
│ ├── useEthersSigner.ts
│ ├── useIsAdmin.ts
│ ├── useIsCorrectNetwork.ts
│ ├── useMetadata.ts
│ ├── useProfile.ts
│ ├── useResults.ts
│ ├── useRevokeAttestations.ts
│ ├── useRoundType.ts
│ └── useWatch.ts
├── layouts
│ ├── BaseLayout.tsx
│ └── DefaultLayout.tsx
├── lib
│ └── eas
│ │ ├── __test__
│ │ └── eas.test.ts
│ │ ├── createAttestation.ts
│ │ ├── createEAS.ts
│ │ └── registerSchemas.ts
├── pages
│ ├── [domain]
│ │ ├── admin
│ │ │ ├── accounts
│ │ │ │ └── index.tsx
│ │ │ ├── applications
│ │ │ │ └── index.tsx
│ │ │ ├── distribute
│ │ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ ├── phases
│ │ │ │ └── index.tsx
│ │ │ ├── token
│ │ │ │ └── index.tsx
│ │ │ └── voters
│ │ │ │ └── index.tsx
│ │ ├── applications
│ │ │ ├── [projectId]
│ │ │ │ └── index.tsx
│ │ │ └── new.tsx
│ │ ├── ballot
│ │ │ ├── confirmation.tsx
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ ├── profile
│ │ │ └── index.tsx
│ │ ├── projects
│ │ │ ├── [projectId]
│ │ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ └── results.tsx
│ │ └── stats
│ │ │ └── index.tsx
│ ├── _app.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth].ts
│ │ ├── blob.ts
│ │ ├── ipfs.ts
│ │ ├── og.tsx
│ │ └── trpc
│ │ │ └── [trpc].ts
│ ├── app
│ │ └── index.tsx
│ ├── create-round
│ │ └── index.tsx
│ ├── index.tsx
│ └── info
│ │ └── index.tsx
├── providers
│ └── index.tsx
├── scripts
│ └── download-projects.ts
├── server
│ ├── api
│ │ ├── root.ts
│ │ ├── routers
│ │ │ ├── applications.ts
│ │ │ ├── ballot.ts
│ │ │ ├── comments.ts
│ │ │ ├── metadata.ts
│ │ │ ├── profile.ts
│ │ │ ├── projects.ts
│ │ │ ├── results.ts
│ │ │ ├── rounds.ts
│ │ │ └── voters.ts
│ │ └── trpc.ts
│ ├── auth.ts
│ └── db.ts
├── styles
│ └── globals.css
├── test-msw.ts
├── test-setup.ts
├── test-utils.tsx
└── utils
│ ├── api.ts
│ ├── calculateResults.test.ts
│ ├── calculateResults.ts
│ ├── classNames.ts
│ ├── csv.ts
│ ├── fetch.ts
│ ├── fetchAttestations.ts
│ ├── fetchMetadata.ts
│ ├── fetchVoterLimits.ts
│ ├── filterKnownNullBallots.ts
│ ├── formatCurrency.ts
│ ├── formatNumber.ts
│ ├── reverseKeys.ts
│ ├── suffixNumber.ts
│ ├── time.ts
│ ├── truncate.ts
│ ├── typedData.ts
│ └── url.ts
├── tailwind.config.ts
├── tsconfig.json
└── vitest.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | const config = {
3 | parser: "@typescript-eslint/parser",
4 | parserOptions: {
5 | project: true,
6 | },
7 | plugins: ["@typescript-eslint"],
8 | extends: [
9 | "plugin:@next/next/recommended",
10 | "plugin:@typescript-eslint/recommended-type-checked",
11 | "plugin:@typescript-eslint/stylistic-type-checked",
12 | ],
13 | rules: {
14 | // These opinionated rules are enabled in stylistic-type-checked above.
15 | // Feel free to reconfigure them to your own preference.
16 | "@typescript-eslint/array-type": "off",
17 | "@typescript-eslint/consistent-type-definitions": "off",
18 |
19 | "@typescript-eslint/consistent-type-imports": [
20 | "warn",
21 | {
22 | prefer: "type-imports",
23 | fixStyle: "inline-type-imports",
24 | },
25 | ],
26 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
27 | "@typescript-eslint/require-await": "off",
28 | "@typescript-eslint/no-misused-promises": [
29 | "error",
30 | {
31 | checksVoidReturn: { attributes: false },
32 | },
33 | ],
34 | },
35 | };
36 |
37 | module.exports = config;
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # database
12 | /prisma/db.sqlite
13 | /prisma/db.sqlite-journal
14 |
15 | # next.js
16 | /.next/
17 | /out/
18 | next-env.d.ts
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # local env files
34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
35 | .env
36 | .env*.local
37 |
38 | # vercel
39 | .vercel
40 |
41 | # typescript
42 | *.tsbuildinfo
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Gitcoin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # EasyRetroPGF
2 |
3 |
10 |
11 |
12 |
13 | [
](https://easyretropgf.xyz/sustainable-urban-development)
14 | [
](https://easyretropgf.xyz/sustainable-urban-development/projects)
15 |
16 |
17 |
18 | ## Documentation
19 |
20 | - [Setup & Deployment](./docs/01_setup.md)
21 | - [Adding Projects & Approving](./docs/02_adding_projects.md)
22 | - [Creating Badgeholders/Voters](./docs/03_creating_badgeholders.md)
23 | - [Voting](./docs/04_voting.md)
24 | - [Results](./docs/06_results.md)
25 | - [Distribute](./docs/07_distribute.md)
26 |
27 | ## Supported Networks
28 |
29 | All networks EAS is deployed to are supported
30 |
31 | - https://docs.attest.sh/docs/quick--start/contracts
32 |
33 | #### Mainnets
34 |
35 | - Ethereum
36 | - Optimism
37 | - Base
38 | - Arbitrum One
39 | - Linea
40 | - Celo
41 | - Filecoin
42 |
43 | #### Testnets
44 |
45 | - Sepolia
46 | - Optimism Goerli
47 | - Base Goerli
48 | - Arbitrum Goerli
49 | - Polygon Mumbai
50 | - Linea Goerli
51 |
52 | ## Development
53 |
54 | To run locally follow these instructions:
55 |
56 | ```sh
57 | git clone https://github.com/gitcoinco/easy-retro-pgf
58 |
59 | bun install # (or pnpm / yarn / npm)
60 |
61 | cp .env.example .env # and update .env variables
62 |
63 | docker-compose up # starts a local postgres instance
64 |
65 | bun run dev
66 |
67 | bun run db:push # create database tables
68 |
69 | open localhost:3000
70 | ```
71 |
72 | ### Technical details
73 |
74 | - **EAS** - Projects, profiles, etc are all stored on-chain in Ethereum Attestation Service
75 | - **Batched requests with tRPC** - Multiple requests are batched into one (for example when the frontend requests the metadata for 24 projects they are batched into 1 request)
76 | - **Server-side caching of requests to EAS and IPFS** - Immediately returns the data without calling EAS and locally serving ipfs cids.
77 | - **SQL database for ballots** - Votes are stored privately in a Postgres database
78 | - **Allo2 for token distribution** - Payouts are calculated based on amount of configured tokens in the pool and the vote calculation
79 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/bun.lockb
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | db:
5 | image: postgres:13
6 | ports:
7 | - "5432:5432"
8 | environment:
9 | - POSTGRES_USER=postgres
10 | - POSTGRES_PASSWORD=postgres
11 | - POSTGRES_DB=easy-retro-pgf
12 |
--------------------------------------------------------------------------------
/docs/01_setup.md:
--------------------------------------------------------------------------------
1 | # Setup
2 |
3 | Follow these instructions to deploy your own instance of EasyRetroPGF.
4 |
5 | ## 1. Fork Repo
6 |
7 | [Fork EasyRetroPGF](https://github.com/gitcoinco/easy-retro-pgf/fork)
8 |
9 | 1. Click to view the `.env.example` file in your newly created repo
10 | 2. Copy its contents and paste into a text editor
11 |
12 | ## 2. Configuration
13 |
14 | The `.env.example` file contains instructions for most of these steps.
15 |
16 | At the very minimum you need to configure a postgres database, nextauth and the voting periods under App Configuration.
17 |
18 | #### Database
19 |
20 | https://vercel.com/dashboard/stores?type=postgres
21 |
22 | 1. Press Create Database button
23 | 2. Select Postgres, press continue, and give it a name and region
24 | 3. Press `.env.local` tab, Copy Snippet and paste into text editor
25 |
26 |
27 |
28 |
29 |
30 |
31 | #### Auth
32 |
33 | 1. Generate a secret (`openssl rand -base64 32`)
34 | 2. Configure `NEXTAUTH_URL` (this should only be necessary on localhost or for production domains)
35 |
36 | #### Network
37 |
38 | The default configuration is Optimism.
39 |
40 | You can find supported networks on the EAS documentation website: https://docs.attest.sh/docs/quick--start/contracts
41 |
42 | #### App
43 |
44 | Configure how many votes each voter receives and how many votes each project can receive.
45 | You can also find configurations for when voting starts and ends as well as the registration and review period.
46 |
47 | Here, you can also configure who your admins are. These are the users who will approve applications and voters.
48 |
49 | To create your own round you need to do a few things:
50 |
51 | - Update `NEXT_PUBLIC_ADMIN_ADDRESSES` with a comma-separated list of wallet addresses that approve the applications and voters (badgeholders)
52 | - Set `NEXT_PUBLIC_ROUND_ID` to a unique identifier that will group the applications and lists you want to list
53 |
54 | #### EAS
55 |
56 | If you are running on a different network than Optimism you need to update the contract addresses for EAS. These addresses are used whenever an attestation is created.
57 |
58 | You can also configure your own schemas here if you wish to.
59 |
60 | ## 3. Deploy
61 |
62 | https://vercel.com/new
63 |
64 | 1. Import the repo you created/forked
65 | 2. Open the Environment Variables panel
66 | 3. Select the first field and paste your variables from your text editor
67 | 4. Deploy!
68 |
69 |
70 |
71 |
72 |
73 |
74 | ## Additional configuration
75 |
76 | ### Configure theme and metadata
77 |
78 | Edit `tailwind.config.ts` and `src/config.ts`
79 |
80 | _You can edit files directly in GitHub by navigating to a file and clicking the Pen icon to the right._
81 |
82 | ### Creating EAS Schemas and Attestations
83 |
84 | You can create your own schemas by running this script.
85 |
86 | ```sh
87 | WALLET_PRIVATEKEY="0x..." npm run eas:registerSchemas
88 | ```
89 |
--------------------------------------------------------------------------------
/docs/02_adding_projects.md:
--------------------------------------------------------------------------------
1 | # Adding projects
2 |
3 | - Navigate to https://easy-retro-pgf.vercel.app/applications/new (replace the domain with your deployment)
4 | - Fill out profile fields with name, profile image and banner image
5 | - Fill out application fields
6 | - **name** - the name to be displayed
7 | - **websiteUrl** - your website url
8 | - **payoutAddress** - address to send payouts to
9 | - **contributionDescription** - describe your contribution
10 | - **impactDescription** - describe your impact
11 | - **contributionLinks** - links to contributions
12 | - **impactMetrics** - links to your impact
13 | - **fundingSources** - list your funding sources
14 |
15 | This will create an Attestation with the Metadata schema and populate the fields:
16 |
17 | - `type: "application"`
18 | - `roundId: NEXT_PUBLIC_ROUND_ID`
19 |
20 | ## Reviewing and approving applications
21 |
22 | - Navigate to https://easy-retro-pgf.vercel.app/applications (replace the domain with your deployment)
23 | - Make sure you have configured `NEXT_PUBLIC_ADMIN_ADDRESSES` with the address you connect your wallet with
24 | - You will see a list of submitted applications
25 | - Press the Review button to open the application
26 | - Select the applications you want to approve
27 | - Press Approve button to create attestations for these projects (send transaction to confirm)
28 |
29 | > It can take 10 minutes for the applications to be approved in the UI
30 |
31 | [ ](https://www.loom.com/embed/cfe9bb7ad0b44aaca4d26a446006386a?sid=a3765630-b097-41bb-aa8b-7600b6901fe4)
32 |
--------------------------------------------------------------------------------
/docs/03_creating_badgeholders.md:
--------------------------------------------------------------------------------
1 | # Creating badgeholders
2 |
3 | - Navigate to https://easy-retro-pgf.vercel.app/voters (replace the domain with your deployment)
4 | - Make sure you have configured `NEXT_PUBLIC_ADMIN_ADDRESSES` with the address you connect your wallet with
5 | - Enter a list of addresses you want to allow to vote (comma-separated)
6 | - Press Approve button to create attestations for these voters (send transaction to confirm)
7 |
8 | > It can take 10 minutes for the voters to be seen in the UI
9 |
10 | [ ](https://www.loom.com/embed/5ee5b309c2334370925a95615ed8bac5)
11 |
--------------------------------------------------------------------------------
/docs/04_voting.md:
--------------------------------------------------------------------------------
1 | # Voting
2 |
3 | Once applications has been approved and the voters' addresses have been added, your voters can now vote for projects.
4 |
5 | - Navigate to https://easy-retro-pgf.vercel.app/projects
6 | - Click the plus icon on the project card or Add to ballot button in the project details page
7 | - Click View ballot to navigate to the ballot page (https://easy-retro-pgf.vercel.app/ballot)
8 | - Adjust the allocation
9 | - Click Submit ballot and sign the message
10 |
11 | You can also export your ballot as a CSV to import into Excel where you can make changes and later export as a CSV. This CSV file can then be imported and replace your ballot.
12 |
13 | [ ](https://www.loom.com/embed/ee5eff07fa3c47258bbdf42777087990?sid=34151556-5fd9-433f-bea3-aa4f81b2e597)
14 |
--------------------------------------------------------------------------------
/docs/06_results.md:
--------------------------------------------------------------------------------
1 | # Results
2 |
3 | Once the voting has ended you can view the results. There's a config determining when the results will be revealed called `NEXT_PUBLIC_RESULTS_DATE`.
4 |
5 | > ! Before the results can be displayed the vote calculation must be configured. See the [Distribute](./07_distribute.md) documentation.
6 |
7 | - https://easy-retro-pgf.vercel.app/projects/results
8 |
9 | 
10 |
11 | You can also see statistics of the round:
12 |
13 | - https://easy-retro-pgf.vercel.app/stats
14 |
15 | 
16 |
--------------------------------------------------------------------------------
/docs/07_distribute.md:
--------------------------------------------------------------------------------
1 | # Distribute
2 |
3 | Once the voting has ended you can distribute tokens to the projects.
4 |
5 | Navigate to:
6 |
7 | - https://easy-retro-pgf.vercel.app/distribute
8 |
9 | Before you should have configured your chosen payout token: `NEXT_PUBLIC_TOKEN_ADDRESS`
10 |
11 | 1. First you need to create an Allo2 profile to create a pool
12 | 2. Create Pool (you can enter an amount to fund or do that later)
13 | 3. Fund Pool
14 | 4. Choose Payout style
15 | - Custom - simply sums all the votes
16 | - OP-style - calculates median votes + a configurable minimum quorum required (number of voters cast votes for a project)
17 |
18 | 
19 | 
20 | 
21 | 
22 |
--------------------------------------------------------------------------------
/docs/images/create_allo_profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/create_allo_profile.png
--------------------------------------------------------------------------------
/docs/images/create_pool.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/create_pool.png
--------------------------------------------------------------------------------
/docs/images/create_postgres.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/create_postgres.png
--------------------------------------------------------------------------------
/docs/images/create_postgres_details.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/create_postgres_details.png
--------------------------------------------------------------------------------
/docs/images/deploy_repo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/deploy_repo.png
--------------------------------------------------------------------------------
/docs/images/distribute.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/distribute.png
--------------------------------------------------------------------------------
/docs/images/distribute_modal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/distribute_modal.png
--------------------------------------------------------------------------------
/docs/images/github_add_file.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/github_add_file.png
--------------------------------------------------------------------------------
/docs/images/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/screenshot.png
--------------------------------------------------------------------------------
/docs/images/screenshot_landing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/screenshot_landing.png
--------------------------------------------------------------------------------
/docs/images/screenshot_landing_project.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/screenshot_landing_project.png
--------------------------------------------------------------------------------
/docs/images/stats.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/stats.png
--------------------------------------------------------------------------------
/docs/images/vercel_configure.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/vercel_configure.png
--------------------------------------------------------------------------------
/docs/images/vercel_new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/vercel_new.png
--------------------------------------------------------------------------------
/docs/images/voting_ended_sidebar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/voting_ended_sidebar.png
--------------------------------------------------------------------------------
/docs/images/walletconnect_create.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/walletconnect_create.png
--------------------------------------------------------------------------------
/docs/images/walletconnect_create2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/walletconnect_create2.png
--------------------------------------------------------------------------------
/docs/images/walletconnect_information.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/walletconnect_information.png
--------------------------------------------------------------------------------
/docs/images/walletconnect_project.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gitcoinco/easy-retro-pgf/3aa5f670aa39cb3a964f5a249be45c4de3f4311e/docs/images/walletconnect_project.png
--------------------------------------------------------------------------------
/funding.json:
--------------------------------------------------------------------------------
1 | {
2 | "opRetro": {
3 | "projectId": "0x6aa80764b6082947c3b2de86fe12804eb475b0afb719de50b9eed60b86f20535"
4 | }
5 | }
6 |
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
3 | * for Docker builds.
4 | */
5 | await import("./src/env.js");
6 |
7 | /** @type {import("next").NextConfig} */
8 | const config = {
9 | reactStrictMode: true,
10 |
11 | webpack: (config) => {
12 | config.resolve.fallback = { fs: false, net: false, tls: false };
13 | config.externals.push("pino-pretty", "lokijs", "encoding", {
14 | "node-gyp-build": "commonjs node-gyp-build",
15 | });
16 | return config;
17 | },
18 |
19 | eslint: {
20 | ignoreDuringBuilds: true,
21 | },
22 | typescript: {
23 | ignoreBuildErrors: true,
24 | },
25 |
26 | /**
27 | * If you are using `appDir` then you must comment the below `i18n` config out.
28 | *
29 | * @see https://github.com/vercel/next.js/issues/41980
30 | */
31 | i18n: {
32 | locales: ["en"],
33 | defaultLocale: "en",
34 | },
35 |
36 | images: {
37 | remotePatterns: [
38 | {
39 | protocol: "https",
40 | port: "",
41 | hostname: "*.public.blob.vercel-storage.com",
42 | },
43 | ],
44 | },
45 | };
46 |
47 | export default config;
48 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
8 | module.exports = config;
9 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
2 | const config = {
3 | plugins: ["prettier-plugin-tailwindcss"],
4 | };
5 |
6 | export default config;
7 |
--------------------------------------------------------------------------------
/public/.well-known/walletconnect.txt:
--------------------------------------------------------------------------------
1 | 8e64b458-f676-4d5d-915b-9a15170b65b7=bbf017f88c8435a30e24b0575fc87e18a21a1ed561ec5e5cab4fb5349699e32b
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/__tests__/index.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { describe, expect, test, vi } from "vitest";
3 | import type { Chain, WalletClient } from "wagmi";
4 | import { MockConnector } from "wagmi/connectors/mock";
5 |
6 | import ProjectsPage from "~/pages/[domain]/projects";
7 | import { fireEvent, render, screen } from "~/test-utils";
8 | import ProjectDetailsPage from "~/pages/[domain]/projects/[projectId]";
9 |
10 | vi.mock("wagmi", async () => {
11 | const actual = await vi.importActual("wagmi");
12 | return {
13 | ...actual,
14 | };
15 | });
16 |
17 | vi.mock("@rainbow-me/rainbowkit", async () => {
18 | const actual = await vi.importActual("@rainbow-me/rainbowkit");
19 | return {
20 | ...actual,
21 | connectorsForWallets: () => undefined,
22 | getWalletConnectConnector: (opts: {
23 | chains: Chain[];
24 | options: { chainId: number; walletClient: WalletClient };
25 | }) => ({
26 | id: "mock",
27 | name: "Mock Wallet",
28 | iconUrl: "",
29 | iconBackground: "#fff",
30 | hidden: ({}) => false,
31 | createConnector: () => ({ connector: new MockConnector(opts) }),
32 | }),
33 | };
34 | });
35 |
36 | describe.skip("EasyRetroPGF", () => {
37 | test("browse projects", async () => {
38 | render( );
39 |
40 | const project = await screen.findByText(/Project #1/);
41 |
42 | expect(project).toBeInTheDocument();
43 | });
44 | test("add project to ballot", async () => {
45 | render( );
46 |
47 | const project = await screen.findByText(/Project #1/);
48 |
49 | const button = await screen.findByText("Add to ballot");
50 |
51 | fireEvent.click(button);
52 |
53 | const input = await screen.findByLabelText("allocation-input");
54 | fireEvent.change(input, { target: { value: "10" } });
55 |
56 | fireEvent.click(await screen.findByText(/Add votes/));
57 |
58 | expect(project).toBeInTheDocument();
59 | });
60 | test.skip("browse lists", async () => {
61 | //
62 | });
63 | test.skip("add list to ballot", async () => {
64 | //
65 | });
66 | test.skip("view ballot", async () => {
67 | //
68 | });
69 | test.skip("edit ballot", async () => {
70 | //
71 | });
72 | test.skip("publish ballot", async () => {
73 | //
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/src/components/AllocationInput.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, type ComponentPropsWithRef } from "react";
2 | import { useFormContext, useController } from "react-hook-form";
3 | import { useVotesCount } from "~/features/voters/hooks/useVotesCount";
4 | import { useAccount } from "wagmi";
5 |
6 | import { InputAddon } from "~/components/ui/Form";
7 | import { useRoundToken } from "~/features/distribute/hooks/useAlloPool";
8 | import { NumberInput } from "./NumberInput";
9 | import { cn } from "~/utils/classNames";
10 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
11 |
12 | export const AllocationInput = forwardRef(function AllocationInput(
13 | {
14 | name,
15 | tokenAddon,
16 | ...props
17 | }: {
18 | disabled?: boolean;
19 | tokenAddon?: boolean;
20 | error?: boolean;
21 | } & ComponentPropsWithRef,
22 | ref,
23 | ) {
24 | const { address } = useAccount();
25 | const { data: voterLimits } = useVotesCount(address!);
26 | const token = useRoundToken();
27 | const { data: round } = useCurrentRound();
28 | const { control } = useFormContext();
29 | const { field } = useController({ name: name!, control });
30 |
31 | const maxVotesProject = voterLimits?.maxVotesProject ?? 0;
32 |
33 | return (
34 | (floatValue ?? 0) <= maxVotesProject}
41 | className={cn({
42 | ["pr-16"]: tokenAddon,
43 | ["border-red-600 dark:border-red-900"]: field.value > maxVotesProject,
44 | })}
45 | >
46 | {tokenAddon && (
47 | {token.data?.symbol}
48 | )}
49 |
50 | );
51 | });
52 |
--------------------------------------------------------------------------------
/src/components/ENS.tsx:
--------------------------------------------------------------------------------
1 | import { type ComponentProps } from "react";
2 | import { type Address } from "viem";
3 | import { normalize } from "viem/ens";
4 | import { useEnsAvatar, useEnsName } from "wagmi";
5 | import { Avatar } from "~/components/ui/Avatar";
6 | import { truncate } from "~/utils/truncate";
7 |
8 | export function AvatarENS({
9 | address,
10 | ...props
11 | }: { address: string } & ComponentProps) {
12 | const { data: name } = useEnsName({
13 | address: address as Address,
14 | chainId: 1,
15 | query: { enabled: Boolean(address) },
16 | });
17 |
18 | const { data: src } = useEnsAvatar({
19 | name: normalize(name!),
20 | query: { enabled: Boolean(name) },
21 | });
22 | return ;
23 | }
24 |
25 | export function NameENS({
26 | address,
27 | truncateLength,
28 | ...props
29 | }: {
30 | address?: string;
31 | truncateLength?: number;
32 | } & ComponentProps<"div">) {
33 | const { data: name } = useEnsName({
34 | address: address as Address,
35 | chainId: 1,
36 | query: { enabled: Boolean(address) },
37 | });
38 |
39 | return (
40 |
41 | {name ?? truncate(address, truncateLength)}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/EligibilityDialog.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { useAccount, useDisconnect } from "wagmi";
3 | import { useSession } from "next-auth/react";
4 |
5 | import { metadata } from "~/config";
6 | import { Dialog } from "./ui/Dialog";
7 | import { useApprovedVoter } from "~/features/voters/hooks/useApprovedVoter";
8 |
9 | export const EligibilityDialog = () => {
10 | const { address } = useAccount();
11 | const { disconnect } = useDisconnect();
12 | const { data: session } = useSession();
13 | const { data, isPending, error } = useApprovedVoter(address!);
14 |
15 | if (isPending || !address || !session || error) return null;
16 |
17 | // TODO: Find a smoother UX for this
18 | if (true) return null;
19 | return (
20 | disconnect()}
24 | title={
25 | <>
26 | You are not eligible to vote 😔
27 | >
28 | }
29 | >
30 |
31 | Only badgeholders are able to vote in {metadata.title}. You can find out
32 | more about how badgeholders are selected{" "}
33 |
34 | here
35 |
36 | .
37 |
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren } from "react";
2 | import { Heading } from "./ui/Heading";
3 |
4 | export function EmptyState({
5 | title,
6 | children,
7 | }: PropsWithChildren<{ title: string }>) {
8 | return (
9 |
10 |
11 | {title}
12 |
13 |
{children}
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/EnsureCorrectNetwork.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren } from "react";
2 | import { useIsCorrectNetwork } from "~/hooks/useIsCorrectNetwork";
3 | import { Button } from "./ui/Button";
4 | import { useSwitchChain } from "wagmi";
5 |
6 | export function EnsureCorrectNetwork({ children }: PropsWithChildren) {
7 | const { isCorrectNetwork, correctNetwork } = useIsCorrectNetwork();
8 | const { switchChain } = useSwitchChain();
9 | if (!isCorrectNetwork)
10 | return (
11 | switchChain({ chainId: correctNetwork?.id as number })}
13 | >
14 | Change network
15 |
16 | );
17 | return <>{children}>;
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/EnureCorrectNetwork.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren } from "react";
2 | import { useIsCorrectNetwork } from "~/hooks/useIsCorrectNetwork";
3 | import { Button } from "./ui/Button";
4 | import { useSwitchChain } from "wagmi";
5 |
6 | export function EnsureCorrectNetwork({ children }: PropsWithChildren) {
7 | const { isCorrectNetwork, correctNetwork } = useIsCorrectNetwork();
8 | const { switchChain } = useSwitchChain();
9 | if (!isCorrectNetwork)
10 | return (
11 | switchChain({ chainId: correctNetwork?.id as number })}
13 | >
14 | Change network
15 |
16 | );
17 | return <>{children}>;
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { GithubIcon } from "lucide-react";
2 |
3 | export function Footer() {
4 | return (
5 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { useRouter } from "next/router";
3 | import { type ComponentPropsWithRef, useState } from "react";
4 | import clsx from "clsx";
5 |
6 | import { ConnectButton } from "./ConnectButton";
7 | import { IconButton } from "./ui/Button";
8 | import { metadata } from "~/config";
9 | import { Menu, X } from "lucide-react";
10 | import dynamic from "next/dynamic";
11 |
12 | const Logo = () => (
13 |
14 |
15 | {metadata.title}
16 |
17 |
18 | );
19 |
20 | const NavLink = ({
21 | isActive,
22 | ...props
23 | }: { isActive: boolean } & ComponentPropsWithRef) => (
24 |
33 | );
34 |
35 | type NavLink = { href: string; children: string };
36 | export const Header = ({ navLinks }: { navLinks: NavLink[] }) => {
37 | const { asPath } = useRouter();
38 | const [isOpen, setOpen] = useState(false);
39 |
40 | return (
41 |
69 | );
70 | };
71 |
72 | const MobileMenu = ({
73 | isOpen,
74 | navLinks,
75 | }: {
76 | isOpen?: boolean;
77 | navLinks: NavLink[];
78 | }) => (
79 |
87 | {navLinks.map((link) => (
88 |
93 | ))}
94 |
95 | );
96 |
97 | export default dynamic(async () => Header, { ssr: false });
98 |
--------------------------------------------------------------------------------
/src/components/ImageUpload.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import clsx from "clsx";
3 | import { ImageIcon } from "lucide-react";
4 | import { type ComponentProps, useRef } from "react";
5 | import { Controller, useFormContext } from "react-hook-form";
6 | import { toast } from "sonner";
7 | import { IconButton } from "~/components/ui/Button";
8 | import { Spinner } from "~/components/ui/Spinner";
9 | import { useUploadMetadata } from "~/hooks/useMetadata";
10 |
11 | type Props = { name?: string; maxSize?: number } & ComponentProps<"img">;
12 |
13 | export function ImageUpload({
14 | name,
15 | maxSize = 1024 * 1024, // 1 MB
16 | className,
17 | }: Props) {
18 | const ref = useRef(null);
19 | const { control } = useFormContext();
20 |
21 | const upload = useUploadMetadata();
22 | const select = useMutation({
23 | mutationFn: async (file: File) => {
24 | if (file?.size >= maxSize) {
25 | toast.error("Image too large", {
26 | description: `The image to selected is: ${(file.size / 1024).toFixed(
27 | 2,
28 | )} / ${(maxSize / 1024).toFixed(2)} kb`,
29 | });
30 | throw new Error("IMAGE_TOO_LARGE");
31 | }
32 |
33 | return URL.createObjectURL(file);
34 | },
35 | });
36 |
37 | return (
38 | {
43 | return (
44 |
45 |
ref.current?.click()}
48 | icon={upload.isPending ? Spinner : ImageIcon}
49 | className="absolute bottom-1 right-1"
50 | >
51 |
52 |
63 |
{
70 | const [file] = event.target.files ?? [];
71 | if (file) {
72 | select.mutate(file, {
73 | onSuccess: () => {
74 | upload.mutate(file, {
75 | onSuccess: (data) => {
76 | onChange(data.url);
77 | },
78 | });
79 | },
80 | });
81 | }
82 | }}
83 | type="file"
84 | />
85 |
86 | );
87 | }}
88 | />
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/InfiniteLoading.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, type ReactNode, useRef, useEffect } from "react";
2 | import { type UseTRPCInfiniteQueryResult } from "@trpc/react-query/shared";
3 |
4 | import { config } from "~/config";
5 | import { useIntersection } from "react-use";
6 | import { Spinner } from "./ui/Spinner";
7 | import { EmptyState } from "./EmptyState";
8 |
9 | const columnMap = {
10 | 2: "grid-cols-1 md:grid-cols-2",
11 | 3: "sm:grid-cols-2 lg:grid-cols-3",
12 | } as const;
13 |
14 | type Props = UseTRPCInfiniteQueryResult & {
15 | renderItem: (item: T, opts: { isLoading: boolean }) => ReactNode;
16 | columns?: keyof typeof columnMap;
17 | };
18 |
19 | export function InfiniteLoading({
20 | data,
21 | columns = 3,
22 | isFetchingNextPage,
23 | isLoading,
24 | renderItem,
25 | fetchNextPage,
26 | }: Props) {
27 | const loadingItems = useMemo(
28 | () =>
29 | Array.from({ length: config.pageSize }).map((_, id) => ({
30 | id,
31 | })) as T[],
32 | [],
33 | );
34 | const pages = data?.pages ?? [];
35 | const items = useMemo(
36 | () => pages.reduce((acc, x) => acc.concat(x), []) ?? [],
37 | [pages],
38 | );
39 |
40 | const hasMore = useMemo(() => {
41 | if (!pages.length) return false;
42 | return (pages[pages.length - 1]?.length ?? 0) === config.pageSize;
43 | }, [pages]);
44 |
45 | return (
46 |
47 | {!isLoading && !items?.length ? (
48 |
49 | ) : null}
50 |
53 | {items.map((item) => renderItem(item, { isLoading }))}
54 | {(isLoading || isFetchingNextPage) &&
55 | loadingItems.map((item) => renderItem(item, { isLoading }))}
56 |
57 |
58 |
63 |
64 | );
65 | }
66 |
67 | function FetchInView({
68 | hasMore,
69 | isFetchingNextPage,
70 | fetchNextPage,
71 | }: {
72 | hasMore?: boolean;
73 | isFetchingNextPage: boolean;
74 | fetchNextPage: () => Promise;
75 | }) {
76 | const ref = useRef(null);
77 | const intersection = useIntersection(ref, {
78 | root: null,
79 | rootMargin: "0px",
80 | threshold: 0.5,
81 | });
82 |
83 | useEffect(() => {
84 | if (intersection?.isIntersecting) {
85 | !isFetchingNextPage && hasMore && fetchNextPage().catch(console.log);
86 | }
87 | }, [intersection?.isIntersecting]);
88 |
89 | return (
90 |
91 | {isFetchingNextPage && }
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/NumberInput.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | forwardRef,
3 | type ComponentPropsWithRef,
4 | type PropsWithChildren,
5 | } from "react";
6 | import { NumericFormat } from "react-number-format";
7 | import { useController, useFormContext } from "react-hook-form";
8 |
9 | import { Input, InputWrapper } from "~/components/ui/Form";
10 |
11 | export const NumberInput = forwardRef(function NumberInput(
12 | {
13 | name,
14 | children,
15 | onBlur,
16 | ...props
17 | }: {
18 | disabled?: boolean;
19 | error?: boolean;
20 | decimalScale?: number;
21 | } & PropsWithChildren &
22 | ComponentPropsWithRef<"input">,
23 | ref,
24 | ) {
25 | const { control, setValue } = useFormContext();
26 | const { field } = useController({ name: name!, control });
27 |
28 | return (
29 |
30 | setValue(name, floatValue)}
42 | onBlur={onBlur}
43 | thousandSeparator=","
44 | {...props}
45 | />
46 | {children}
47 |
48 | );
49 | });
50 |
--------------------------------------------------------------------------------
/src/components/SortByDropdown.tsx:
--------------------------------------------------------------------------------
1 | import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
2 | import { Button } from "./ui/Button";
3 | import { ArrowUpDown, Check } from "lucide-react";
4 | import { type SortType, sortLabels } from "~/features/filter/hooks/useFilter";
5 | import dynamic from "next/dynamic";
6 |
7 | type Props = {
8 | value: SortType;
9 | onChange: (value: string) => void;
10 | options: SortType[];
11 | };
12 |
13 | const SortByDropdown = ({ value, onChange, options = [] }: Props) => {
14 | return (
15 |
16 |
17 |
23 | Sort by: {value && sortLabels[value]}
24 |
25 |
26 |
27 |
28 |
32 |
33 | Sort By
34 |
35 | onChange(v)}
38 | >
39 | {options.map((value) => (
40 |
41 | ))}
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | const RadioItem = ({ value = "", label = "" }) => (
50 |
54 |
55 |
56 |
57 | {label}
58 |
59 | );
60 |
61 | export default dynamic(async () => await SortByDropdown, { ssr: false });
62 |
--------------------------------------------------------------------------------
/src/components/SortFilter.tsx:
--------------------------------------------------------------------------------
1 | import type { OrderBy, SortOrder } from "~/features/filter/types";
2 | import SortByDropdown from "./SortByDropdown";
3 | import { useFilter } from "~/features/filter/hooks/useFilter";
4 | import { SearchInput } from "./ui/Form";
5 | import { useDebounce } from "react-use";
6 | import { useState } from "react";
7 |
8 | export const SortFilter = () => {
9 | const { orderBy, sortOrder, setFilter } = useFilter();
10 |
11 | const [search, setSearch] = useState("");
12 | useDebounce(() => setFilter({ search }), 500, [search]);
13 |
14 | return (
15 |
16 | setSearch(e.target.value)}
21 | />
22 | {
26 | const [orderBy, sortOrder] = sort.split("_") as [OrderBy, SortOrder];
27 |
28 | await setFilter({ orderBy, sortOrder }).catch();
29 | }}
30 | />
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/Toaster.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "next-themes";
2 | import { Toaster as Sonner } from "sonner";
3 |
4 | export function Toaster() {
5 | const { theme } = useTheme();
6 | return (
7 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ui/Alert.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 | import { type ComponentProps, createElement } from "react";
4 | import { type LucideIcon } from "lucide-react";
5 |
6 | const alert = tv({
7 | base: "rounded-xl p-4",
8 | variants: {
9 | variant: {
10 | warning: "bg-red-200 text-red-800",
11 | info: "bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-300",
12 | success: "bg-green-200 text-green-800",
13 | },
14 | },
15 | });
16 |
17 | export const AlertComponent = createComponent("div", alert);
18 |
19 | export const Alert = ({
20 | icon,
21 | title,
22 | children,
23 | ...props
24 | }: { icon?: LucideIcon } & ComponentProps) => {
25 | return (
26 |
27 |
28 | {icon ? createElement(icon, { className: "w-4 h-4" }) : null}
29 | {title &&
{title}
}
30 |
31 | {children}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/src/components/ui/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 | import { BackgroundImage } from "./BackgroundImage";
4 |
5 | export const Avatar = createComponent(
6 | BackgroundImage,
7 | tv({
8 | base: "bg-gray-200 dark:bg-gray-800",
9 | variants: {
10 | size: {
11 | xs: "w-5 h-5 rounded-xs",
12 | sm: "w-12 h-12 rounded-md",
13 | md: "w-16 h-16 rounded-md",
14 | lg: "w-40 h-40 rounded-3xl",
15 | },
16 | rounded: {
17 | full: "rounded-full",
18 | },
19 | bordered: {
20 | true: "outline outline-white dark:outline-gray-900",
21 | },
22 | },
23 | defaultVariants: {
24 | size: "md",
25 | },
26 | }),
27 | );
28 |
--------------------------------------------------------------------------------
/src/components/ui/BackgroundImage.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import type { ComponentPropsWithRef } from "react";
3 |
4 | export const BackgroundImage = ({
5 | src,
6 | fallbackSrc,
7 | isLoading,
8 | className,
9 | ...props
10 | }: {
11 | src?: string;
12 | fallbackSrc?: string;
13 | isLoading?: boolean;
14 | } & ComponentPropsWithRef<"div">) => (
15 |
23 | );
24 |
--------------------------------------------------------------------------------
/src/components/ui/Badge.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 |
4 | export const Badge = createComponent(
5 | "div",
6 | tv({
7 | base: "inline-flex items-center rounded font-semibold text-gray-500 text-sm",
8 | variants: {
9 | variant: {
10 | default: "bg-gray-100 dark:bg-gray-800",
11 | success: "bg-green-100 dark:bg-green-300 text-green-900",
12 | },
13 | size: {
14 | md: "px-1",
15 | lg: "px-2 py-1 text-base",
16 | },
17 | },
18 | defaultVariants: {
19 | variant: "default",
20 | size: "md",
21 | },
22 | }),
23 | );
24 |
--------------------------------------------------------------------------------
/src/components/ui/Banner.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from "~/components/ui";
3 | import { BackgroundImage } from "./BackgroundImage";
4 |
5 | export const Banner = createComponent(
6 | BackgroundImage,
7 | tv({
8 | base: "bg-gray-200 dark:bg-gray-800",
9 | variants: {
10 | size: {
11 | md: "h-24 rounded-2xl",
12 | lg: "h-80 rounded-3xl",
13 | },
14 | rounded: {
15 | full: "rounded-full",
16 | },
17 | },
18 | defaultVariants: {
19 | size: "md",
20 | },
21 | }),
22 | );
23 |
--------------------------------------------------------------------------------
/src/components/ui/Button.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 | import {
4 | type ComponentPropsWithRef,
5 | type FunctionComponent,
6 | createElement,
7 | forwardRef,
8 | } from "react";
9 | import { cn } from "~/utils/classNames";
10 | import { Spinner } from "./Spinner";
11 |
12 | export const button = tv({
13 | base: "inline-flex items-center justify-center font-semibold text-center transition-colors rounded-full duration-150 whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:ring-offset-gray-800",
14 | variants: {
15 | variant: {
16 | primary:
17 | "bg-primary-600 hover:bg-primary-500 dark:bg-white dark:hover:bg-gray-200 dark:text-gray-900 text-white dark:disabled:bg-gray-500",
18 | ghost: "hover:bg-gray-100 dark:hover:bg-gray-800",
19 | default:
20 | "bg-gray-100 dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800",
21 | danger:
22 | "bg-red-600 text-white dark:bg-red-900 hover:bg-red-500 dark:hover:bg-red-700",
23 | outline: "border border-gray-300 hover:bg-white/5 hover:border-gray-400",
24 | },
25 | size: {
26 | sm: "px-3 py-2 h-10 min-w-[40px]",
27 | md: "px-6 py-2 h-12",
28 | lg: "px-6 py-3 text-lg",
29 | icon: "h-12 w-12",
30 | },
31 | disabled: {
32 | true: "dark:text-gray-400 pointer-events-none pointer-default opacity-50 border-none",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | size: "md",
38 | },
39 | });
40 |
41 | const ButtonComponent = createComponent("button", button);
42 |
43 | export const IconButton = forwardRef(function IconButton(
44 | {
45 | children,
46 | icon,
47 | size,
48 | ...props
49 | }: // eslint-disable-next-line
50 | { icon: any; size?: string } & ComponentPropsWithRef,
51 | ref,
52 | ) {
53 | return (
54 |
55 | {icon
56 | ? createElement(icon, {
57 | className: `w-4 h-4 ${children ? "mr-2" : ""}`,
58 | })
59 | : null}
60 | {children}
61 |
62 | );
63 | });
64 |
65 | export function Button({
66 | icon,
67 | children,
68 | isLoading,
69 | ...props
70 | }: ComponentPropsWithRef & {
71 | /*eslint-disable @typescript-eslint/no-explicit-any */
72 | icon?: FunctionComponent;
73 | isLoading?: boolean;
74 | }) {
75 | const Icon = isLoading ? Spinner : icon;
76 | return (
77 |
84 | {Icon &&
85 | createElement(Icon, {
86 | className: cn("size-4", { ["mr-2"]: Boolean(children) }),
87 | })}
88 | {children}
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/components/ui/Calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ChevronLeft, ChevronRight } from "lucide-react";
5 | import { DayPicker } from "react-day-picker";
6 | import { cn } from "~/utils/classNames";
7 | import { button } from "./Button";
8 |
9 | type CalendarProps = React.ComponentProps;
10 |
11 | export function Calendar({
12 | className,
13 | classNames,
14 | showOutsideDays = true,
15 | ...props
16 | }: CalendarProps) {
17 | return (
18 | ,
56 | IconRight: ({ ...props }) => ,
57 | }}
58 | {...props}
59 | />
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/ui/Card.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 |
4 | export const Card = createComponent(
5 | "div",
6 | tv({
7 | base: "cursor-pointer rounded-[20px] border p-2 transition-colors hover:border-gray-400",
8 | })
9 | );
10 |
11 | export const CardTitle = createComponent(
12 | "h3",
13 | tv({
14 | base: "text-base md:text-lg font-bold",
15 | })
16 | );
17 |
--------------------------------------------------------------------------------
/src/components/ui/Chip.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 |
4 | const chip = tv({
5 | base: "border border-gray-300 rounded-full min-w-[42px] px-2 md:px-3 py-2 cursor-pointer inline-flex justify-center items-center whitespace-nowrap text-gray-600 hover:text-gray-800 hover:border-gray-400 transition-colors",
6 | variants: {},
7 | });
8 |
9 | export const Chip = createComponent("button", chip);
10 |
--------------------------------------------------------------------------------
/src/components/ui/DatePicker.tsx:
--------------------------------------------------------------------------------
1 | import { type ComponentPropsWithRef, forwardRef } from "react";
2 | import { useController, useFormContext } from "react-hook-form";
3 | import {
4 | Popover,
5 | PopoverContent,
6 | PopoverTrigger,
7 | } from "@radix-ui/react-popover";
8 | import { DateInput } from "./Form";
9 | import { Calendar } from "./Calendar";
10 | import { format } from "date-fns";
11 |
12 | export const DatePicker = forwardRef(function DatePicker({
13 | name,
14 | }: { name: string } & ComponentPropsWithRef) {
15 | const { control } = useFormContext();
16 | const { field } = useController({ name: name, control });
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
27 |
34 |
35 |
36 | );
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/ui/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as RadixDialog from "@radix-ui/react-dialog";
2 | import type { ReactNode, PropsWithChildren, ComponentProps } from "react";
3 | import { IconButton } from "./Button";
4 | import { createComponent } from ".";
5 | import { tv } from "tailwind-variants";
6 | import { X } from "lucide-react";
7 | import { theme } from "~/config";
8 |
9 | export const Dialog = ({
10 | title,
11 | size,
12 | isOpen,
13 | children,
14 | onOpenChange,
15 | }: {
16 | title?: string | ReactNode;
17 | isOpen?: boolean;
18 | size?: "sm" | "md";
19 | onOpenChange?: ComponentProps["onOpenChange"];
20 | } & PropsWithChildren) => {
21 | return (
22 |
23 |
24 |
25 | {/* Because of Portal we need to set the theme here */}
26 |
27 |
28 |
29 | {title}
30 |
31 | {children}
32 | {onOpenChange ? (
33 |
34 |
39 |
40 | ) : null}
41 |
42 |
43 |
44 |
45 | );
46 | };
47 | const Content = createComponent(
48 | RadixDialog.Content,
49 | tv({
50 | base: "z-20 fixed bottom-0 rounded-t-2xl bg-white dark:bg-gray-900 dark:text-white px-7 py-6 w-full sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl",
51 | variants: {
52 | size: {
53 | sm: "sm:w-[456px] md:w-[456px]",
54 | md: "sm:w-[456px] md:w-[800px]",
55 | },
56 | },
57 | defaultVariants: {
58 | size: "md",
59 | },
60 | }),
61 | );
62 |
--------------------------------------------------------------------------------
/src/components/ui/Divider.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 | import { createElement, type ComponentPropsWithoutRef } from "react";
4 |
5 | export const Divider = createComponent(
6 | "div",
7 | tv({
8 | base: "bg-gray-200",
9 | variants: {
10 | orientation: {
11 | vertical: "h-full w-[1px]",
12 | horizontal: "w-full h-[1px]",
13 | },
14 | },
15 | defaultVariants: {
16 | orientation: "horizontal",
17 | },
18 | })
19 | );
20 |
21 | export const DividerIcon = ({
22 | icon,
23 | ...props
24 | }: // eslint-disable-next-line
25 | { icon?: any } & ComponentPropsWithoutRef) => (
26 |
27 |
28 | {icon ? (
29 |
30 | {createElement(icon)}
31 |
32 | ) : null}
33 |
34 | );
35 |
--------------------------------------------------------------------------------
/src/components/ui/Heading.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 |
4 | export const Heading = createComponent(
5 | "div",
6 | tv({
7 | base: "font-bold",
8 | variants: {
9 | size: {
10 | md: "text-base",
11 | lg: "text-lg mt-2 mb-1 ",
12 | xl: "text-xl ",
13 | "2xl": "text-2xl mt-8 mb-4 ",
14 | "3xl": "text-3xl mt-8 mb-4 ",
15 | },
16 | },
17 | defaultVariants: {
18 | size: "md",
19 | },
20 | }),
21 | );
22 |
--------------------------------------------------------------------------------
/src/components/ui/Link.tsx:
--------------------------------------------------------------------------------
1 | import NextLink from "next/link";
2 | import { type ComponentProps } from "react";
3 | import { tv } from "tailwind-variants";
4 |
5 | import { createComponent } from ".";
6 | import clsx from "clsx";
7 | import { ExternalLinkIcon } from "lucide-react";
8 |
9 | export const Link = createComponent(
10 | NextLink,
11 | tv({
12 | base: "font-semibold underline-offset-2 hover:underline text-secondary-600",
13 | }),
14 | );
15 |
16 | export const ExternalLink = ({
17 | children,
18 | ...props
19 | }: ComponentProps) => (
20 |
25 | {children}
26 |
27 | );
28 |
--------------------------------------------------------------------------------
/src/components/ui/Markdown.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { type ComponentProps } from "react";
3 | import ReactMarkdown from "react-markdown";
4 | import { Link } from "./Link";
5 |
6 | const components = {
7 | a: (p: ComponentProps) => ,
8 | };
9 | export function Markdown({
10 | isLoading,
11 | ...props
12 | }: { isLoading?: boolean } & ComponentProps) {
13 | return (
14 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ui/Progress.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 | import { cn } from "~/utils/classNames";
4 |
5 | const ProgressWrapper = createComponent(
6 | "div",
7 | tv({
8 | base: "h-1 rounded-full bg-gray-200 relative overflow-hidden",
9 | }),
10 | );
11 |
12 | export const Progress = ({ value = 0, max = 100 }) => (
13 |
14 | max,
18 | })}
19 | style={{ width: `${(value / max) * 100}%` }}
20 | />
21 |
22 | );
23 |
--------------------------------------------------------------------------------
/src/components/ui/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { type ComponentProps } from "react";
2 | import clsx from "clsx";
3 | import { cn } from "~/utils/classNames";
4 |
5 | export const Skeleton = ({
6 | isLoading = false,
7 | className,
8 | children,
9 | }: ComponentProps<"span"> & { isLoading?: boolean }) =>
10 | isLoading ? (
11 |
17 | ) : (
18 | <>{children}>
19 | );
20 |
--------------------------------------------------------------------------------
/src/components/ui/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { type ComponentProps } from "react";
3 |
4 | export const Spinner = (props: ComponentProps<"div">) => (
5 |
6 |
13 |
17 |
21 |
22 |
Loading...
23 |
24 | );
25 |
--------------------------------------------------------------------------------
/src/components/ui/Table.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 |
4 | export const Table = createComponent(
5 | "table",
6 | tv({
7 | base: "w-full",
8 | }),
9 | );
10 | export const Thead = createComponent("thead", tv({ base: "" }));
11 | export const Tbody = createComponent("tbody", tv({ base: "" }));
12 | export const Tr = createComponent(
13 | "tr",
14 | tv({
15 | base: "border-b dark:border-gray-800 last:border-none",
16 | }),
17 | );
18 | export const Th = createComponent("th", tv({ base: "text-left" }));
19 | export const Td = createComponent("td", tv({ base: "px-1 py-2" }));
20 |
--------------------------------------------------------------------------------
/src/components/ui/Tag.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from ".";
3 |
4 | export const Tag = createComponent(
5 | "div",
6 | tv({
7 | base: "cursor-pointer inline-flex items-center justify-center gap-2 bg-gray-100 border border-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 text-gray-800 whitespace-nowrap transition",
8 | variants: {
9 | size: {
10 | sm: "rounded py-1 px-2 text-xs",
11 | md: "rounded-lg py-1.5 px-3 text-sm",
12 | lg: "rounded-xl py-2 px-4 text-lg",
13 | },
14 | selected: {
15 | true: "border-gray-900 dark:border-gray-300",
16 | },
17 | disabled: {
18 | true: "opacity-50 cursor-not-allowed",
19 | },
20 | },
21 | defaultVariants: {
22 | size: "md",
23 | },
24 | }),
25 | );
26 |
--------------------------------------------------------------------------------
/src/components/ui/index.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentPropsWithRef, ReactNode, ElementType } from "react";
2 | import { forwardRef } from "react";
3 |
4 | export type PolymorphicRef
=
5 | React.ComponentPropsWithRef["ref"];
6 |
7 | export type ComponentProps = {
8 | as?: C;
9 | children?: ReactNode;
10 | } & ComponentPropsWithRef;
11 |
12 | export function createComponent(
13 | tag: T,
14 | variant: TV
15 | ) {
16 | return forwardRef(function UIComponent(
17 | props: ComponentPropsWithRef,
18 | ref?: PolymorphicRef
19 | ) {
20 | const { as: Component = tag, className, ...rest } = props;
21 | return (
22 |
28 | );
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/src/features/admin/components/FormTokenSymbol.tsx:
--------------------------------------------------------------------------------
1 | import { useFormContext } from "react-hook-form";
2 | import { isAddress } from "viem";
3 | import { type Address, useToken } from "wagmi";
4 |
5 | export function TokenSymbol() {
6 | const address = useFormContext<{ tokenAddress: Address }>().watch(
7 | "tokenAddress",
8 | );
9 |
10 | const token = useToken({
11 | address,
12 | enabled: isAddress(address),
13 | });
14 | return <>{token.data?.symbol}>;
15 | }
16 |
--------------------------------------------------------------------------------
/src/features/admin/components/ImportVotersCSV.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "~/components/ui/Button";
3 | import { Table, Td, Tr } from "~/components/ui/Table";
4 | import { useImportVoters } from "~/features/voters/hooks/useImportVoters";
5 | import { api } from "~/utils/api";
6 |
7 | export function ImportVotersCSV() {
8 | const [file, setFile] = useState(null);
9 | const importVoters = useImportVoters();
10 | const voters = api.voters.voteCounts.useQuery();
11 |
12 | const handleFileChange = (event: React.ChangeEvent) => {
13 | const selectedFile = event.target.files?.[0];
14 | if (selectedFile) {
15 | setFile(selectedFile);
16 | }
17 | };
18 |
19 | return (
20 |
21 |
22 |
file && importVoters.mutateAsync(file)}
24 | disabled={!file || importVoters.isPending}
25 | isLoading={importVoters.isPending}
26 | >
27 | Import Voters
28 |
29 |
30 | {voters.data?.map((voter) => (
31 |
32 | {voter.voterId}
33 |
34 | {voter.maxVotesProject} / {voter.maxVotesTotal}
35 |
36 |
37 | ))}
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/features/applications/components/ApproveButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "~/components/ui/Button";
2 | import { useApproveApplication } from "~/features/applications/hooks/useApproveApplication";
3 | import dynamic from "next/dynamic";
4 | import { type PropsWithChildren } from "react";
5 | import { useApprovedApplications } from "../hooks/useApprovedApplications";
6 | import { Badge } from "~/components/ui/Badge";
7 |
8 | function ApproveButton({
9 | children = "Approve project",
10 | projectIds = [],
11 | }: PropsWithChildren<{ projectIds: string[] }>) {
12 | const approvals = useApprovedApplications(projectIds);
13 |
14 | const approve = useApproveApplication();
15 | if (approvals.data?.length)
16 | return (
17 |
18 | Approved
19 |
20 | );
21 | return (
22 | approve.mutate(projectIds)}
26 | >
27 | {children}
28 |
29 | );
30 | }
31 |
32 | export default dynamic(() => Promise.resolve(ApproveButton));
33 |
--------------------------------------------------------------------------------
/src/features/applications/hooks/useApplications.ts:
--------------------------------------------------------------------------------
1 | import { api } from "~/utils/api";
2 |
3 | export function useApplications() {
4 | return api.applications.list.useQuery({});
5 | }
6 |
--------------------------------------------------------------------------------
/src/features/applications/hooks/useApproveApplication.ts:
--------------------------------------------------------------------------------
1 | import { useAttest } from "~/hooks/useEAS";
2 | import { useMutation } from "@tanstack/react-query";
3 | import { createAttestation } from "~/lib/eas/createAttestation";
4 | import { useEthersSigner } from "~/hooks/useEthersSigner";
5 | import { toast } from "sonner";
6 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
7 | import { getContracts } from "~/lib/eas/createEAS";
8 |
9 | export function useApproveApplication(opts?: { onSuccess?: () => void }) {
10 | const attest = useAttest();
11 | const signer = useEthersSigner();
12 |
13 | const { data: round } = useCurrentRound();
14 |
15 | return useMutation({
16 | onSuccess: () => {
17 | toast.success("Application approved successfully!");
18 | opts?.onSuccess?.();
19 | },
20 | onError: (err: { reason?: string; data?: { message: string } }) =>
21 | toast.error("Application approve error", {
22 | description: err.reason ?? err.data?.message,
23 | }),
24 | mutationFn: async (applicationIds: string[]) => {
25 | if (!signer) throw new Error("Connect wallet first");
26 | if (!round?.network) throw new Error("Round network not configured");
27 |
28 | const contracts = getContracts(round.network);
29 |
30 | const attestations = await Promise.all(
31 | applicationIds.map((refUID) =>
32 | createAttestation(
33 | {
34 | values: { type: "application", round: round.id },
35 | schemaUID: contracts.schemas.approval,
36 | refUID,
37 | },
38 | signer,
39 | contracts,
40 | ),
41 | ),
42 | );
43 | return attest.mutateAsync(
44 | attestations.map((att) => ({ ...att, data: [att.data] })),
45 | );
46 | },
47 | });
48 | }
49 |
--------------------------------------------------------------------------------
/src/features/applications/hooks/useApprovedApplications.ts:
--------------------------------------------------------------------------------
1 | import { api } from "~/utils/api";
2 |
3 | export function useApprovedApplications(ids?: string[]) {
4 | return api.applications.approvals.useQuery({ ids });
5 | }
6 |
--------------------------------------------------------------------------------
/src/features/applications/hooks/useCreateApplication.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { eas } from "~/config";
3 | import { useUploadMetadata } from "~/hooks/useMetadata";
4 | import { useAttest, useCreateAttestation } from "~/hooks/useEAS";
5 | import type { Application, Profile } from "../types";
6 | import { type TransactionError } from "~/features/voters/hooks/useApproveVoters";
7 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
8 | import { getContracts } from "~/lib/eas/createEAS";
9 |
10 | export function useCreateApplication({
11 | onSuccess,
12 | onError,
13 | }: {
14 | onSuccess: () => void;
15 | onError: (err: TransactionError) => void;
16 | }) {
17 | const { data: round } = useCurrentRound();
18 | const attestation = useCreateAttestation();
19 | const attest = useAttest();
20 | const upload = useUploadMetadata();
21 |
22 | const roundId = String(round?.id);
23 |
24 | const mutation = useMutation({
25 | onSuccess,
26 | onError,
27 | mutationFn: async (values: {
28 | application: Application;
29 | profile: Profile;
30 | }) => {
31 | if (!roundId) throw new Error("Round ID must be defined");
32 | console.log("Uploading profile and application metadata");
33 | if (!round?.network) throw new Error("Round network must be configured");
34 |
35 | const contracts = getContracts(round.network);
36 | return Promise.all([
37 | upload.mutateAsync(values.application).then(({ url: metadataPtr }) => {
38 | console.log("Creating application attestation data");
39 | return attestation.mutateAsync({
40 | schemaUID: contracts.schemas.metadata,
41 | values: {
42 | name: values.application.name,
43 | metadataType: 0, // "http"
44 | metadataPtr,
45 | type: "application",
46 | round: roundId,
47 | },
48 | });
49 | }),
50 | upload.mutateAsync(values.profile).then(({ url: metadataPtr }) => {
51 | console.log("Creating profile attestation data");
52 | return attestation.mutateAsync({
53 | schemaUID: contracts.schemas.metadata,
54 | values: {
55 | name: values.profile.name,
56 | metadataType: 0, // "http"
57 | metadataPtr,
58 | type: "profile",
59 | round: roundId,
60 | },
61 | });
62 | }),
63 | ]).then((attestations) => {
64 | console.log("Creating onchain attestations", attestations, values);
65 | return attest.mutateAsync(
66 | attestations.map((att) => ({ ...att, data: [att.data] })),
67 | );
68 | });
69 | },
70 | });
71 |
72 | return {
73 | ...mutation,
74 | error: attest.error || upload.error || mutation.error,
75 | isAttesting: attest.isPending,
76 | isUploading: upload.isPending,
77 | };
78 | }
79 |
--------------------------------------------------------------------------------
/src/features/applications/types/index.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { EthAddressSchema } from "~/features/rounds/types";
3 | import { reverseKeys } from "~/utils/reverseKeys";
4 |
5 | export const MetadataSchema = z.object({
6 | name: z.string().min(3),
7 | metadataType: z.enum(["1"]),
8 | metadataPtr: z.string().min(3),
9 | });
10 |
11 | export const ProfileSchema = z.object({
12 | name: z.string().min(3),
13 | profileImageUrl: z.string(),
14 | bannerImageUrl: z.string(),
15 | });
16 |
17 | export type Profile = z.infer;
18 |
19 | export const contributionTypes = {
20 | CONTRACT_ADDRESS: "Contract address",
21 | GITHUB_REPO: "Github repo",
22 | OTHER: "Other",
23 | } as const;
24 |
25 | export const fundingSourceTypes = {
26 | GOVERNANCE_FUND: "Governance fund",
27 | PARTNER_FUND: "Partner fund",
28 | REVENUE: "Revenue",
29 | OTHER: "Other",
30 | } as const;
31 |
32 | export const ApplicationSchema = z.object({
33 | name: z.string().min(3),
34 | bio: z.string().min(3),
35 | websiteUrl: z.string().url().min(1),
36 | payoutAddress: EthAddressSchema,
37 | contributionDescription: z.string().min(3),
38 | impactDescription: z.string().min(3),
39 | impactCategory: z.array(z.string()).default([]),
40 | contributionLinks: z
41 | .array(
42 | z.object({
43 | description: z.string().min(3),
44 | type: z.nativeEnum(reverseKeys(contributionTypes)),
45 | url: z.string().url(),
46 | }),
47 | )
48 | .min(1),
49 | impactMetrics: z
50 | .array(
51 | z.object({
52 | description: z.string().min(3),
53 | url: z.string().url(),
54 | number: z.number(),
55 | }),
56 | )
57 | .min(1),
58 | fundingSources: z
59 | .array(
60 | z.object({
61 | description: z.string().min(3),
62 | amount: z.number(),
63 | currency: z.string().min(3).max(4),
64 | type: z.nativeEnum(reverseKeys(fundingSourceTypes)),
65 | }),
66 | )
67 | .min(1),
68 | });
69 |
70 | export const DripsApplicationSchema = z.object({
71 | githubUrl: z.string().includes("https://github.com/"),
72 | ...ApplicationSchema.shape,
73 | });
74 |
75 | export const CeloApplicationSchema = z.object({
76 | ...ApplicationSchema.shape,
77 | fundingSources: z.array(
78 | z.object({
79 | description: z.string().min(3),
80 | amount: z.number(),
81 | currency: z.string().min(3).max(4),
82 | type: z.nativeEnum(reverseKeys(fundingSourceTypes)),
83 | }),
84 | ),
85 | twitterHandle: z.string().optional(),
86 | farcasterHandle: z.string().optional(),
87 | telegramHandle: z.string().optional(),
88 | githubHandle: z.string().optional(),
89 | emailHandle: z.string().optional(),
90 | country: z.string().optional(),
91 | });
92 |
93 | export type Application = z.infer;
94 |
95 | export type DripsApplication = z.infer;
96 |
--------------------------------------------------------------------------------
/src/features/ballot/components/VotingEndsIn.tsx:
--------------------------------------------------------------------------------
1 | import { createGlobalState, useHarmonicIntervalFn } from "react-use";
2 | import { tv } from "tailwind-variants";
3 |
4 | import { calculateTimeLeft } from "~/utils/time";
5 | import { createComponent } from "~/components/ui";
6 |
7 | const useEndDate = createGlobalState<[number, number, number, number]>([
8 | 0, 0, 0, 0,
9 | ]);
10 | export function useVotingTimeLeft(date: Date) {
11 | const [state, setState] = useEndDate();
12 |
13 | useHarmonicIntervalFn(() => setState(calculateTimeLeft(date)), 1000);
14 |
15 | return state;
16 | }
17 | export const VotingEndsIn = ({ resultAt }: { resultAt: Date }) => {
18 | const [days, hours, minutes, seconds] = useVotingTimeLeft(resultAt);
19 |
20 | if (seconds < 0) {
21 | return Voting has ended
;
22 | }
23 |
24 | return (
25 |
26 | {days}d :{hours}h :
27 | {minutes}m :{seconds}s
28 |
29 | );
30 | };
31 |
32 | const TimeSlice = createComponent(
33 | "span",
34 | tv({ base: "text-gray-900 dark:text-gray-300" }),
35 | );
36 |
--------------------------------------------------------------------------------
/src/features/ballot/types/index.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const VoteSchema = z.object({
4 | projectId: z.string(),
5 | amount: z.number().min(0),
6 | });
7 |
8 | export const BallotSchema = z.object({
9 | votes: z.array(VoteSchema),
10 | });
11 |
12 | export const BallotPublishSchema = z.object({
13 | chainId: z.number(),
14 | signature: z.custom<`0x${string}`>(),
15 | message: z.object({
16 | total_votes: z.bigint(),
17 | project_count: z.bigint(),
18 | hashed_votes: z.string(),
19 | }),
20 | });
21 |
22 | export type Vote = z.infer;
23 | export type Ballot = z.infer;
24 | export type BallotPublish = z.infer;
25 |
--------------------------------------------------------------------------------
/src/features/comments/types/index.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const CommentSchema = z.object({
4 | content: z.string().min(2),
5 | projectId: z.string(),
6 | });
7 |
8 | export const CommentUpdateSchema = z.object({
9 | id: z.string(),
10 | content: z.string().min(2),
11 | });
12 |
--------------------------------------------------------------------------------
/src/features/distribute/components/ConfirmDistributionDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { Button, IconButton } from "~/components/ui/Button";
3 | import { Dialog } from "~/components/ui/Dialog";
4 | import { Spinner } from "~/components/ui/Spinner";
5 | import { useDistribute } from "~/features/distribute/hooks/useDistribute";
6 | import { type Distribution } from "~/features/distribute/types";
7 | import { usePoolAmount, usePoolToken } from "../hooks/useAlloPool";
8 | import { type Address, formatUnits, parseUnits } from "viem";
9 | import { cn } from "~/utils/classNames";
10 | import { formatNumber } from "~/utils/formatNumber";
11 |
12 | export function ConfirmDistributionDialog({
13 | distribution,
14 | onOpenChange,
15 | }: {
16 | distribution: Distribution[];
17 | onOpenChange: () => void;
18 | }) {
19 | const { data: token } = usePoolToken();
20 | const { data: balance } = usePoolAmount();
21 |
22 | const { isPending, mutate } = useDistribute();
23 |
24 | const { recipients, amounts } = useMemo(() => {
25 | return distribution.reduce(
26 | (acc, x) => ({
27 | recipients: acc.recipients.concat(x.payoutAddress as Address),
28 | amounts: acc.amounts.concat(
29 | parseUnits(String(x.amount), token.decimals),
30 | ),
31 | }),
32 | { recipients: [], amounts: [] } as {
33 | recipients: Address[];
34 | amounts: bigint[];
35 | },
36 | );
37 | }, [distribution]);
38 |
39 | const amountDiff = (balance ?? 0n) - amounts.reduce((sum, x) => sum + x, 0n);
40 |
41 | return (
42 | 0}
44 | size="sm"
45 | title="Confirm distribution"
46 | onOpenChange={onOpenChange}
47 | >
48 |
49 | This will distribute the pools funds to the payout addresses according
50 | to the table.
51 |
52 |
53 |
54 |
55 | Pool balance after distribution
56 |
57 |
62 | {formatNumber(Number(formatUnits(amountDiff, token.decimals)))}
63 |
64 |
65 |
66 |
72 | mutate?.(
73 | { recipients, amounts },
74 | { onSuccess: () => onOpenChange() },
75 | )
76 | }
77 | >
78 | {isPending ? "Confirming..." : "Confirm"}
79 |
80 |
81 | Cancel
82 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/features/distribute/components/ExportCSV.tsx:
--------------------------------------------------------------------------------
1 | import { FileDown } from "lucide-react";
2 | import { useCallback } from "react";
3 | import { IconButton } from "~/components/ui/Button";
4 | import { type Distribution } from "~/features/distribute/types";
5 | import { useProjectsById } from "~/features/projects/hooks/useProjects";
6 | import { format } from "~/utils/csv";
7 |
8 | export function ExportCSV({ votes }: { votes: Distribution[] }) {
9 | // Fetch projects for votes to get the name
10 | const projects = useProjectsById(votes.map((v) => v.projectId));
11 |
12 | const exportCSV = useCallback(async () => {
13 | // Append project name to votes
14 | const votesWithProjects = votes.map((vote) => ({
15 | ...vote,
16 | name: projects.data?.find((p) => p.id === vote.projectId)?.name,
17 | }));
18 |
19 | // Generate CSV file
20 | const csv = format(votesWithProjects, {
21 | columns: ["projectId", "name", "payoutAddress", "amount"],
22 | });
23 | window.open(`data:text/csv;charset=utf-8,${csv}`);
24 | }, [votes]);
25 |
26 | return (
27 |
34 | Export CSV
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/features/distribute/components/ImportCSV.tsx:
--------------------------------------------------------------------------------
1 | import { FileUp } from "lucide-react";
2 | import { useCallback, useRef, useState } from "react";
3 | import { useFormContext } from "react-hook-form";
4 | import { Button, IconButton } from "~/components/ui/Button";
5 | import { Dialog } from "~/components/ui/Dialog";
6 | import { parse } from "~/utils/csv";
7 | import { type Distribution } from "../types";
8 | import { getAddress } from "viem";
9 | import { toast } from "sonner";
10 |
11 | export function ImportCSV() {
12 | const form = useFormContext();
13 | const [distribution, setDistribution] = useState([]);
14 | const csvInputRef = useRef(null);
15 |
16 | const importCSV = useCallback((csvString: string) => {
17 | try {
18 | // Parse CSV and build the ballot data (remove name column)
19 | const { data } = parse(csvString);
20 | const distribution = data.map(({ projectId, amount, payoutAddress }) => ({
21 | projectId,
22 | payoutAddress: getAddress(payoutAddress),
23 | amount: Number(amount),
24 | }));
25 | console.log(123, distribution);
26 | setDistribution(distribution);
27 | } catch (error) {
28 | toast.error((error as unknown as Error).message);
29 | }
30 | }, []);
31 | return (
32 |
33 |
csvInputRef.current?.click()}>
34 | Import CSV
35 |
36 |
37 |
{
43 | const [file] = e.target.files ?? [];
44 | if (!file) return;
45 | // CSV parser doesn't seem to work with File
46 | // Read the CSV contents as string
47 | const reader = new FileReader();
48 | reader.readAsText(file);
49 | reader.onload = () => importCSV(String(reader.result));
50 | reader.onerror = () => console.log(reader.error);
51 | }}
52 | />
53 |
0}
55 | size="sm"
56 | title="Import distribution?"
57 | onOpenChange={() => setDistribution([])}
58 | >
59 |
60 | This will replace your distribution with the CSV. Refreshing the page
61 | will reset the distribution.
62 |
63 |
64 | {
67 | form.reset({ votes: distribution });
68 | setDistribution([]);
69 | }}
70 | >
71 | Yes I'm sure
72 |
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/features/distribute/hooks/useAllo.ts:
--------------------------------------------------------------------------------
1 | import { useChainId, useConfig } from "wagmi";
2 | import { Allo, Registry } from "@allo-team/allo-v2-sdk/";
3 |
4 | import {
5 | decodeEventLog,
6 | type Address,
7 | type Chain,
8 | type PublicClient,
9 | } from "viem";
10 | import { useMemo } from "react";
11 | import { type JsonFragment } from "ethers";
12 |
13 | const createAlloOpts = (chain: Chain) => ({
14 | chain: chain.id,
15 | rpc: chain.rpcUrls.default.http[0],
16 | });
17 | export function useAllo() {
18 | const { chains } = useConfig();
19 | const chainId = useChainId();
20 | const chain = chains.find((c) => c.id === chainId);
21 | return useMemo(() => chain && new Allo(createAlloOpts(chain)), [chain]);
22 | }
23 | export function useAlloRegistry() {
24 | const { chains } = useConfig();
25 | const chainId = useChainId();
26 | const chain = chains.find((c) => c.id === chainId);
27 | return useMemo(() => chain && new Registry(createAlloOpts(chain)), [chain]);
28 | }
29 |
30 | export async function waitForLogs(
31 | hash: Address,
32 | abi: readonly JsonFragment[],
33 | client?: PublicClient,
34 | ) {
35 | return client?.waitForTransactionReceipt({ hash }).then(({ logs }) => {
36 | return logs
37 | .map(({ data, topics }) => {
38 | try {
39 | console.log(decodeEventLog({ abi, data, topics }));
40 | return decodeEventLog({ abi, data, topics });
41 | } catch (error) {
42 | return null;
43 | }
44 | })
45 | .filter(Boolean);
46 | });
47 | }
48 |
--------------------------------------------------------------------------------
/src/features/distribute/hooks/useAlloProfile.ts:
--------------------------------------------------------------------------------
1 | import { useAccount, usePublicClient, useSendTransaction } from "wagmi";
2 | import { abi as RegistryABI } from "@allo-team/allo-v2-sdk/dist/Registry/registry.config";
3 | import { type Address, zeroAddress } from "viem";
4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5 | import { solidityPackedKeccak256 } from "ethers";
6 | import { useAlloRegistry, waitForLogs } from "./useAllo";
7 |
8 | export function useAlloProfile() {
9 | const registry = useAlloRegistry();
10 | const { address } = useAccount();
11 |
12 | return useQuery({
13 | queryKey: ["allo/profile"],
14 | queryFn: async () => {
15 | const profileId = getProfileId(address);
16 | const profile = await registry?.getProfileById(profileId);
17 | if (profile?.anchor === zeroAddress) return null;
18 | return profile;
19 | },
20 | enabled: Boolean(registry && address),
21 | });
22 | }
23 |
24 | const NONCE = 3n;
25 | export function useCreateAlloProfile() {
26 | const registry = useAlloRegistry();
27 | const { address } = useAccount();
28 | const client = usePublicClient();
29 | const { sendTransactionAsync } = useSendTransaction();
30 | const queryClient = useQueryClient();
31 | return useMutation({
32 | mutationFn: async () => {
33 | if (!address) throw new Error("Connect wallet first");
34 | if (!registry) throw new Error("Allo Registry not initialized");
35 |
36 | const { to, data } = registry.createProfile({
37 | nonce: NONCE,
38 | members: [address],
39 | owner: address,
40 | metadata: { protocol: 1n, pointer: "" },
41 | name: "",
42 | });
43 |
44 | const hash = await sendTransactionAsync({ to, data });
45 | return waitForLogs(hash, RegistryABI, client).then(async (logs) => {
46 | await queryClient.invalidateQueries({ queryKey: ["allo/profile"] });
47 | return logs;
48 | });
49 | },
50 | });
51 | }
52 |
53 | function getProfileId(address?: Address) {
54 | return solidityPackedKeccak256(
55 | ["uint256", "address"],
56 | [NONCE, address],
57 | ) as `0x${string}`;
58 | }
59 |
--------------------------------------------------------------------------------
/src/features/distribute/hooks/useDistribute.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { useAllo } from "./useAllo";
3 | import { usePoolId } from "./useAlloPool";
4 | import { type Address, encodeAbiParameters, parseAbiParameters } from "viem";
5 | import { useSendTransaction } from "wagmi";
6 |
7 | export function useDistribute() {
8 | const allo = useAllo();
9 | const { data: poolId } = usePoolId();
10 |
11 | const { sendTransactionAsync } = useSendTransaction();
12 | return useMutation({
13 | mutationFn: async ({
14 | recipients,
15 | amounts,
16 | }: {
17 | recipients: Address[];
18 | amounts: bigint[];
19 | }) => {
20 | if (!allo) throw new Error("Allo not initialized");
21 | if (!poolId) throw new Error("PoolID is required");
22 |
23 | console.log({ recipients, amounts });
24 | const { to, data } = allo.distribute(
25 | BigInt(poolId),
26 | recipients,
27 | encodeAmounts(amounts),
28 | );
29 |
30 | return sendTransactionAsync({ to, data });
31 | },
32 | });
33 | }
34 |
35 | function encodeAmounts(amounts: bigint[]) {
36 | return encodeAbiParameters(parseAbiParameters("uint256[]"), [amounts]);
37 | }
38 |
--------------------------------------------------------------------------------
/src/features/distribute/types/index.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { EthAddressSchema } from "~/features/rounds/types";
3 |
4 | export const DistributionSchema = z.object({
5 | projectId: z.string(),
6 | amount: z.number(),
7 | payoutAddress: EthAddressSchema,
8 | });
9 |
10 | export type Distribution = z.infer;
11 |
--------------------------------------------------------------------------------
/src/features/distribute/utils/calculatePayout.tsx:
--------------------------------------------------------------------------------
1 | export function calculatePayout(
2 | votes: number,
3 | totalVotes: bigint,
4 | totalTokens: bigint,
5 | ) {
6 | return (BigInt(Math.round(votes * 100)) * totalTokens) / totalVotes / 100n;
7 | }
8 |
--------------------------------------------------------------------------------
/src/features/filter/hooks/useFilter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | parseAsInteger,
3 | parseAsString,
4 | parseAsStringEnum,
5 | useQueryStates,
6 | } from "nuqs";
7 |
8 | import { OrderBy, SortOrder } from "../types";
9 |
10 | export const sortLabels = {
11 | name_asc: "A to Z",
12 | name_desc: "Z to A",
13 | time_asc: "Oldest",
14 | time_desc: "Newest",
15 | };
16 | export type SortType = keyof typeof sortLabels;
17 |
18 | export function useFilter() {
19 | const [filter, setFilter] = useQueryStates(
20 | {
21 | search: parseAsString.withDefault(""),
22 | orderBy: parseAsStringEnum(Object.values(OrderBy)).withDefault(
23 | OrderBy.name,
24 | ),
25 | sortOrder: parseAsStringEnum(
26 | Object.values(SortOrder),
27 | ).withDefault(SortOrder.asc),
28 | },
29 | { history: "replace" },
30 | );
31 |
32 | return { ...filter, setFilter };
33 | }
34 |
--------------------------------------------------------------------------------
/src/features/filter/types/index.ts:
--------------------------------------------------------------------------------
1 | import z from "zod";
2 | import { config } from "~/config";
3 |
4 | export enum OrderBy {
5 | name = "name",
6 | time = "time",
7 | }
8 | export enum SortOrder {
9 | asc = "asc",
10 | desc = "desc",
11 | }
12 |
13 | export const FilterSchema = z.object({
14 | limit: z.number().default(config.pageSize),
15 | cursor: z.number().default(0),
16 | seed: z.number().default(0),
17 | orderBy: z.nativeEnum(OrderBy).default(OrderBy.name),
18 | sortOrder: z.nativeEnum(SortOrder).default(SortOrder.asc),
19 | search: z.string().default(""),
20 | });
21 |
22 | export type Filter = z.infer;
23 |
--------------------------------------------------------------------------------
/src/features/info/components/RoundProgress.tsx:
--------------------------------------------------------------------------------
1 | import { tv } from "tailwind-variants";
2 | import { createComponent } from "~/components/ui";
3 | import { cn } from "~/utils/classNames";
4 | import { formatDate } from "~/utils/time";
5 |
6 | export function RoundProgress({
7 | steps = [],
8 | }: {
9 | steps: { label: string; date: Date }[];
10 | }) {
11 | const { progress, currentStepIndex } = calculateProgress(steps);
12 |
13 | return (
14 |
15 |
20 |
21 |
22 |
23 | {steps.map((step, i) => (
24 |
33 |
{step.label}
34 |
{formatDate(step.date)}
35 |
36 | ))}
37 |
38 |
39 | );
40 | }
41 |
42 | const ProgressWrapper = createComponent(
43 | "div",
44 | tv({
45 | base: "absolute hidden h-full w-4/5 overflow-hidden rounded-xl border-y border-yellow-400 md:block",
46 | }),
47 | );
48 | const ProgressBar = createComponent(
49 | "div",
50 | tv({
51 | base: "h-full bg-gradient-to-r from-yellow-50 to-yellow-200 transition-all dark:from-yellow-600 dark:to-yellow-700",
52 | }),
53 | );
54 |
55 | function calculateProgress(steps: { label: string; date: Date }[]) {
56 | const now = Number(new Date());
57 |
58 | let currentStepIndex = steps.findIndex(
59 | (step, index) =>
60 | now < Number(step.date) &&
61 | (index === 0 || now >= Number(steps[index - 1]?.date)),
62 | );
63 |
64 | if (currentStepIndex === -1) {
65 | currentStepIndex = steps.length;
66 | }
67 |
68 | let progress = 0;
69 |
70 | if (currentStepIndex > 0) {
71 | // Calculate progress for completed segments
72 | for (let i = 0; i < currentStepIndex - 1; i++) {
73 | progress += 1 / (steps.length - 1);
74 | }
75 |
76 | // Calculate progress within the current segment
77 | const segmentStart =
78 | currentStepIndex === 0 ? 0 : Number(steps[currentStepIndex - 1]?.date);
79 | const segmentEnd = Number(steps[currentStepIndex]?.date);
80 | const segmentDuration = segmentEnd - segmentStart;
81 | const timeElapsedInSegment = now - segmentStart;
82 |
83 | progress +=
84 | Math.min(timeElapsedInSegment, segmentDuration) /
85 | segmentDuration /
86 | (steps.length - 1);
87 | }
88 |
89 | progress = Math.min(Math.max(progress, 0), 1);
90 |
91 | return { progress, currentStepIndex };
92 | }
93 |
--------------------------------------------------------------------------------
/src/features/profile/components/MyApplications.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { useMemo } from "react";
3 | import { useAccount } from "wagmi";
4 | import { Badge } from "~/components/ui/Badge";
5 | import { Skeleton } from "~/components/ui/Skeleton";
6 | import { ProjectAvatar } from "~/features/projects/components/ProjectAvatar";
7 | import { useCurrentDomain } from "~/features/rounds/hooks/useRound";
8 | import { api } from "~/utils/api";
9 | import { type Attestation } from "~/utils/fetchAttestations";
10 | import { formatDate } from "~/utils/time";
11 |
12 | export function MyApplications() {
13 | const { address } = useAccount();
14 | const applications = api.applications.list.useQuery(
15 | { attester: address },
16 | { enabled: Boolean(address) },
17 | );
18 |
19 | const ids = applications.data?.map((a) => a.id);
20 | const approvals = api.applications.approvals.useQuery(
21 | { ids },
22 | { enabled: Boolean(ids?.length) },
23 | );
24 | const approvalsById = useMemo(
25 | () =>
26 | Object.fromEntries(
27 | (approvals.data ?? [])
28 | .filter((a) => !a.revoked)
29 | .map((a) => [a.refUID, true]),
30 | ),
31 | [approvals],
32 | );
33 |
34 | return (
35 |
36 |
My Applications
37 |
38 |
39 | {applications.data?.map((application) => {
40 | const isApproved = approvalsById[application.id];
41 |
42 | return (
43 |
46 | );
47 | })}
48 |
49 |
50 | );
51 | }
52 |
53 | function ApplicationItem({
54 | id,
55 | recipient,
56 | name,
57 | time,
58 | isLoading,
59 | isApproved,
60 | }: Attestation & {
61 | isLoading?: boolean;
62 | isApproved?: boolean;
63 | }) {
64 | const roundId = useCurrentDomain();
65 | return (
66 |
67 |
71 |
72 |
73 |
74 |
75 | {name}
76 |
77 |
78 |
79 |
{formatDate(time * 1000)}
80 | {isApproved ? (
81 |
Approved
82 | ) : (
83 |
Pending
84 | )}
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/features/projects/components/BioInfo.tsx:
--------------------------------------------------------------------------------
1 | import { Markdown } from "~/components/ui/Markdown";
2 | import { type Application } from "~/features/applications/types";
3 | import { InfoBox, type InfoBoxProps } from "./InfoBox";
4 |
5 | type Props = {
6 | project?: Application & {
7 | githubUrl?: string;
8 | twitterHandle?: string;
9 | farcasterHandle?: string;
10 | telegramHandle?: string;
11 | githubHandle?: string;
12 | emailHandle?: string;
13 | country?: string;
14 | };
15 | };
16 |
17 | export default function BioInfo({ project }: Props) {
18 | const {
19 | bio,
20 | twitterHandle,
21 | farcasterHandle,
22 | telegramHandle,
23 | githubHandle,
24 | emailHandle,
25 | country,
26 | } = project ?? {};
27 |
28 | const elements: InfoBoxProps["elements"] = [
29 | { type: "country", value: country },
30 | { type: "email", value: emailHandle },
31 | { type: "twitter", value: twitterHandle },
32 | { type: "farcaster", value: farcasterHandle },
33 | { type: "telegram", value: telegramHandle },
34 | { type: "github", value: githubHandle },
35 | ];
36 |
37 | const showInfoBox = elements.some(({ value }) => value);
38 |
39 | if (!showInfoBox) return {bio} ;
40 |
41 | return (
42 |
43 |
44 | {bio}
45 |
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/features/projects/components/ImpactCategories.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { Tag } from "~/components/ui/Tag";
3 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
4 |
5 | export const ImpactCategories = ({ tags }: { tags?: string[] }) => {
6 | const { data: round } = useCurrentRound();
7 |
8 | const categoriesByKey = useMemo(
9 | () =>
10 | Object.fromEntries(
11 | round?.categories?.map((cat) => [cat.id, cat.label]) ?? [],
12 | ),
13 | [round],
14 | );
15 |
16 | console.log({ categoriesByKey });
17 | return (
18 |
19 |
20 | {tags?.map((key) => (
21 |
22 | {categoriesByKey[key as keyof typeof categoriesByKey] ?? key}
23 |
24 | ))}
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/features/projects/components/InfoBox.tsx:
--------------------------------------------------------------------------------
1 | import { ExternalLink } from "~/components/ui/Link";
2 | import {
3 | SiFarcaster as Farcaster,
4 | SiTelegram as Telegram,
5 | } from "react-icons/si";
6 | import { FaXTwitter as Twitter } from "react-icons/fa6";
7 | import { LuGithub as Github, LuGlobe as Globe } from "react-icons/lu";
8 | import { MdOutlineMail as Mail } from "react-icons/md";
9 | import { type IconType } from "react-icons/lib";
10 |
11 | const InfoItem = ({
12 | type,
13 | value,
14 | }: {
15 | type: "github" | "twitter" | "farcaster" | "telegram" | "email" | "country";
16 | value: string;
17 | }) => {
18 | const Icon: IconType | undefined = {
19 | github: Github,
20 | twitter: Twitter,
21 | farcaster: Farcaster,
22 | telegram: Telegram,
23 | email: Mail,
24 | country: Globe,
25 | }[type];
26 |
27 | const sanitizedValue = value.startsWith("@") ? value.slice(1) : value;
28 |
29 | const url =
30 | sanitizedValue.startsWith("http://") ||
31 | sanitizedValue.startsWith("https://")
32 | ? sanitizedValue
33 | : {
34 | github: `https://github.com/${sanitizedValue}`,
35 | twitter: `https://x.com/${sanitizedValue}`,
36 | farcaster: `https://warpcast.com/${sanitizedValue}`,
37 | telegram: `https://t.me/${sanitizedValue}`,
38 | email: `mailto:${sanitizedValue}`,
39 | country: `https://www.google.com/maps/search/${sanitizedValue}`,
40 | }[type];
41 |
42 | if (type === "country") {
43 | return (
44 |
45 |
46 | {value}
47 |
48 | );
49 | }
50 |
51 | return (
52 |
53 |
54 | {value}
55 |
56 | );
57 | };
58 |
59 | export interface InfoBoxProps {
60 | label: string;
61 | elements?: {
62 | type: "github" | "twitter" | "farcaster" | "telegram" | "email" | "country";
63 | value?: string;
64 | }[];
65 | }
66 |
67 | export const InfoBox = ({ label, elements }: InfoBoxProps) => {
68 | const filteredElements = elements?.filter(({ value }) => value);
69 | return (
70 |
71 |
72 | {label}
73 |
74 |
75 | {filteredElements?.map(({ type, value }, i) => (
76 |
77 | ))}
78 |
79 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/features/projects/components/LinkBox.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 | import { ExternalLink } from "~/components/ui/Link";
3 |
4 | export function LinkBox({
5 | label,
6 | links,
7 | renderItem,
8 | }: {
9 | label: string;
10 | links?: T[];
11 | renderItem: (link: T) => ReactNode;
12 | }) {
13 | return (
14 |
15 |
16 | {label}
17 |
18 |
19 | {links?.map((link, i) => (
20 |
25 | {renderItem(link)}
26 |
27 | ))}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectAvatar.tsx:
--------------------------------------------------------------------------------
1 | import { type ComponentProps } from "react";
2 | import { type Address } from "viem";
3 |
4 | import { Avatar } from "~/components/ui/Avatar";
5 | import { useProfileWithMetadata } from "~/hooks/useProfile";
6 |
7 | export function ProjectAvatar({
8 | profileId,
9 | ...props
10 | }: { profileId?: Address } & ComponentProps) {
11 | const profile = useProfileWithMetadata(profileId);
12 | const { profileImageUrl } = profile.data ?? {};
13 |
14 | return ;
15 | }
16 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectAwarded.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "~/components/ui/Button";
2 | import { useProjectResults } from "~/hooks/useResults";
3 | import { formatNumber } from "~/utils/formatNumber";
4 |
5 | export function ProjectAwarded({ id = "" }) {
6 | const amount = useProjectResults(id);
7 |
8 | if (amount.isPending) return null;
9 | return (
10 |
11 | {formatNumber(amount.data ?? 0)} votes
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectBanner.tsx:
--------------------------------------------------------------------------------
1 | import { type ComponentProps } from "react";
2 | import { type Address } from "viem";
3 |
4 | import { Banner } from "~/components/ui/Banner";
5 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
6 | import { useProfileWithMetadata } from "~/hooks/useProfile";
7 |
8 | export function ProjectBanner({
9 | profileId,
10 | ...props
11 | }: { profileId?: Address } & ComponentProps) {
12 | const profile = useProfileWithMetadata(profileId);
13 | const { profileImageUrl, bannerImageUrl } = profile.data ?? {};
14 |
15 | return (
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | // The Celo Project Banner is a component that displays the banner image of the round, as the application form does not gather any banner.
23 | export function CeloProjectBanner({ ...props }: ComponentProps) {
24 | const round = useCurrentRound();
25 |
26 | return (
27 |
28 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectContributions.tsx:
--------------------------------------------------------------------------------
1 | import { Markdown } from "~/components/ui/Markdown";
2 | import { Heading } from "~/components/ui/Heading";
3 | import { LinkBox } from "./LinkBox";
4 | import { FileCode, Github, Globe, type LucideIcon } from "lucide-react";
5 | import { createElement } from "react";
6 | import { type Application } from "~/features/applications/types";
7 |
8 | type Props = { isLoading: boolean; project?: Application };
9 |
10 | export default function ProjectContributions({ isLoading, project }: Props) {
11 | return (
12 | <>
13 |
14 | Contributions
15 |
16 |
17 |
18 |
19 | {project?.contributionDescription}
20 |
21 |
22 |
23 |
{
27 | const icon: LucideIcon | undefined = {
28 | CONTRACT_ADDRESS: FileCode,
29 | GITHUB_REPO: Github,
30 | OTHER: Globe,
31 | }[link.type];
32 | return (
33 | <>
34 | {createElement(icon ?? "div", {
35 | className: "w-4 h-4 mt-1",
36 | })}
37 |
38 | {link.description}
39 |
40 | >
41 | );
42 | }}
43 | />
44 |
45 |
46 | >
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectImpact.tsx:
--------------------------------------------------------------------------------
1 | import { Markdown } from "~/components/ui/Markdown";
2 | import { Heading } from "~/components/ui/Heading";
3 | import { LinkBox } from "./LinkBox";
4 | import { suffixNumber } from "~/utils/suffixNumber";
5 | import { type Application } from "~/features/applications/types";
6 |
7 | type Props = { isLoading: boolean; project?: Application };
8 |
9 | export default function ProjectImpact({ isLoading, project }: Props) {
10 | return (
11 | <>
12 |
13 | Impact
14 |
15 |
16 |
17 |
18 | {project?.impactDescription}
19 |
20 |
21 |
22 |
(
26 | <>
27 |
28 | {link.description}
29 |
30 | {suffixNumber(link.number)}
31 | >
32 | )}
33 | />
34 |
35 |
36 | >
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectItem.tsx:
--------------------------------------------------------------------------------
1 | import { ProjectAvatar } from "./ProjectAvatar";
2 | import { ProjectBanner } from "./ProjectBanner";
3 | import { Heading } from "~/components/ui/Heading";
4 | import { Skeleton } from "~/components/ui/Skeleton";
5 | import { useProjectMetadata } from "../hooks/useProjects";
6 | import { type Attestation } from "~/utils/fetchAttestations";
7 | import { ImpactCategories } from "./ImpactCategories";
8 | import { formatNumber } from "~/utils/formatNumber";
9 |
10 | export function ProjectItem({
11 | attestation,
12 | isLoading,
13 | }: {
14 | attestation: Attestation;
15 | isLoading: boolean;
16 | }) {
17 | const metadata = useProjectMetadata(attestation?.metadataPtr);
18 |
19 | return (
20 |
24 |
32 |
33 | {attestation?.name}
34 |
35 |
36 |
37 |
38 | {metadata.data?.bio}
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
49 | export function ProjectItemAwarded({ amount = 0 }) {
50 | return (
51 |
52 | {formatNumber(amount)} votes
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectSelectButton.tsx:
--------------------------------------------------------------------------------
1 | import { type ComponentProps } from "react";
2 | import { tv } from "tailwind-variants";
3 | import { AlbumIcon, CheckIcon, PlusIcon } from "lucide-react";
4 |
5 | import { createComponent } from "~/components/ui";
6 |
7 | const ActionButton = createComponent(
8 | "button",
9 | tv({
10 | base: "flex h-6 w-6 items-center justify-center rounded-full border-2 border-transparent transition-colors bg-gray-100 dark:bg-gray-900",
11 | variants: {
12 | color: {
13 | default:
14 | "dark:border-white/50 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:border-white",
15 | highlight:
16 | "hover:bg-white dark:hover:bg-gray-800 dark:border-white dark:text-white",
17 | green:
18 | "border-transparent border-gray-100 dark:border-gray-900 text-gray-500",
19 | },
20 | },
21 | defaultVariants: { color: "default" },
22 | }),
23 | );
24 |
25 | type Props = { state: 2 | 1 | 0 } & ComponentProps;
26 |
27 | export function ProjectSelectButton({ state, ...props }: Props) {
28 | const { color, icon: Icon } = {
29 | 0: { color: "default", icon: PlusIcon },
30 | 1: { color: "highlight", icon: CheckIcon },
31 | 2: { color: "green", icon: AlbumIcon },
32 | }[state];
33 |
34 | return (
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectsResults.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import Link from "next/link";
3 |
4 | import { InfiniteLoading } from "~/components/InfiniteLoading";
5 | import { useRoundState } from "~/features/rounds/hooks/useRoundState";
6 | import { useProjectsResults, useResults } from "~/hooks/useResults";
7 | import { ProjectItem, ProjectItemAwarded } from "./ProjectItem";
8 | import { useCurrentDomain } from "~/features/rounds/hooks/useRound";
9 | import { useIsShowActualVotes } from "~/features/rounds/hooks/useIsShowActualVotes";
10 |
11 | export function ProjectsResults() {
12 | const projects = useProjectsResults();
13 | const results = useResults();
14 | const domain = useCurrentDomain();
15 |
16 | const isShowActualVotes = useIsShowActualVotes();
17 |
18 | const roundState = useRoundState();
19 | return (
20 | {
23 | return (
24 |
29 | {!results.isPending && roundState === "RESULTS" ? (
30 |
37 | ) : null}
38 |
39 |
40 | );
41 | }}
42 | />
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/features/projects/components/ProjectsSearch.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from "react";
2 | import clsx from "clsx";
3 | import { Command } from "cmdk";
4 | import { useClickAway } from "react-use";
5 |
6 | import { type Vote } from "~/features/ballot/types";
7 | import { SearchInput } from "~/components/ui/Form";
8 | import { useSearchProjects } from "~/features/projects/hooks/useProjects";
9 | import { type Filter } from "~/features/filter/types";
10 | import { ProjectAvatar } from "~/features/projects/components/ProjectAvatar";
11 |
12 | type Props = {
13 | addedProjects: Vote[];
14 | onSelect: (path: string) => void;
15 | };
16 |
17 | export const ProjectsSearch = ({ addedProjects, onSelect }: Props) => {
18 | const searchRef = useRef(null);
19 | const [isOpen, setOpen] = useState(false);
20 | const [search, setSearch] = useState("");
21 | useClickAway(searchRef, () => setOpen(false));
22 |
23 | // Search does not use pagination, categories and always sorts A to Z
24 | const filter = {
25 | orderBy: "name",
26 | sortOrder: "asc",
27 | search,
28 | cursor: 0,
29 | limit: 10,
30 | } as Partial;
31 | const projects = useSearchProjects(filter);
32 |
33 | const projectsData =
34 | (projects.data?.pages.flat() ?? []).filter(
35 | (project) => !addedProjects.find((p) => p.projectId === project.id),
36 | ) ?? [];
37 |
38 | return (
39 |
40 |
46 | setOpen(true)}
50 | onKeyDown={() => setOpen(true)}
51 | onValueChange={setSearch}
52 | placeholder="Search projects..."
53 | />
54 |
55 | {projectsData.length ? (
56 |
63 | {projects.isPending ? (
64 | Loading...
65 | ) : !projectsData.length ? null : (
66 | projectsData?.map((item) => (
67 | {
72 | setSearch("");
73 | setOpen(false);
74 | onSelect(id);
75 | }}
76 | >
77 |
81 | {item.name}
82 |
83 | ))
84 | )}
85 |
86 | ) : null}
87 |
88 |
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/src/features/projects/hooks/useProjects.ts:
--------------------------------------------------------------------------------
1 | import { useMetadata } from "~/hooks/useMetadata";
2 | import { api } from "~/utils/api";
3 | import { type Application } from "~/features/applications/types";
4 | import { useFilter } from "~/features/filter/hooks/useFilter";
5 | import { type Filter } from "~/features/filter/types";
6 |
7 | export function useProjectById(id: string) {
8 | const query = api.projects.get.useQuery(
9 | { ids: [id] },
10 | { enabled: Boolean(id) },
11 | );
12 |
13 | return { ...query, data: query.data?.[0] };
14 | }
15 |
16 | export function useProjectsById(ids: string[]) {
17 | return api.projects.get.useQuery({ ids }, { enabled: Boolean(ids.length) });
18 | }
19 |
20 | const seed = 0;
21 | export function useSearchProjects(filterOverride?: Partial) {
22 | const { setFilter, ...filter } = useFilter();
23 | return api.projects.search.useInfiniteQuery(
24 | { seed, ...filter, ...filterOverride },
25 | {
26 | getNextPageParam: (_, pages) => pages.length,
27 | },
28 | );
29 | }
30 |
31 | export function useProjectMetadata(metadataPtr?: string) {
32 | return useMetadata<
33 | Application & {
34 | githubUrl?: string;
35 | twitterHandle?: string;
36 | farcasterHandle?: string;
37 | telegramHandle?: string;
38 | githubHandle?: string;
39 | emailHandle?: string;
40 | country?: string;
41 | }
42 | >(metadataPtr);
43 | }
44 |
45 | export function useProjectCount() {
46 | return api.projects.count.useQuery();
47 | }
48 |
--------------------------------------------------------------------------------
/src/features/projects/hooks/useSelectProjects.ts:
--------------------------------------------------------------------------------
1 | import { useMemo, useState } from "react";
2 | import {
3 | ballotContains,
4 | useAddToBallot,
5 | useBallot,
6 | } from "~/features/ballot/hooks/useBallot";
7 |
8 | export function useSelectProjects() {
9 | const add = useAddToBallot();
10 | const { data: ballot, isPending } = useBallot();
11 |
12 | const [selected, setSelected] = useState>({});
13 |
14 | const toAdd = useMemo(
15 | () =>
16 | Object.keys(selected)
17 | .filter((id) => selected[id])
18 | .map((projectId) => ({ projectId, amount: 0 })),
19 | [selected],
20 | );
21 |
22 | return {
23 | count: toAdd.length,
24 | isPending: isPending || add.isPending,
25 | add: () => {
26 | add.mutate(toAdd);
27 | setSelected({});
28 | },
29 | reset: () => setSelected({}),
30 | toggle: (id: string) => {
31 | if (!id) return;
32 | selected[id]
33 | ? setSelected((s) => ({ ...s, [id]: false }))
34 | : setSelected((s) => ({ ...s, [id]: true }));
35 | },
36 | getState: (id: string) =>
37 | Boolean(ballotContains(id, ballot)) ? 2 : selected[id] ? 1 : 0,
38 | };
39 | }
40 |
--------------------------------------------------------------------------------
/src/features/results/components/Chart.tsx:
--------------------------------------------------------------------------------
1 | import { ResponsiveLine } from "@nivo/line";
2 | import { formatNumber } from "~/utils/formatNumber";
3 | import { suffixNumber } from "~/utils/suffixNumber";
4 |
5 | export default function ResultsChart({
6 | data,
7 | }: {
8 | data: {
9 | id: string;
10 | data: { x: string; y: number | undefined }[];
11 | }[];
12 | }) {
13 | return (
14 | formatNumber(Number(v))}
26 | axisLeft={{
27 | tickSize: 5,
28 | tickPadding: 5,
29 | tickRotation: 0,
30 | legendOffset: -40,
31 | legendPosition: "middle",
32 | renderTick: ({ textAnchor, textX, textY, value, x, y }) => (
33 |
34 |
39 | {suffixNumber(Number(value))}
40 |
41 |
42 | ),
43 | }}
44 | axisBottom={{
45 | renderTick: ({ textAnchor, textX, textY, value, x, y }) => (
46 |
47 |
52 | {value}
53 |
54 |
55 | ),
56 | }}
57 | pointSize={10}
58 | pointColor={{ theme: "background" }}
59 | pointBorderWidth={2}
60 | pointBorderColor={{ from: "serieColor" }}
61 | pointLabelYOffset={-12}
62 | useMesh={true}
63 | />
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/features/rounds/hooks/useIsShowActualVotes.ts:
--------------------------------------------------------------------------------
1 | import { useCurrentDomain } from "./useRound";
2 |
3 | const domainsToShowActualVotes: string[] = ["libp2p-r-pgf-1"];
4 |
5 | export function useIsShowActualVotes() {
6 | const domain = useCurrentDomain();
7 | return domainsToShowActualVotes.includes(domain);
8 | }
9 |
--------------------------------------------------------------------------------
/src/features/rounds/hooks/useRound.ts:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { toast } from "sonner";
3 | import { api } from "~/utils/api";
4 |
5 | export function useCurrentDomain() {
6 | return useRouter().query.domain as string;
7 | }
8 | export function useCurrentRound() {
9 | const domain = useCurrentDomain();
10 | return api.rounds.get.useQuery({ domain }, { enabled: Boolean(domain) });
11 | }
12 |
13 | export function useUpdateRound() {
14 | return api.rounds.update.useMutation({
15 | onSuccess: () => {
16 | toast.success("Round updated successfully!");
17 | },
18 | onError: (err: unknown) =>
19 | toast.error("Error updating round", {
20 | description: err?.toString(),
21 | }),
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/src/features/rounds/hooks/useRoundState.ts:
--------------------------------------------------------------------------------
1 | import { isBefore } from "date-fns";
2 | import { useMemo } from "react";
3 | import { useCurrentRound } from "./useRound";
4 | import { type RoundSchema } from "../types";
5 |
6 | type AppState =
7 | | "APPLICATION"
8 | | "REVIEWING"
9 | | "VOTING"
10 | | "TALLYING"
11 | | "RESULTS"
12 | | null;
13 |
14 | export function useRoundState() {
15 | const { data } = useCurrentRound();
16 | return useMemo(() => getState(data), [data]);
17 | }
18 |
19 | export const getState = (round?: RoundSchema | null): AppState => {
20 | const now = new Date();
21 |
22 | if (!round) return null;
23 |
24 | if (isBefore(now, round.reviewAt ?? now)) return "APPLICATION";
25 | if (isBefore(now, round.votingAt ?? now)) return "REVIEWING";
26 | if (isBefore(now, round.resultAt ?? now)) return "VOTING";
27 | if (isBefore(now, round.payoutAt ?? now)) return "TALLYING";
28 |
29 | return "RESULTS";
30 | };
31 |
--------------------------------------------------------------------------------
/src/features/rounds/types/index.ts:
--------------------------------------------------------------------------------
1 | import { isBefore } from "date-fns";
2 | import { isAddress, type Address } from "viem";
3 | import { z } from "zod";
4 |
5 | export const RoundNameSchema = z.string().max(50);
6 |
7 | const RoundVotes = z.object({
8 | maxVotesTotal: z.number().nullable(),
9 | maxVotesProject: z.number().nullable(),
10 | });
11 |
12 | export const EthAddressSchema = z.custom(
13 | (val) => isAddress(val as Address),
14 | "Invalid address",
15 | );
16 |
17 | export const RoundVotesSchema = RoundVotes.refine(
18 | (schema) => (schema?.maxVotesTotal ?? 0) >= (schema?.maxVotesProject ?? 0),
19 | {
20 | path: ["maxVotesTotal"],
21 | message: "Total votes must be at least equal to votes per project",
22 | },
23 | );
24 |
25 | export const calculationTypes = {
26 | average: "Mean (average)",
27 | median: "Median",
28 | sum: "Sum",
29 | } as const;
30 |
31 | export const CalculationTypeSchema = z
32 | .enum(Object.keys(calculationTypes) as [string, ...string[]])
33 | .default("average");
34 |
35 | export const RoundDates = z.object({
36 | startsAt: z.date().nullable(),
37 | reviewAt: z.date().nullable(),
38 | votingAt: z.date().nullable(),
39 | resultAt: z.date().nullable(),
40 | payoutAt: z.date().nullable(),
41 | });
42 |
43 | export const RoundDatesSchema = RoundDates.superRefine(
44 | ({ startsAt, reviewAt, votingAt, resultAt, payoutAt }, ctx) => {
45 | if (reviewAt && startsAt && isBefore(reviewAt, startsAt)) {
46 | ctx.addIssue({
47 | code: z.ZodIssueCode.custom,
48 | message: "Review date must be after start date",
49 | path: ["reviewAt"],
50 | });
51 | }
52 | if (votingAt && reviewAt && isBefore(votingAt, reviewAt)) {
53 | ctx.addIssue({
54 | code: z.ZodIssueCode.custom,
55 | message: "Voting date must be after review date",
56 | path: ["votingAt"],
57 | });
58 | }
59 | if (resultAt && votingAt && isBefore(resultAt, votingAt)) {
60 | ctx.addIssue({
61 | code: z.ZodIssueCode.custom,
62 | message: "Voting end date must be after voting start date",
63 | path: ["resultAt"],
64 | });
65 | }
66 | if (payoutAt && votingAt && isBefore(payoutAt, votingAt)) {
67 | ctx.addIssue({
68 | code: z.ZodIssueCode.custom,
69 | message: "Payout date must be after voting date",
70 | path: ["payoutAt"],
71 | });
72 | }
73 | },
74 | );
75 | export const RoundSchema = z
76 | .object({
77 | id: z.string(),
78 | name: RoundNameSchema,
79 | domain: z.string(),
80 | creatorId: z.string(),
81 | admins: z.array(EthAddressSchema),
82 | description: z.string().nullable(),
83 | bannerImageUrl: z.string().url(),
84 | categories: z.array(z.object({ id: z.string(), label: z.string() })),
85 | network: z.string().nullable(),
86 | tokenAddress: EthAddressSchema.or(z.string().nullish()),
87 | poolId: z.number().nullable(),
88 | calculationType: CalculationTypeSchema,
89 | calculationConfig: z
90 | .record(z.string().or(z.number()))
91 | .nullish()
92 | .default({}),
93 | })
94 | .merge(RoundDates)
95 | .merge(RoundVotes);
96 |
97 | export type RoundSchema = z.infer;
98 | export type RoundDatesSchema = z.infer;
99 |
--------------------------------------------------------------------------------
/src/features/stats/utils/generateResultsChartData.ts:
--------------------------------------------------------------------------------
1 | import type { BallotResults } from "~/utils/calculateResults";
2 |
3 | type ChartPoint = { x: string; y: number };
4 | type LineChartData = { id: string; data: ChartPoint[] };
5 |
6 | export function generateResultsChartData(
7 | allProjectResults: { name: string; id: string }[],
8 | projectResults: { name: string; id: string }[],
9 | projects: BallotResults,
10 | ): {
11 | calculatedData: LineChartData[];
12 | actualData: LineChartData[];
13 | } {
14 | const calculated = mapProjectResultsToChartPoints(
15 | projectResults,
16 | projects,
17 | 15,
18 | (project) => project?.votes ?? 0,
19 | );
20 |
21 | const projectNamesMap = getProjectNamesMap(allProjectResults);
22 |
23 | const actual = getTopProjectsByVotes(projects, 15).map(
24 | ([id, { actualVotes }]) => ({
25 | x: projectNamesMap[id] ?? "",
26 | y: actualVotes,
27 | }),
28 | );
29 |
30 | return {
31 | calculatedData: [{ id: "awarded", data: calculated }],
32 | actualData: [{ id: "awarded", data: actual }],
33 | };
34 | }
35 |
36 | const getProjectNamesMap = (
37 | projects: { name: string; id: string }[],
38 | ): Record =>
39 | projects.reduce>((acc, { name, id }) => {
40 | acc[id] = name;
41 | return acc;
42 | }, {});
43 |
44 | const mapProjectResultsToChartPoints = (
45 | results: { name: string; id: string }[],
46 | data: BallotResults,
47 | limit: number,
48 | getVotes: (project?: {
49 | voters: number;
50 | votes: number;
51 | actualVotes: number;
52 | }) => number,
53 | ): ChartPoint[] =>
54 | results.slice(0, limit).map((project) => ({
55 | x: project.name,
56 | y: getVotes(data[project.id]),
57 | }));
58 |
59 | const getTopProjectsByVotes = (
60 | data: BallotResults,
61 | limit: number,
62 | ): [string, BallotResults[string]][] =>
63 | Object.entries(data)
64 | .sort((a, b) => b[1].actualVotes - a[1].actualVotes)
65 | .slice(0, limit);
66 |
--------------------------------------------------------------------------------
/src/features/voters/hooks/useApproveVoters.ts:
--------------------------------------------------------------------------------
1 | import { eas } from "~/config";
2 | import { useAttest } from "~/hooks/useEAS";
3 | import { useEthersSigner } from "~/hooks/useEthersSigner";
4 | import { useMutation } from "@tanstack/react-query";
5 | import { createAttestation } from "~/lib/eas/createAttestation";
6 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
7 | import { api } from "~/utils/api";
8 | import { getContracts } from "~/lib/eas/createEAS";
9 |
10 | // TODO: Move this to a shared folders
11 | export type TransactionError = { reason?: string; data?: { message: string } };
12 |
13 | export function useVoters() {
14 | return api.voters.list.useQuery(undefined, { refetchInterval: 5000 });
15 | }
16 |
17 | export function useApproveVoters({
18 | onSuccess,
19 | onError,
20 | }: {
21 | onSuccess: () => void;
22 | onError: (err: TransactionError) => void;
23 | }) {
24 | const attest = useAttest();
25 | const signer = useEthersSigner();
26 | const { data: round } = useCurrentRound();
27 |
28 | return useMutation({
29 | mutationFn: async (voters: string[]) => {
30 | if (!signer) throw new Error("Connect wallet first");
31 | if (!round) throw new Error("Round must be defined");
32 | if (!round?.network) throw new Error("Round network must be configured");
33 |
34 | const contracts = getContracts(round.network);
35 | const attestations = await Promise.all(
36 | voters.map((recipient) =>
37 | createAttestation(
38 | {
39 | values: { type: "voter", round: round.id },
40 | schemaUID: contracts.schemas.approval,
41 | recipient,
42 | },
43 | signer,
44 | contracts,
45 | ),
46 | ),
47 | );
48 | return attest.mutateAsync(
49 | attestations.map((att) => ({ ...att, data: [att.data] })),
50 | );
51 | },
52 | onSuccess,
53 | onError,
54 | });
55 | }
56 |
--------------------------------------------------------------------------------
/src/features/voters/hooks/useApprovedVoter.ts:
--------------------------------------------------------------------------------
1 | import { type Address } from "viem";
2 |
3 | import { api } from "~/utils/api";
4 |
5 | export function useApprovedVoter(address: Address) {
6 | return api.voters.approved.useQuery(
7 | { address },
8 | { enabled: Boolean(address) },
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/features/voters/hooks/useVotesCount.ts:
--------------------------------------------------------------------------------
1 | import { api } from "~/utils/api";
2 |
3 | export function useVotesCount(voterAddress: string) {
4 | return api.voters.voteCountPerAddress.useQuery(
5 | { address: voterAddress },
6 | { enabled: !!voterAddress },
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/src/hooks/useEAS.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import {
3 | type MultiRevocationRequest,
4 | type MultiAttestationRequest,
5 | } from "@ethereum-attestation-service/eas-sdk";
6 |
7 | import { useEthersSigner } from "~/hooks/useEthersSigner";
8 | import { createAttestation } from "~/lib/eas/createAttestation";
9 | import { createEAS, getContracts } from "~/lib/eas/createEAS";
10 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
11 |
12 | export function useCreateAttestation() {
13 | const signer = useEthersSigner();
14 | const { data: round } = useCurrentRound();
15 | return useMutation({
16 | mutationFn: async (data: {
17 | values: Record;
18 | schemaUID: string;
19 | }) => {
20 | if (!signer) throw new Error("Connect wallet first");
21 | if (!round?.network) throw new Error("Round network not configured");
22 | return createAttestation(data, signer, getContracts(round.network));
23 | },
24 | });
25 | }
26 |
27 | export function useAttest() {
28 | const signer = useEthersSigner();
29 | const { data: round } = useCurrentRound();
30 | return useMutation({
31 | mutationFn: async (attestations: MultiAttestationRequest[]) => {
32 | if (!signer) throw new Error("Connect wallet first");
33 | if (!round?.network) throw new Error("Round network not configured");
34 | const eas = createEAS(signer, round?.network);
35 | return eas.multiAttest(attestations);
36 | },
37 | });
38 | }
39 | export function useRevoke() {
40 | const signer = useEthersSigner();
41 | const { data: round } = useCurrentRound();
42 | return useMutation({
43 | mutationFn: async (revocations: MultiRevocationRequest[]) => {
44 | if (!signer) throw new Error("Connect wallet first");
45 | if (!round?.network) throw new Error("Round network not configured");
46 | const eas = createEAS(signer, round?.network);
47 | return eas.multiRevoke(revocations);
48 | },
49 | });
50 | }
51 |
--------------------------------------------------------------------------------
/src/hooks/useEthersSigner.ts:
--------------------------------------------------------------------------------
1 | import { BrowserProvider, JsonRpcSigner } from "ethers";
2 | import { useMemo } from "react";
3 | import type { Account, Chain, Client, Transport } from "viem";
4 | import { type Config, useConnectorClient } from "wagmi";
5 |
6 | export function clientToSigner(client: Client) {
7 | const { account, chain, transport } = client;
8 | if (!chain) return null;
9 | const network = {
10 | chainId: chain.id,
11 | name: chain.name,
12 | ensAddress: chain.contracts?.ensRegistry?.address,
13 | };
14 | const provider = new BrowserProvider(transport, network);
15 | const signer = new JsonRpcSigner(provider, account.address);
16 | return signer;
17 | }
18 |
19 | /** Hook to convert a viem Wallet Client to an ethers.js Signer. */
20 | export function useEthersSigner({ chainId }: { chainId?: number } = {}) {
21 | const { data: client } = useConnectorClient({ chainId });
22 | return useMemo(() => (client ? clientToSigner(client) : undefined), [client]);
23 | }
24 |
--------------------------------------------------------------------------------
/src/hooks/useIsAdmin.ts:
--------------------------------------------------------------------------------
1 | import { useAccount } from "wagmi";
2 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
3 |
4 | export function useIsAdmin() {
5 | const { address } = useAccount();
6 | const round = useCurrentRound();
7 | return round.data?.admins.includes(address!);
8 | }
9 |
--------------------------------------------------------------------------------
/src/hooks/useIsCorrectNetwork.ts:
--------------------------------------------------------------------------------
1 | import { supportedNetworks } from "~/config";
2 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
3 | import { useAccount, useChainId } from "wagmi";
4 |
5 | export function useIsCorrectNetwork() {
6 | const { isConnected } = useAccount();
7 | const chainId = useChainId();
8 |
9 | const { data: round } = useCurrentRound();
10 |
11 | const network = supportedNetworks.find((n) => n.chain === round?.network);
12 | const isCorrectNetwork = isConnected && chainId === network?.id;
13 |
14 | return {
15 | isCorrectNetwork,
16 | correctNetwork: network,
17 | };
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/useMetadata.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { api } from "~/utils/api";
3 |
4 | export function useMetadata(metadataPtr?: string) {
5 | const query = api.metadata.get.useQuery(
6 | { metadataPtr: String(metadataPtr) },
7 | { enabled: Boolean(metadataPtr) },
8 | );
9 |
10 | return {
11 | ...query,
12 | data: query.data as T,
13 | };
14 | }
15 |
16 | export function useUploadMetadata() {
17 | return useMutation({
18 | mutationFn: async (data: Record | File) => {
19 | const formData = new FormData();
20 |
21 | if (!(data instanceof File)) {
22 | const blob = new Blob([JSON.stringify(data)], {
23 | type: "application/json",
24 | });
25 | data = new File([blob], "metadata.json");
26 | }
27 |
28 | formData.append("file", data);
29 | return fetch(`/api/blob?filename=${data.name}`, {
30 | method: "POST",
31 | headers: { "Content-Type": "application/json" },
32 | body: data,
33 | }).then(async (r) => {
34 | if (!r.ok) throw new Error("Network error");
35 | return (await r.json()) as { url: string };
36 | });
37 | },
38 | });
39 | }
40 |
--------------------------------------------------------------------------------
/src/hooks/useProfile.ts:
--------------------------------------------------------------------------------
1 | import { type Address } from "viem";
2 | import { api } from "~/utils/api";
3 | import { useMetadata } from "./useMetadata";
4 |
5 | export function useProfile(id?: Address) {
6 | return api.profile.get.useQuery({ id: String(id) }, { enabled: Boolean(id) });
7 | }
8 |
9 | export function useProfileWithMetadata(id?: Address) {
10 | const profile = useProfile(id);
11 |
12 | return useMetadata<{ profileImageUrl: string; bannerImageUrl: string }>(
13 | profile.data?.metadataPtr,
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/useResults.ts:
--------------------------------------------------------------------------------
1 | import { config } from "~/config";
2 | import { useIsShowActualVotes } from "~/features/rounds/hooks/useIsShowActualVotes";
3 | import { useRoundState } from "~/features/rounds/hooks/useRoundState";
4 | import { api } from "~/utils/api";
5 |
6 | export function useResults() {
7 | return api.results.results.useQuery();
8 | }
9 |
10 | const seed = 0;
11 | export function useProjectsResults() {
12 | return api.results.projects.useInfiniteQuery(
13 | { limit: config.pageSize, seed },
14 | { getNextPageParam: (_, pages) => pages.length },
15 | );
16 | }
17 |
18 | export function useProjectResults(id: string) {
19 | const isShowActualVotes = useIsShowActualVotes();
20 | const query = api.results.results.useQuery(undefined, {
21 | enabled: useRoundState() === "RESULTS",
22 | });
23 | const project = query.data?.projects?.[id];
24 |
25 | const votes = isShowActualVotes ? project?.actualVotes : project?.votes;
26 |
27 | return {
28 | ...query,
29 | data: votes ?? 0,
30 | };
31 | }
32 |
33 | export function useAllProjectsResults() {
34 | return api.results.allProjects.useQuery();
35 | }
36 |
--------------------------------------------------------------------------------
/src/hooks/useRevokeAttestations.ts:
--------------------------------------------------------------------------------
1 | import { useRevoke } from "~/hooks/useEAS";
2 | import { useMutation } from "@tanstack/react-query";
3 | import { useEthersSigner } from "~/hooks/useEthersSigner";
4 | import { toast } from "sonner";
5 | import { useCurrentRound } from "~/features/rounds/hooks/useRound";
6 | import { getContracts } from "~/lib/eas/createEAS";
7 |
8 | export function useRevokeAttestations(opts?: { onSuccess?: () => void }) {
9 | const revoke = useRevoke();
10 | const signer = useEthersSigner();
11 |
12 | const { data: round } = useCurrentRound();
13 |
14 | return useMutation({
15 | onSuccess: () => {
16 | toast.success("Attestations revoked successfully!");
17 | opts?.onSuccess?.();
18 | },
19 | onError: (err: { reason?: string; data?: { message: string } }) => {
20 | toast.error("Attestations revoke error", {
21 | description: err.reason ?? err.data?.message,
22 | });
23 | },
24 | mutationFn: async (attestationIds: string[]) => {
25 | if (!signer) throw new Error("Connect wallet first");
26 | if (!round?.network) throw new Error("Round network not configured");
27 |
28 | const contracts = getContracts(round.network);
29 |
30 | return revoke.mutateAsync(
31 | attestationIds.map((uid) => ({
32 | schema: contracts.schemas.approval,
33 | data: [{ uid }],
34 | })),
35 | );
36 | },
37 | });
38 | }
39 |
--------------------------------------------------------------------------------
/src/hooks/useRoundType.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useRouter } from "next/router";
3 | import { config } from "~/config";
4 |
5 | export function useRoundType() {
6 | const router = useRouter();
7 | const [isCeloRound, setIsCeloRound] = useState(null);
8 | const [isDripRound, setIsDripRound] = useState(null);
9 | const [isOtherRound, setIsOtherRound] = useState(null);
10 |
11 | useEffect(() => {
12 | const isCeloRound = router.asPath.includes(config.celoRoundId);
13 |
14 | const isDripRound = config.dripsRounds.some((round) =>
15 | router.asPath.includes(round),
16 | );
17 | setIsCeloRound(isCeloRound);
18 | setIsDripRound(isDripRound);
19 | setIsOtherRound(!(isCeloRound ?? isDripRound));
20 | }, [router]);
21 | const roundType = isCeloRound ? "CELO" : isDripRound ? "DRIP" : isOtherRound;
22 |
23 | return roundType;
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/useWatch.ts:
--------------------------------------------------------------------------------
1 | import { useQueryClient } from "@tanstack/react-query";
2 | import { useEffect } from "react";
3 | import { useBlockNumber } from "wagmi";
4 | import { useIsCorrectNetwork } from "./useIsCorrectNetwork";
5 |
6 | export function useWatch(queryKey: readonly unknown[]) {
7 | const queryClient = useQueryClient();
8 |
9 | const { isCorrectNetwork } = useIsCorrectNetwork();
10 | const { data: blockNumber } = useBlockNumber({
11 | watch: true,
12 | query: { enabled: isCorrectNetwork },
13 | });
14 | useEffect(() => {
15 | queryClient.invalidateQueries({ queryKey }).catch(console.log);
16 | }, [queryKey, blockNumber]);
17 | }
18 |
--------------------------------------------------------------------------------
/src/layouts/DefaultLayout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode, PropsWithChildren } from "react";
2 | import { useAccount } from "wagmi";
3 |
4 | import Header from "~/components/Header";
5 | import BallotOverview from "~/features/ballot/components/BallotOverview";
6 | import { BaseLayout, type LayoutProps } from "./BaseLayout";
7 | import {
8 | useCurrentDomain,
9 | useCurrentRound,
10 | } from "~/features/rounds/hooks/useRound";
11 | import { useRoundState } from "~/features/rounds/hooks/useRoundState";
12 | import { useSession } from "next-auth/react";
13 | import { Button } from "~/components/ui/Button";
14 | import Link from "next/link";
15 |
16 | type Props = PropsWithChildren<
17 | {
18 | sidebar?: "left" | "right";
19 | sidebarComponent?: ReactNode;
20 | } & LayoutProps
21 | >;
22 | export const Layout = ({ children, ...props }: Props) => {
23 | const { address } = useAccount();
24 |
25 | const domain = useCurrentDomain();
26 | const { data: round, isPending } = useCurrentRound();
27 |
28 | const navLinks = [
29 | {
30 | href: `/${domain}/projects`,
31 | children: `Projects`,
32 | },
33 | ];
34 |
35 | if (useRoundState() === "RESULTS") {
36 | navLinks.push({
37 | href: `/${domain}/stats`,
38 | children: `Stats`,
39 | });
40 | }
41 |
42 | if (round?.admins.includes(address!)) {
43 | navLinks.push(
44 | ...[
45 | {
46 | href: `/${domain}/admin`,
47 | children: `Admin`,
48 | },
49 | ],
50 | );
51 | }
52 |
53 | if (!isPending && !round) {
54 | return (
55 |
56 |
57 | Round not found
58 |
59 | Go home
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | return (
67 | }>
68 | {children}
69 |
70 | );
71 | };
72 |
73 | export function LayoutWithBallot(props: Props) {
74 | const { address } = useAccount();
75 | const { data: session } = useSession();
76 | return (
77 | }
80 | {...props}
81 | />
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/lib/eas/__test__/eas.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test, vi } from "vitest";
2 | import { createAttestation } from "../createAttestation";
3 | import type { Transaction } from "@ethereum-attestation-service/eas-sdk/dist/transaction";
4 | import { ethers } from "ethers";
5 | import type {
6 | AttestationRequest,
7 | GetSchemaParams,
8 | SchemaRecord,
9 | } from "@ethereum-attestation-service/eas-sdk";
10 | import { createEAS } from "../createEAS";
11 |
12 | const signer = ethers.Wallet.createRandom().connect(
13 | ethers.getDefaultProvider(),
14 | ) as unknown as ethers.providers.JsonRpcSigner;
15 | test("createAttestation", async () => {
16 | const application = {
17 | name: "foo",
18 | metadataType: 1,
19 | metadataPtr: "metadata",
20 | type: "application",
21 | round: "0x0",
22 | };
23 |
24 | const attestation = await createAttestation(
25 | {
26 | values: application,
27 | schemaUID:
28 | "0x76e98cce95f3ba992c2ee25cef25f756495147608a3da3aa2e5ca43109fe77cc",
29 | },
30 | signer,
31 | );
32 |
33 | expect(attestation.data.recipient).toEqual(await signer.getAddress());
34 | });
35 |
36 | test("createEAS", async () => {
37 | const eas = createEAS(signer);
38 |
39 | expect(eas.attest).toBeDefined();
40 | });
41 |
42 | const { attestMock } = vi.hoisted(() => ({
43 | attestMock: vi
44 | .fn<[AttestationRequest], Transaction>()
45 | .mockResolvedValue({} as Transaction),
46 | }));
47 |
48 | vi.mock("@ethereum-attestation-service/eas-sdk", async () => {
49 | const actual = await import("@ethereum-attestation-service/eas-sdk");
50 | const metadataSchemaRecord: SchemaRecord = {
51 | resolver: "0x0000000000000000000000000000000000000000",
52 | revocable: true,
53 | schema:
54 | "string name,uint256 metadataType,string metadataPtr,bytes32 type,bytes32 round",
55 | uid: "0x58e80750f091c47b3e55ac89942b30df23de405399edad95920fe15bd22309b7",
56 | };
57 | return {
58 | ...actual,
59 | // EAS: class extends actual.EAS {
60 | // async attest(req: AttestationRequest) {
61 | // return attestMock(req);
62 | // }
63 | // },
64 | SchemaRegistry: class extends actual.SchemaRegistry {
65 | async getSchema({ uid }: GetSchemaParams) {
66 | return Promise.resolve(metadataSchemaRecord);
67 | }
68 | },
69 | };
70 | });
71 |
--------------------------------------------------------------------------------
/src/lib/eas/createAttestation.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SchemaEncoder,
3 | SchemaRegistry,
4 | type SchemaValue,
5 | type AttestationRequest,
6 | type SchemaRecord,
7 | } from "@ethereum-attestation-service/eas-sdk";
8 | import { type Signer } from "ethers";
9 |
10 | import { eas } from "~/config";
11 |
12 | type Params = {
13 | values: Record;
14 | schemaUID: string;
15 | recipient?: string;
16 | refUID?: string;
17 | };
18 |
19 | export async function createAttestation(
20 | params: Params,
21 | signer: Signer,
22 | contracts: typeof eas.contracts.default,
23 | ): Promise {
24 | console.log("Getting recipient address");
25 | const recipient = params.recipient ?? (await signer.getAddress());
26 |
27 | const schemaRegistry = new SchemaRegistry(contracts.registry);
28 | console.log("Connecting signer to SchemaRegistry...");
29 | schemaRegistry.connect(signer);
30 | console.log("Getting schema record...", params.schemaUID);
31 | const schemaRecord = await schemaRegistry.getSchema({
32 | uid: params.schemaUID,
33 | });
34 |
35 | console.log("Encoding attestation data");
36 | const data = await encodeData(params, schemaRecord);
37 |
38 | return {
39 | schema: params.schemaUID,
40 | data: {
41 | recipient,
42 | expirationTime: 0n,
43 | revocable: true,
44 | data,
45 | refUID: params.refUID,
46 | },
47 | };
48 | }
49 |
50 | async function encodeData({ values }: Params, schemaRecord: SchemaRecord) {
51 | console.log("Getting schema record...");
52 |
53 | const schemaEncoder = new SchemaEncoder(schemaRecord.schema);
54 |
55 | console.log("Creating data to encode from schema record...");
56 | const dataToEncode = schemaRecord?.schema.split(",").map((param) => {
57 | const [type, name] = param.trim().split(" ");
58 | if (name && type && values) {
59 | const value = values[name] as SchemaValue;
60 | return { name, type, value };
61 | } else {
62 | throw new Error(
63 | `Attestation data: ${name} not found in ${JSON.stringify(values)}`,
64 | );
65 | }
66 | });
67 |
68 | console.log("Encoding data with schema...");
69 | return schemaEncoder.encodeData(dataToEncode);
70 | }
71 |
--------------------------------------------------------------------------------
/src/lib/eas/createEAS.ts:
--------------------------------------------------------------------------------
1 | import { type JsonRpcSigner } from "ethers";
2 | import {
3 | EAS,
4 | type TransactionSigner,
5 | } from "@ethereum-attestation-service/eas-sdk";
6 |
7 | import * as config from "~/config";
8 |
9 | export function createEAS(signer: JsonRpcSigner, network: string): EAS {
10 | console.log("Creating EAS instance");
11 | const contracts = getContracts(network);
12 |
13 | const eas = new EAS(contracts.eas);
14 |
15 | console.log("Connecting signer to EAS");
16 | return eas.connect(signer as unknown as TransactionSigner);
17 | }
18 |
19 | export function getContracts(network: string) {
20 | return {
21 | ...config.eas.contracts.default,
22 | ...(config.eas.contracts[network as keyof typeof config.eas.contracts] ??
23 | {}),
24 | };
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/eas/registerSchemas.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { SchemaRegistry } from "@ethereum-attestation-service/eas-sdk";
3 | import { type Address, formatEther, publicActions } from "viem";
4 | import { createWalletClient, http } from "viem";
5 | import { privateKeyToAccount } from "viem/accounts";
6 | import * as allChains from "viem/chains";
7 | import { getContracts } from "./createEAS";
8 | import { clientToSigner } from "~/hooks/useEthersSigner";
9 |
10 | const account = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as Address);
11 | const contracts = getContracts(process.env.NETWORK!);
12 | const chain = allChains[process.env.NETWORK as keyof typeof allChains];
13 | if (!chain)
14 | throw new Error(
15 | "Environment variable NETWORK must be set to a valid network",
16 | );
17 |
18 | const client = createWalletClient({
19 | account,
20 | chain,
21 | transport: http(),
22 | }).extend(publicActions);
23 |
24 | /*
25 | This file defines and registers the EAS schemas.
26 |
27 | Each schema's UID is generated by hashing the schema string, resolver address and revocable boolean.
28 | This means applications, profiles and lists share the same schema and are differentiated by the type.
29 |
30 |
31 | Run: npx tsx src/lib/eas/registerSchemas
32 | */
33 |
34 | const approvalSchema = "bytes32 type, bytes32 round";
35 | const metadataSchema =
36 | "string name, string metadataPtr, uint256 metadataType, bytes32 type, bytes32 round";
37 |
38 | const schemas = [
39 | { name: "Approval", schema: approvalSchema },
40 | { name: "Metadata", schema: metadataSchema },
41 | ];
42 |
43 | const schemaRegistry = new SchemaRegistry(contracts.registry);
44 | schemaRegistry.connect(clientToSigner(client));
45 |
46 | export async function registerSchemas() {
47 | console.log(
48 | "Balance: ",
49 | await client.getBalance({ address: account.address }).then(formatEther),
50 | );
51 | return Promise.all(
52 | schemas.map(async ({ name, schema }) => {
53 | console.log(`Registering schema: ${name}`);
54 |
55 | return schemaRegistry
56 | .register({ schema, revocable: true })
57 | .then(async (tx) => ({ name, uid: await tx.wait() }));
58 | }),
59 | ).then((registered) => {
60 | console.log(`Schemas registered!`);
61 | registered.forEach((schema) =>
62 | console.log(` ${schema.name}: ${schema.uid}`),
63 | );
64 |
65 | return registered;
66 | });
67 | }
68 |
69 | registerSchemas().catch(console.log);
70 |
--------------------------------------------------------------------------------
/src/pages/[domain]/admin/accounts/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "~/components/ui/Button";
3 | import { Form, FormSection } from "~/components/ui/Form";
4 | import {
5 | AddAddressesModal,
6 | AddressList,
7 | AddressSchema,
8 | DeleteSelectedButton,
9 | SelectAllButton,
10 | } from "~/features/admin/components/AddAddresses";
11 | import { RoundAdminLayout } from "~/features/admin/layouts/AdminLayout";
12 | import { useUpdateRound } from "~/features/rounds/hooks/useRound";
13 | import { api } from "~/utils/api";
14 |
15 | export default function AdminAccountsPage() {
16 | const [isOpen, setOpen] = useState(false);
17 |
18 | const utils = api.useUtils();
19 | const update = useUpdateRound();
20 |
21 | return (
22 |
23 | {({ data }) => (
24 |
84 | )}
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/pages/[domain]/admin/applications/index.tsx:
--------------------------------------------------------------------------------
1 | import { RoundAdminLayout } from "~/features/admin/layouts/AdminLayout";
2 | import { ApplicationsToApprove } from "~/features/applications/components/ApplicationsToApprove";
3 |
4 | export default function ApplicationsPage() {
5 | return (
6 |
7 | {() => }
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/[domain]/admin/distribute/index.tsx:
--------------------------------------------------------------------------------
1 | import { Spinner } from "~/components/ui/Spinner";
2 | import { CalculationForm } from "~/features/admin/components/CalculationForm";
3 | import { RoundAdminLayout } from "~/features/admin/layouts/AdminLayout";
4 | import ConfigurePool from "~/features/distribute/components/CreatePool";
5 | import { Distributions } from "~/features/distribute/components/Distributions";
6 |
7 | import {
8 | useCurrentRound,
9 | useUpdateRound,
10 | } from "~/features/rounds/hooks/useRound";
11 | import { api } from "~/utils/api";
12 |
13 | export default function DistributePage() {
14 | const utils = api.useUtils();
15 | const round = useCurrentRound();
16 | const update = useUpdateRound();
17 |
18 | return (
19 |
22 |
23 | {round.isPending ? (
24 |
25 | ) : (
26 | {
29 | update.mutate(
30 | { id: round.data?.id, calculationType, calculationConfig },
31 | {
32 | async onSuccess() {
33 | return utils.results.votes.invalidate();
34 | },
35 | },
36 | );
37 | }}
38 | />
39 | )}
40 |
41 | }
42 | >
43 | {() => (
44 |
45 | {update.isPending ? (
46 |
47 |
48 |
49 | ) : (
50 |
51 | )}
52 |
53 | )}
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/pages/[domain]/applications/[projectId]/index.tsx:
--------------------------------------------------------------------------------
1 | import { type GetServerSideProps } from "next";
2 |
3 | import ProjectDetails from "~/features/projects/components/ProjectDetails";
4 | import { useProjectById } from "~/features/projects/hooks/useProjects";
5 | import ApproveButton from "~/features/applications/components/ApproveButton";
6 | import { Layout } from "~/layouts/DefaultLayout";
7 |
8 | export default function ApplicationDetailsPage({ projectId = "" }) {
9 | const project = useProjectById(projectId);
10 |
11 | return (
12 |
13 | }
16 | />
17 |
18 | );
19 | }
20 |
21 | export const getServerSideProps: GetServerSideProps = async ({
22 | query: { projectId },
23 | }) => ({ props: { projectId } });
24 |
--------------------------------------------------------------------------------
/src/pages/[domain]/applications/new.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from "~/layouts/DefaultLayout";
2 |
3 | import { ApplicationForm } from "~/features/applications/components/ApplicationForm";
4 | import { CeloApplicationForm } from "~/features/applications/components/CeloApplicationForm";
5 | import { DripsApplicationForm } from "~/features/applications/components/DripsApplicationForm";
6 | import { useAccount } from "wagmi";
7 | import { Alert } from "~/components/ui/Alert";
8 | import { FormSection } from "~/components/ui/Form";
9 | import { useRoundType } from "~/hooks/useRoundType";
10 |
11 | export default function NewProjectPage() {
12 | const { address } = useAccount();
13 | const roundType = useRoundType();
14 | const isCeloRound = roundType === "CELO";
15 | const isDripRound = roundType === "DRIP";
16 | if (roundType === null) {
17 | return null;
18 | }
19 | return (
20 |
21 |
25 |
26 | Fill out this form to create an application for your project. It
27 | will then be reviewed by our admins.
28 |
29 |
30 | Your progress is saved locally so you can return to this page to
31 | resume your application.
32 |
33 | >
34 | }
35 | >
36 | {address ? (
37 |
38 | {isCeloRound ? (
39 |
40 | ) : isDripRound ? (
41 |
42 | ) : (
43 |
44 | )}
45 |
46 | ) : (
47 |
48 | )}
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/pages/[domain]/ballot/confirmation.tsx:
--------------------------------------------------------------------------------
1 | import { BallotConfirmation } from "~/features/ballot/components/BallotConfirmation";
2 | import { useBallot } from "~/features/ballot/hooks/useBallot";
3 | import { Layout } from "~/layouts/DefaultLayout";
4 |
5 | export default function BallotConfirmationPage() {
6 | const { data: ballot } = useBallot();
7 |
8 | if (!ballot) return null;
9 |
10 | return (
11 |
12 | +b.amount - +a.amount)}
14 | />
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/pages/[domain]/ballot/index.tsx:
--------------------------------------------------------------------------------
1 | import { Form } from "~/components/ui/Form";
2 | import { useBallot } from "~/features/ballot/hooks/useBallot";
3 | import { BallotSchema } from "~/features/ballot/types";
4 | import { BallotAllocationForm } from "~/features/ballot/components/BallotAllocationForm";
5 | import { LayoutWithBallot } from "~/layouts/DefaultLayout";
6 |
7 | export default function BallotPage() {
8 | const { data: ballot, isPending } = useBallot();
9 |
10 | if (isPending) return null;
11 |
12 | const votes = ballot?.votes.sort((a, b) => b.amount - a.amount);
13 | return (
14 |
15 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/[domain]/profile/index.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from "~/layouts/DefaultLayout";
2 | import { MyApplications } from "~/features/profile/components/MyApplications";
3 |
4 | export default function ProjectsPage() {
5 | return (
6 | }>
7 |
8 |
9 | );
10 | }
11 |
12 | function ProfileSidebar() {
13 | return (
14 |
15 |
Profile
16 |
See your submitted applications here.
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/pages/[domain]/projects/[projectId]/index.tsx:
--------------------------------------------------------------------------------
1 | import { type GetServerSideProps } from "next";
2 |
3 | import { LayoutWithBallot } from "~/layouts/DefaultLayout";
4 | import ProjectDetails from "~/features/projects/components/ProjectDetails";
5 | import { useProjectById } from "~/features/projects/hooks/useProjects";
6 | import { ProjectAddToBallot } from "~/features/projects/components/AddToBallot";
7 | import { ProjectAwarded } from "~/features/projects/components/ProjectAwarded";
8 | import { useRoundState } from "~/features/rounds/hooks/useRoundState";
9 | import { ProjectComments } from "~/features/comments/components/ProjectComments";
10 |
11 | export default function ProjectDetailsPage({ projectId = "" }) {
12 | const project = useProjectById(projectId);
13 | const { name } = project.data ?? {};
14 |
15 | const action =
16 | useRoundState() === "RESULTS" ? (
17 |
18 | ) : (
19 |
20 | );
21 | return (
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | export const getServerSideProps: GetServerSideProps = async ({
30 | query: { projectId },
31 | }) => ({ props: { projectId } });
32 |
--------------------------------------------------------------------------------
/src/pages/[domain]/projects/index.tsx:
--------------------------------------------------------------------------------
1 | import { LayoutWithBallot } from "~/layouts/DefaultLayout";
2 | import { Projects } from "~/features/projects/components/Projects";
3 |
4 | export default function ProjectsPage() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/[domain]/projects/results.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from "~/layouts/DefaultLayout";
2 | import { ProjectsResults } from "~/features/projects/components/ProjectsResults";
3 |
4 | export default function ProjectsResultsPage() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import "~/styles/globals.css";
2 | import "@rainbow-me/rainbowkit/styles.css";
3 |
4 | import { Inter, Teko } from "next/font/google";
5 | import type { AppProps } from "next/app";
6 | import type { Session } from "next-auth";
7 | import { Providers } from "~/providers";
8 | import { api } from "~/utils/api";
9 | import posthog from "posthog-js";
10 | import { PostHogProvider } from "posthog-js/react";
11 | import { useRouter } from "next/router";
12 | import { useEffect } from "react";
13 |
14 | if (typeof window !== "undefined") {
15 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
16 | api_host:
17 | process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com",
18 | person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well
19 | loaded: (posthog) => {
20 | if (process.env.NODE_ENV === "development") posthog.debug();
21 | },
22 | });
23 | }
24 |
25 | const inter = Inter({
26 | subsets: ["latin"],
27 | variable: "--font-inter",
28 | });
29 |
30 | const heading = Teko({
31 | weight: ["400"],
32 | subsets: ["latin"],
33 | variable: "--font-heading",
34 | });
35 |
36 | function MyApp({ Component, pageProps }: AppProps<{ session: Session }>) {
37 | const router = useRouter();
38 | useEffect(() => {
39 | // Track page views
40 | const handleRouteChange = () => posthog.capture("$pageview");
41 | router.events.on("routeChangeComplete", handleRouteChange);
42 |
43 | return () => {
44 | router.events.off("routeChangeComplete", handleRouteChange);
45 | };
46 | }, []);
47 | return (
48 |
49 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
65 | export default api.withTRPC(MyApp);
66 |
--------------------------------------------------------------------------------
/src/pages/api/blob.ts:
--------------------------------------------------------------------------------
1 | import { put } from "@vercel/blob";
2 | import type { NextApiResponse, NextApiRequest, PageConfig } from "next";
3 |
4 | export default async function handler(
5 | req: NextApiRequest,
6 | res: NextApiResponse,
7 | ) {
8 | const blob = await put(req.query.filename as string, req, {
9 | access: "public",
10 | });
11 |
12 | return res.status(200).json(blob);
13 | }
14 |
15 | export const config: PageConfig = {
16 | api: {
17 | bodyParser: false,
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/src/pages/api/ipfs.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import formidable from "formidable";
3 | import type { NextApiResponse, NextApiRequest, PageConfig } from "next";
4 | import pinataSDK from "@pinata/sdk";
5 |
6 | const pinata = new pinataSDK({ pinataJWTKey: process.env.PINATA_JWT });
7 |
8 | async function pinFileToIPFS(file: formidable.File) {
9 | try {
10 | const stream = fs.createReadStream(file.filepath);
11 | const response = await pinata.pinFileToIPFS(stream, {
12 | pinataMetadata: { name: file.originalFilename },
13 | });
14 | fs.unlinkSync(file.filepath);
15 |
16 | return response;
17 | } catch (error) {
18 | throw error;
19 | }
20 | }
21 |
22 | export default async function handler(
23 | req: NextApiRequest,
24 | res: NextApiResponse,
25 | ) {
26 | if (req.method === "POST") {
27 | try {
28 | const form = formidable({});
29 | form.parse(req, (err, _, files) => {
30 | if (err) {
31 | return res.status(500).send("Upload Error");
32 | }
33 | const [file] = files.file ?? [];
34 | if (file) {
35 | pinFileToIPFS(file)
36 | .then(({ IpfsHash: cid }) => res.send({ cid }))
37 | .catch(console.log);
38 | }
39 | });
40 | } catch (e) {
41 | console.log(e);
42 | res.status(500).send("Server Error");
43 | }
44 | } else {
45 | return res.status(405);
46 | }
47 | }
48 |
49 | export const config: PageConfig = {
50 | api: { bodyParser: false },
51 | };
52 |
--------------------------------------------------------------------------------
/src/pages/api/og.tsx:
--------------------------------------------------------------------------------
1 | import { ImageResponse } from "next/og";
2 | import { metadata } from "~/config";
3 |
4 | export const runtime = "edge";
5 |
6 | const size = {
7 | width: 1200,
8 | height: 630,
9 | };
10 |
11 | export const contentType = "image/png";
12 |
13 | export default async function Image() {
14 | return new ImageResponse(
15 | (
16 |
17 |
18 | {metadata.title}
19 |
20 |
{metadata.description}
21 |
22 | ),
23 | {
24 | ...size,
25 | },
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/pages/api/trpc/[trpc].ts:
--------------------------------------------------------------------------------
1 | import { createNextApiHandler } from "@trpc/server/adapters/next";
2 |
3 | import { env } from "~/env";
4 | import { appRouter } from "~/server/api/root";
5 | import { createTRPCContext } from "~/server/api/trpc";
6 |
7 | export default createNextApiHandler({
8 | router: appRouter,
9 | createContext: createTRPCContext,
10 | onError:
11 | env.NODE_ENV === "development"
12 | ? ({ path, error }) => {
13 | console.error(
14 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`,
15 | );
16 | }
17 | : undefined,
18 | });
19 |
--------------------------------------------------------------------------------
/src/pages/create-round/index.tsx:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { useAccount } from "wagmi";
3 | import { Button } from "~/components/ui/Button";
4 | import { Form, FormControl, FormSection, Input } from "~/components/ui/Form";
5 | import { ConnectButton } from "~/components/ConnectButton";
6 | import { BaseLayout } from "~/layouts/BaseLayout";
7 | import { api } from "~/utils/api";
8 | import { useRouter } from "next/router";
9 | import { RoundNameSchema } from "~/features/rounds/types";
10 |
11 | export default function CreateRoundPage() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | function CreateRound() {
22 | const router = useRouter();
23 | const { address } = useAccount();
24 |
25 | const create = api.rounds.create.useMutation();
26 |
27 | return (
28 | <>
29 |
30 | {!address && (
31 |
32 |
33 | You must connect your wallet to create a round
34 |
35 |
36 |
37 |
38 |
39 | )}
40 |
41 |
75 | >
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { BaseLayout } from "~/layouts/BaseLayout";
2 | import { Button } from "~/components/ui/Button";
3 | import Link from "next/link";
4 | import { ConnectButton } from "~/components/ConnectButton";
5 | import { createComponent } from "~/components/ui";
6 | import { tv } from "tailwind-variants";
7 | import { Github, Send } from "lucide-react";
8 |
9 | export default function ProjectsPage({}) {
10 | return (
11 |
14 |
15 |
16 | Go to app
17 |
18 |
19 |
20 | }
21 | >
22 |
23 |
24 | Retroactive Public Goods Funding
25 |
26 | for everyone
27 |
28 |
29 |
36 | Create a round
37 |
38 |
45 | Self-hosted
46 |
47 |
57 | What is RPGF?
58 |
59 |
60 |
61 |
62 |
66 |
67 | Github
68 |
69 |
70 |
71 | Telegram
72 |
73 |
74 |
75 | );
76 | }
77 |
78 | const Meta = createComponent(
79 | Link,
80 | tv({
81 | base: "flex items-center gap-2 text-xl text-gray-600 hover:text-gray-900",
82 | }),
83 | );
84 | const MetaIcon = createComponent(
85 | "div",
86 | tv({
87 | base: "size-6",
88 | }),
89 | );
90 |
--------------------------------------------------------------------------------
/src/pages/info/index.tsx:
--------------------------------------------------------------------------------
1 | import { RoundProgress } from "~/features/info/components/RoundProgress";
2 | import { Layout } from "~/layouts/DefaultLayout";
3 |
4 | export default function InfoPage() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/scripts/download-projects.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { config, eas } from "~/config";
3 | import { createDataFilter, fetchAttestations } from "~/utils/fetchAttestations";
4 |
5 | console.log(config.roundId, process.env.NEXT_PUBLIC_ROUND_ID);
6 | const filters = [
7 | createDataFilter("type", "bytes32", "application"),
8 | createDataFilter("round", "bytes32", "ROUND_ID"),
9 | ];
10 |
11 | console.log(filters);
12 |
13 | async function main() {
14 | const projects = await fetchAttestations([eas.schemas.approval], {
15 | where: {
16 | attester: { in: config.admins },
17 | ...createDataFilter("type", "bytes32", "application"),
18 | },
19 | })
20 | .then((attestations = []) => {
21 | const approvedIds = attestations
22 | .map(({ refUID }) => refUID)
23 | .filter(Boolean);
24 |
25 | return fetchAttestations([eas.schemas.metadata], {
26 | take: 100000,
27 | skip: 0,
28 | where: {
29 | AND: filters,
30 | },
31 | });
32 | })
33 | .then((projects) => {
34 | console.log(projects);
35 | });
36 | }
37 |
38 | main().catch(console.log);
39 |
--------------------------------------------------------------------------------
/src/server/api/root.ts:
--------------------------------------------------------------------------------
1 | import { ballotRouter } from "~/server/api/routers/ballot";
2 | import { resultsRouter } from "~/server/api/routers/results";
3 | import { roundsRouter } from "~/server/api/routers/rounds";
4 | import { commentsRouter } from "~/server/api/routers/comments";
5 | import { projectsRouter } from "~/server/api/routers/projects";
6 | import { metadataRouter } from "~/server/api/routers/metadata";
7 | import { applicationsRouter } from "~/server/api/routers/applications";
8 | import { profileRouter } from "~/server/api/routers/profile";
9 | import { votersRouter } from "~/server/api/routers/voters";
10 | import { createTRPCRouter } from "~/server/api/trpc";
11 |
12 | /**
13 | * This is the primary router for your server.
14 | *
15 | * All routers added in /api/routers should be manually added here.
16 | */
17 | export const appRouter = createTRPCRouter({
18 | rounds: roundsRouter,
19 | comments: commentsRouter,
20 | results: resultsRouter,
21 | ballot: ballotRouter,
22 | voters: votersRouter,
23 | applications: applicationsRouter,
24 | profile: profileRouter,
25 | metadata: metadataRouter,
26 | projects: projectsRouter,
27 | });
28 |
29 | // export type definition of API
30 | export type AppRouter = typeof appRouter;
31 |
--------------------------------------------------------------------------------
/src/server/api/routers/applications.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | import { attestationProcedure, createTRPCRouter } from "~/server/api/trpc";
4 | import { createDataFilter } from "~/utils/fetchAttestations";
5 |
6 | export const FilterSchema = z.object({
7 | limit: z.number().default(3 * 8),
8 | cursor: z.number().default(0),
9 | });
10 | export const applicationsRouter = createTRPCRouter({
11 | approvals: attestationProcedure
12 | .input(z.object({ ids: z.array(z.string()).optional() }))
13 | .query(async ({ input, ctx }) => {
14 | return ctx.fetchAttestations(["approval"], {
15 | where: {
16 | attester: { in: ctx.round?.admins },
17 | refUID: input.ids ? { in: input.ids } : undefined,
18 | AND: [
19 | createDataFilter("type", "bytes32", "application"),
20 | createDataFilter("round", "bytes32", String(ctx.round?.id)),
21 | ],
22 | },
23 | });
24 | }),
25 | list: attestationProcedure
26 | .input(z.object({ attester: z.string().optional() }))
27 | .query(async ({ input: { attester }, ctx }) => {
28 | return ctx.fetchAttestations(["metadata"], {
29 | orderBy: [{ time: "desc" }],
30 | where: {
31 | AND: [
32 | attester ? { attester: { equals: attester } } : {},
33 | createDataFilter("type", "bytes32", "application"),
34 | createDataFilter("round", "bytes32", String(ctx.round?.id)),
35 | ],
36 | },
37 | });
38 | }),
39 | });
40 |
--------------------------------------------------------------------------------
/src/server/api/routers/comments.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createTRPCRouter,
3 | protectedProcedure,
4 | protectedRoundProcedure,
5 | } from "~/server/api/trpc";
6 | import { CommentSchema } from "~/features/comments/types";
7 | import { fetchApprovedVoter } from "~/utils/fetchAttestations";
8 | import { TRPCError } from "@trpc/server";
9 | import { z } from "zod";
10 |
11 | export const commentsRouter = createTRPCRouter({
12 | create: protectedRoundProcedure
13 | .input(CommentSchema)
14 | .mutation(async ({ input: { content, projectId }, ctx }) => {
15 | const creatorId = String(ctx.session?.user.name);
16 |
17 | if (!(await fetchApprovedVoter(ctx.round, creatorId))) {
18 | throw new TRPCError({
19 | code: "UNAUTHORIZED",
20 | message: "Must be an approved voter to comment",
21 | });
22 | }
23 | const roundId = ctx.round.id;
24 | return ctx.db.comment.create({
25 | data: { content, creatorId, projectId, roundId },
26 | });
27 | }),
28 | delete: protectedProcedure
29 | .input(z.object({ id: z.string() }))
30 | .mutation(async ({ input: { id }, ctx }) => {
31 | const creatorId = String(ctx.session.user.name);
32 | return ctx.db.comment.delete({ where: { id, creatorId } });
33 | }),
34 | update: protectedProcedure
35 | .input(z.object({ id: z.string(), content: z.string() }))
36 | .mutation(async ({ input: { id, content }, ctx }) => {
37 | const creatorId = String(ctx.session.user.name);
38 | return ctx.db.comment.update({
39 | where: { id, creatorId },
40 | data: { content },
41 | });
42 | }),
43 | list: protectedRoundProcedure
44 | .input(z.object({ projectId: z.string() }))
45 | .query(async ({ input: { projectId }, ctx }) => {
46 | const roundId = ctx.round.id;
47 | return ctx.db.comment.findMany({ where: { projectId, roundId } });
48 | }),
49 | });
50 |
--------------------------------------------------------------------------------
/src/server/api/routers/metadata.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
3 | import { fetchMetadata } from "~/utils/fetchMetadata";
4 |
5 | export const metadataRouter = createTRPCRouter({
6 | get: publicProcedure
7 | .input(z.object({ metadataPtr: z.string() }))
8 | .query(async ({ input: { metadataPtr } }) => fetchMetadata(metadataPtr)),
9 | });
10 |
--------------------------------------------------------------------------------
/src/server/api/routers/profile.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from "@trpc/server";
2 | import { z } from "zod";
3 |
4 | import { attestationProcedure, createTRPCRouter } from "~/server/api/trpc";
5 | import { createDataFilter } from "~/utils/fetchAttestations";
6 |
7 | export const profileRouter = createTRPCRouter({
8 | get: attestationProcedure
9 | .input(z.object({ id: z.string() }))
10 | .query(async ({ input, ctx }) => {
11 | return ctx
12 | .fetchAttestations(["metadata"], {
13 | where: {
14 | recipient: { in: [input.id] },
15 | ...createDataFilter("type", "bytes32", "profile"),
16 | },
17 | orderBy: [{ time: "desc" }],
18 | })
19 | .then(([attestation]) => {
20 | if (!attestation) {
21 | throw new TRPCError({ code: "NOT_FOUND" });
22 | }
23 | return attestation;
24 | });
25 | }),
26 | });
27 |
--------------------------------------------------------------------------------
/src/server/api/routers/rounds.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { convert } from "url-slug";
3 | import { addDays, addMonths } from "date-fns";
4 | import {
5 | roundProcedure,
6 | createTRPCRouter,
7 | protectedProcedure,
8 | adminProcedure,
9 | } from "~/server/api/trpc";
10 | import { RoundNameSchema, RoundSchema } from "~/features/rounds/types";
11 | import { networks } from "~/config";
12 | import { type PrismaClient } from "@prisma/client";
13 |
14 | export const roundsRouter = createTRPCRouter({
15 | get: roundProcedure
16 | .input(z.object({ domain: z.string() }))
17 | .query(async ({ input: { domain }, ctx }) => {
18 | return (await ctx.db.round.findFirst({
19 | where: { domain },
20 | })) as RoundSchema | null;
21 | }),
22 | create: protectedProcedure
23 | .input(z.object({ name: RoundNameSchema }))
24 | .mutation(async ({ input: { name }, ctx }) => {
25 | const domain = await createDomain(name, ctx.db);
26 |
27 | const creatorId = ctx.session.user.name!;
28 |
29 | const now = new Date();
30 | return ctx.db.round
31 | .create({
32 | data: {
33 | name,
34 | domain,
35 | creatorId,
36 | startsAt: addDays(now, 7),
37 | reviewAt: addDays(now, 14),
38 | votingAt: addMonths(now, 1),
39 | resultAt: addMonths(addDays(now, 7), 1),
40 | payoutAt: addMonths(addDays(now, 7), 1),
41 | maxVotesProject: 10_000,
42 | maxVotesTotal: 100_000,
43 | admins: [creatorId],
44 | network: networks.optimismSepolia,
45 | calculationType: "average",
46 | },
47 | })
48 | .then((r) => r as RoundSchema | undefined);
49 | }),
50 |
51 | update: adminProcedure
52 | .input(RoundSchema.partial())
53 | .mutation(async ({ input, ctx }) => {
54 | return ctx.db.round.update({ where: { id: ctx.round?.id }, data: input });
55 | }),
56 |
57 | list: protectedProcedure.query(async ({ ctx }) => {
58 | const creatorId = ctx.session.user.name!;
59 |
60 | return ctx.db.round.findMany({ where: { admins: { has: creatorId } } });
61 | }),
62 | });
63 |
64 | const blacklistedDomains = ["api", "app", "create-round"];
65 | async function createDomain(name: string, db: PrismaClient) {
66 | let domain = convert(name);
67 | // If domain already exist or one of the not allowed - append a random string
68 | if (
69 | blacklistedDomains.includes(domain) ||
70 | (await db.round.findFirst({ where: { domain } }))
71 | ) {
72 | domain = domain + `_${Math.random().toString(16).slice(2, 5)}`;
73 | }
74 | return domain;
75 | }
76 |
--------------------------------------------------------------------------------
/src/server/auth.ts:
--------------------------------------------------------------------------------
1 | import { type GetServerSidePropsContext } from "next";
2 | import { getServerSession, type DefaultSession } from "next-auth";
3 |
4 | import { getAuthOptions } from "~/pages/api/auth/[...nextauth]";
5 |
6 | /**
7 | * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
8 | * object and keep type safety.
9 | *
10 | * @see https://next-auth.js.org/getting-started/typescript#module-augmentation
11 | */
12 | declare module "next-auth" {
13 | interface Session extends DefaultSession {
14 | user: DefaultSession["user"] & {
15 | id: string;
16 | };
17 | }
18 | }
19 |
20 | /**
21 | * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
22 | *
23 | * @see https://next-auth.js.org/configuration/nextjs
24 | */
25 | export const getServerAuthSession = (ctx: {
26 | req: GetServerSidePropsContext["req"];
27 | res: GetServerSidePropsContext["res"];
28 | }) => {
29 | return getServerSession(ctx.req, ctx.res, getAuthOptions(ctx.req));
30 | };
31 |
--------------------------------------------------------------------------------
/src/server/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | import { env } from "../env";
4 |
5 | const globalForPrisma = globalThis as unknown as {
6 | prisma: PrismaClient | undefined;
7 | };
8 |
9 | export const db =
10 | globalForPrisma.prisma ??
11 | new PrismaClient({
12 | log:
13 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
14 | });
15 |
16 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;
17 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .no-scrollbar ::-webkit-scrollbar {
6 | display: none;
7 | width: 0;
8 | }
9 | .no-scrollbar {
10 | -ms-overflow-style: none;
11 | scrollbar-width: 0;
12 | }
13 |
--------------------------------------------------------------------------------
/src/test-msw.ts:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom";
2 |
3 | import superjson from "superjson";
4 | import { createTRPCMsw } from "msw-trpc";
5 | import { type AppRouter } from "./server/api/root";
6 | import { setupServer } from "msw/node";
7 | import { HttpResponse, http } from "msw";
8 |
9 | export const mockTrpc = createTRPCMsw({
10 | baseUrl: "http://localhost:3000/api/trpc",
11 | transformer: {
12 | input: superjson,
13 | output: superjson,
14 | },
15 | });
16 |
17 | const mockProjects = Array(24)
18 | .fill(null)
19 | .map((_, i) => ({
20 | id: `project-${i}`,
21 | name: `Project #${i}`,
22 | metadataPtr: "https://localhost:3000/api/metadata",
23 | }));
24 | export const server = setupServer(
25 | http.get("/api/auth/session", () => {
26 | return HttpResponse.json({});
27 | }),
28 | // mockTrpc.projects.search.query(() => {
29 | // return mockProjects;
30 | // }),
31 | // mockTrpc.projects.get.query(({ id }) => {
32 | // return mockProjects.find((p) => p.id === id);
33 | // }),
34 | // mockTrpc.metadata.get.query((params) => {
35 | // console.log(params);
36 | // return {};
37 | // }),
38 | // mockTrpc.projects.count.query(() => {
39 | // return { count: 9999 };
40 | // }),
41 | );
42 |
--------------------------------------------------------------------------------
/src/test-setup.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import "@testing-library/jest-dom";
3 |
4 | import { afterAll, beforeAll, vi } from "vitest";
5 |
6 | import "./test-msw";
7 |
8 | import { server } from "./test-msw";
9 | import { config } from "./config";
10 |
11 | console.log(config);
12 | beforeAll(() => {
13 | // server.listen({ onUnhandledRequest: "warn" });
14 | /* eslint-disable-next-line */
15 | vi.mock("next/router", () => require("next-router-mock"));
16 | });
17 | afterAll(() => server.close());
18 |
19 | Object.defineProperty(window, "matchMedia", {
20 | writable: true,
21 | value: vi.fn().mockImplementation((query) => ({
22 | matches: false,
23 | /* eslint-disable-next-line */
24 | media: query,
25 | onchange: null,
26 | addListener: vi.fn(),
27 | removeListener: vi.fn(),
28 | addEventListener: vi.fn(),
29 | removeEventListener: vi.fn(),
30 | dispatchEvent: vi.fn(),
31 | })),
32 | });
33 |
--------------------------------------------------------------------------------
/src/test-utils.tsx:
--------------------------------------------------------------------------------
1 | import React, { type PropsWithChildren, type ReactElement } from "react";
2 | import { type RenderOptions, render } from "@testing-library/react";
3 |
4 | import { createTRPCNext } from "@trpc/next";
5 | import superjson from "superjson";
6 | import { httpLink } from "@trpc/client";
7 |
8 | import { type AppRouter } from "./server/api/root";
9 |
10 | import { Providers } from "./providers";
11 |
12 | const AllTheProviders = ({ children }: PropsWithChildren) => {
13 | return {children} ;
14 | };
15 |
16 | const mockApi = createTRPCNext({
17 | config() {
18 | return {
19 | transformer: superjson,
20 | links: [
21 | httpLink({
22 | url: `http://localhost:3000/api/trpc`,
23 | headers: () => ({ "Content-Type": "application/json" }),
24 | }),
25 | ],
26 | };
27 | },
28 | ssr: false,
29 | });
30 |
31 | const customRender = (
32 | ui: ReactElement,
33 | options?: Omit,
34 | ) => render(ui, { wrapper: mockApi.withTRPC(AllTheProviders), ...options });
35 |
36 | // re-export everything
37 | export * from "@testing-library/react";
38 |
39 | // override render method
40 | export { customRender as render };
41 |
--------------------------------------------------------------------------------
/src/utils/api.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which
3 | * contains the Next.js App-wrapper, as well as your type-safe React Query hooks.
4 | *
5 | * We also create a few inference helpers for input and output types.
6 | */
7 | import { httpBatchLink, loggerLink } from "@trpc/client";
8 | import { createTRPCNext } from "@trpc/next";
9 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
10 | import superjson from "superjson";
11 |
12 | import { type AppRouter } from "~/server/api/root";
13 |
14 | const getBaseUrl = () => {
15 | if (typeof window !== "undefined") return ""; // browser should use relative url
16 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
17 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
18 | };
19 |
20 | /** A set of type-safe react-query hooks for your tRPC API. */
21 | export const api = createTRPCNext({
22 | config() {
23 | return {
24 | /**
25 | * Links used to determine request flow from client to server.
26 | *
27 | * @see https://trpc.io/docs/links
28 | */
29 | links: [
30 | loggerLink({
31 | enabled: (opts) =>
32 | process.env.NODE_ENV === "development" ||
33 | (opts.direction === "down" && opts.result instanceof Error),
34 | }),
35 | httpBatchLink({
36 | transformer: superjson,
37 | url: `${getBaseUrl()}/api/trpc`,
38 | }),
39 | ],
40 | };
41 | },
42 | /**
43 | * Whether tRPC should await queries when server rendering pages.
44 | *
45 | * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false
46 | */
47 | ssr: false,
48 | transformer: superjson,
49 | });
50 |
51 | /**
52 | * Inference helper for inputs.
53 | *
54 | * @example type HelloInput = RouterInputs['example']['hello']
55 | */
56 | export type RouterInputs = inferRouterInputs;
57 |
58 | /**
59 | * Inference helper for outputs.
60 | *
61 | * @example type HelloOutput = RouterOutputs['example']['hello']
62 | */
63 | export type RouterOutputs = inferRouterOutputs;
64 |
--------------------------------------------------------------------------------
/src/utils/calculateResults.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 | import { calculateVotes } from "./calculateResults";
3 |
4 | describe("Calculate results", () => {
5 | const ballots = [
6 | {
7 | voterId: "voterA",
8 | votes: [
9 | {
10 | projectId: "projectA",
11 | amount: 20,
12 | },
13 | {
14 | projectId: "projectB",
15 | amount: 30,
16 | },
17 | ],
18 | },
19 | {
20 | voterId: "voterB",
21 | votes: [
22 | {
23 | projectId: "projectA",
24 | amount: 22,
25 | },
26 | {
27 | projectId: "projectB",
28 | amount: 50,
29 | },
30 | ],
31 | },
32 | {
33 | voterId: "voterC",
34 | votes: [
35 | {
36 | projectId: "projectA",
37 | amount: 30,
38 | },
39 | {
40 | projectId: "projectB",
41 | amount: 40,
42 | },
43 | {
44 | projectId: "projectC",
45 | amount: 60,
46 | },
47 | ],
48 | },
49 | {
50 | voterId: "voterD",
51 | votes: [
52 | {
53 | projectId: "projectA",
54 | amount: 35,
55 | },
56 | {
57 | projectId: "projectC",
58 | amount: 70,
59 | },
60 | ],
61 | },
62 | ];
63 | test("custom payout", () => {
64 | const { projects } = calculateVotes(ballots, { style: "custom" });
65 |
66 | const actual: Record<
67 | string,
68 | {
69 | voters: number;
70 | votes: number;
71 | }
72 | > = {};
73 |
74 | for (const key in projects) {
75 | if (projects.hasOwnProperty(key)) {
76 | const { voters, votes } = projects[key]!;
77 | actual[key] = { voters, votes };
78 | }
79 | }
80 | console.log(actual);
81 |
82 | expect(actual).toMatchInlineSnapshot(`
83 | {
84 | "projectA": {
85 | "voters": 4,
86 | "votes": 107,
87 | },
88 | "projectB": {
89 | "voters": 3,
90 | "votes": 120,
91 | },
92 | "projectC": {
93 | "voters": 2,
94 | "votes": 130,
95 | },
96 | }
97 | `);
98 | });
99 | test("OP-style payout", () => {
100 | const { projects } = calculateVotes(ballots, {
101 | style: "op",
102 | threshold: 3,
103 | });
104 |
105 | const actual: Record<
106 | string,
107 | {
108 | voters: number;
109 | votes: number;
110 | }
111 | > = {};
112 |
113 | for (const key in projects) {
114 | if (projects.hasOwnProperty(key)) {
115 | const { voters, votes } = projects[key]!;
116 | actual[key] = { voters, votes };
117 | }
118 | }
119 |
120 | console.log(actual);
121 | expect(actual).toMatchInlineSnapshot(`
122 | {
123 | "projectA": {
124 | "voters": 4,
125 | "votes": 26,
126 | },
127 | "projectB": {
128 | "voters": 3,
129 | "votes": 40,
130 | },
131 | }
132 | `);
133 | });
134 | });
135 |
--------------------------------------------------------------------------------
/src/utils/calculateResults.ts:
--------------------------------------------------------------------------------
1 | import { type Vote } from "~/features/ballot/types";
2 |
3 | export type PayoutOptions = {
4 | calculation: "average" | "median" | "sum";
5 | threshold?: number;
6 | };
7 | export type BallotResults = Record<
8 | string,
9 | {
10 | voters: number;
11 | votes: number;
12 | actualVotes: number;
13 | }
14 | >;
15 | export function calculateVotes(
16 | ballots: { voterId: string; votes: Vote[] }[],
17 | payoutOpts: PayoutOptions,
18 | ): { actualTotalVotes: number; projects: BallotResults } {
19 | let actualTotalVotes = 0;
20 |
21 | const projectVotes: Record<
22 | string,
23 | {
24 | total: number;
25 | amounts: number[];
26 | voterIds: Set;
27 | }
28 | > = {};
29 |
30 | for (const ballot of ballots) {
31 | for (const vote of ballot.votes) {
32 | if (!projectVotes[vote.projectId]) {
33 | projectVotes[vote.projectId] = {
34 | total: 0,
35 | amounts: [],
36 | voterIds: new Set(),
37 | };
38 | }
39 | projectVotes[vote.projectId]!.amounts.push(vote.amount);
40 | projectVotes[vote.projectId]!.voterIds.add(ballot.voterId);
41 | projectVotes[vote.projectId]!.total += vote.amount;
42 | actualTotalVotes += vote.amount;
43 | }
44 | }
45 |
46 | const projects: BallotResults = {};
47 |
48 | const calcFunctions = {
49 | average: calculateAverage,
50 | median: calculateMedian,
51 | sum: calculateSum,
52 | };
53 | for (const projectId in projectVotes) {
54 | const { total, amounts, voterIds } = projectVotes[projectId]!;
55 |
56 | const { threshold = 0 } = payoutOpts;
57 | const voteIsCounted = voterIds.size >= threshold;
58 |
59 | if (voteIsCounted) {
60 | projects[projectId] = {
61 | voters: voterIds.size,
62 | votes: calcFunctions[payoutOpts.calculation ?? "average"]?.(
63 | amounts.sort((a, b) => a - b),
64 | ),
65 | actualVotes: total,
66 | };
67 | }
68 | }
69 |
70 | return { actualTotalVotes, projects };
71 | }
72 |
73 | function calculateSum(arr: number[]) {
74 | return arr?.reduce((sum, x) => sum + x, 0);
75 | }
76 | function calculateAverage(arr: number[]) {
77 | if (arr.length === 0) {
78 | return 0;
79 | }
80 |
81 | const sum = arr.reduce((sum, x) => sum + x, 0);
82 | const average = sum / arr.length;
83 |
84 | return Math.round(average);
85 | }
86 |
87 | function calculateMedian(arr: number[]): number {
88 | const mid = Math.floor(arr.length / 2);
89 | return arr.length % 2 !== 0
90 | ? arr[mid] ?? 0
91 | : ((arr[mid - 1] ?? 0) + (arr[mid] ?? 0)) / 2;
92 | }
93 |
--------------------------------------------------------------------------------
/src/utils/classNames.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/csv.ts:
--------------------------------------------------------------------------------
1 | import Papa, { type UnparseConfig } from "papaparse";
2 |
3 | export function parse(file: string) {
4 | return Papa.parse(file, { header: true, skipEmptyLines: true });
5 | }
6 | export function format(data: unknown[], config: UnparseConfig) {
7 | return Papa.unparse(data, config);
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/fetch.ts:
--------------------------------------------------------------------------------
1 | import NodeFetchCache, { MemoryCache } from "node-fetch-cache";
2 |
3 | export function createCachedFetch({ ttl = 1000 * 60 }) {
4 | const _fetch = NodeFetchCache.create({ cache: new MemoryCache({ ttl }) });
5 |
6 | return function fetch(
7 | url: string,
8 | opts?: { method: "POST" | "GET"; body?: string },
9 | ) {
10 | return _fetch(url, {
11 | method: opts?.method ?? "GET",
12 | body: opts?.body,
13 | headers: { "Content-Type": "application/json" },
14 | }).then(async (r) => {
15 | if (!r.ok) {
16 | await r.ejectFromCache();
17 | throw new Error("Network error");
18 | }
19 |
20 | return (await r.json()) as { data: T; error: Error };
21 | });
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/fetchMetadata.ts:
--------------------------------------------------------------------------------
1 | import { createCachedFetch } from "./fetch";
2 |
3 | // ipfs data never changes
4 | const ttl = 2147483647;
5 | const fetch = createCachedFetch({ ttl });
6 |
7 | export async function fetchMetadata(url: string) {
8 | const ipfsGateway =
9 | process.env.NEXT_PUBLIC_IPFS_GATEWAY ?? "https://dweb.link/ipfs/";
10 |
11 | if (!url.startsWith("http")) {
12 | url = `${ipfsGateway}${url}`;
13 | }
14 |
15 | return fetch(url);
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils/fetchVoterLimits.ts:
--------------------------------------------------------------------------------
1 | import { type Round } from "@prisma/client";
2 | import { db } from "~/server/db";
3 |
4 | export async function fetchVoterLimits(
5 | round: Pick,
6 | voterId: string,
7 | ) {
8 | const voter = await db.voterConfig.findUnique({
9 | where: { voterId_roundId: { voterId, roundId: round.id } },
10 | });
11 |
12 | return (
13 | voter ?? {
14 | maxVotesTotal: round.maxVotesTotal ?? 0,
15 | maxVotesProject: round.maxVotesProject ?? 0,
16 | }
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/utils/filterKnownNullBallots.ts:
--------------------------------------------------------------------------------
1 | import type { Vote } from "~/features/ballot/types";
2 |
3 | export function filterKnownNullBallots(
4 | roundId: string,
5 | ballots: { voterId: string; votes: Vote[] }[],
6 | ): { voterId: string; votes: Vote[] }[] {
7 | switch (roundId) {
8 | case "clw51aqhg0000llf7qgyqgrbl":
9 | return ballots.map((ballot) => {
10 | switch (ballot.voterId) {
11 | case "0xB9B04D9667D439215268DBe9F0F7126Dc8486bc8":
12 | return {
13 | ...ballot,
14 | votes: ballot.votes.filter(
15 | (vote) =>
16 | ![
17 | "0xc0d4e44c0f3821ec058f176c1037191754c94fbf589458c43ac1c5696771dcf6",
18 | "0x7ff9f6b18d91d1c68a7ffe9a8ae47d1d80c901d41aa09a421396940f9d52c78b",
19 | "0x5148a380bcde7d5bd2711a6b3700292a1ccde7a8397592d3a1e398e10f5ea4ae",
20 | "0x97c4d252752415eafc3a8f471be23ef03389ea2871035162ad262db067d727f4",
21 | "0x42717b94bd215827036600416cca928d2872b39ccd61d8efc56a265ef5084a5a",
22 | ].includes(vote.projectId),
23 | ),
24 | };
25 | default:
26 | return ballot;
27 | }
28 | });
29 | default:
30 | return ballots;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/formatCurrency.ts:
--------------------------------------------------------------------------------
1 | import { formatNumber } from "./formatNumber";
2 | import { suffixNumber } from "./suffixNumber";
3 |
4 | export function formatCurrency(amount: number, currency: string, short = true) {
5 | return `${short ? suffixNumber(amount) : formatNumber(amount)} ${currency}`;
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/formatNumber.ts:
--------------------------------------------------------------------------------
1 | export const formatNumber = (num = 0) =>
2 | Number(num)?.toLocaleString("en-US", {
3 | minimumFractionDigits: 0,
4 | maximumFractionDigits: 0,
5 | }) ?? "0";
6 |
--------------------------------------------------------------------------------
/src/utils/reverseKeys.ts:
--------------------------------------------------------------------------------
1 | export const reverseKeys = (obj: Record) =>
2 | Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k]));
3 |
--------------------------------------------------------------------------------
/src/utils/suffixNumber.ts:
--------------------------------------------------------------------------------
1 | export const suffixNumber = (num: number) => {
2 | const lookup = [
3 | { value: 1, symbol: "" },
4 | { value: 1_000, symbol: "k" },
5 | { value: 1_000_000, symbol: "M" },
6 | ];
7 | const regexp = /\.0+$|(\.[0-9]*[1-9])0+$/;
8 | const item = [...lookup].reverse().find((item) => num >= item.value);
9 | return item
10 | ? (num / item.value).toFixed(2).replace(regexp, "$1") + item.symbol
11 | : "0";
12 | };
13 |
--------------------------------------------------------------------------------
/src/utils/time.ts:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 |
3 | export const calculateTimeLeft = (
4 | date: Date,
5 | ): [number, number, number, number] => {
6 | const sec = Math.floor((date.getTime() - Date.now()) / 1000);
7 | const min = Math.floor(sec / 60);
8 | const hrs = Math.floor(min / 60);
9 | const days = Math.floor(hrs / 24);
10 |
11 | return [days % 365, hrs % 24, min % 60, sec % 60];
12 | };
13 |
14 | export const formatDate = (date: Date | number) =>
15 | format(date, "dd MMM yyyy HH:mm");
16 |
--------------------------------------------------------------------------------
/src/utils/truncate.ts:
--------------------------------------------------------------------------------
1 | export const truncate = (str = "", max = 20, sep = "...") => {
2 | const len = str.length;
3 | if (len > max) {
4 | const seplen = sep.length;
5 |
6 | // If seperator is larger than character limit,
7 | // well then we don't want to just show the seperator,
8 | // so just show right hand side of the string.
9 | if (seplen > max) {
10 | return str.substr(len - max);
11 | }
12 |
13 | // Half the difference between max and string length.
14 | // Multiply negative because small minus big.
15 | // Must account for length of separator too.
16 | const n = -0.5 * (max - len - seplen);
17 |
18 | // This gives us the centerline.
19 | const center = len / 2;
20 |
21 | const front = str.substr(0, center - n);
22 | const back = str.substr(len - center + n); // without second arg, will automatically go to end of line.
23 |
24 | return front + sep + back;
25 | }
26 |
27 | return str;
28 | };
29 |
--------------------------------------------------------------------------------
/src/utils/typedData.ts:
--------------------------------------------------------------------------------
1 | export const ballotTypedData = (chainId?: number) =>
2 | ({
3 | primaryType: "Ballot",
4 | domain: {
5 | name: "Sign votes",
6 | version: "1",
7 | chainId,
8 | },
9 | types: {
10 | Ballot: [
11 | { name: "total_votes", type: "uint256" },
12 | { name: "project_count", type: "uint256" },
13 | { name: "hashed_votes", type: "string" },
14 | ],
15 | },
16 | }) as const;
17 |
--------------------------------------------------------------------------------
/src/utils/url.ts:
--------------------------------------------------------------------------------
1 | export const mergeParams = (prev: object, next: object = {}) =>
2 | new URLSearchParams({ ...prev, ...next }).toString();
3 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from "tailwindcss";
2 |
3 | import colors from "tailwindcss/colors";
4 | import theme from "tailwindcss/defaultTheme";
5 |
6 | export default {
7 | content: ["./src/**/*.tsx"],
8 | darkMode: "class",
9 | theme: {
10 | extend: {
11 | colors: {
12 | ...colors,
13 | primary: colors.green,
14 | gray: colors.stone,
15 | },
16 | fontFamily: {
17 | sans: ["var(--font-inter)", ...theme.fontFamily.sans],
18 | heading: ["var(--font-heading)", ...theme.fontFamily.sans],
19 | },
20 | },
21 | },
22 | plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
23 | } satisfies Config;
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Base Options: */
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 |
12 | /* Strictness */
13 | "strict": true,
14 | "noUncheckedIndexedAccess": true,
15 | "checkJs": true,
16 |
17 | /* Bundled projects */
18 | "lib": ["dom", "dom.iterable", "ES2022"],
19 | "noEmit": true,
20 | "module": "ESNext",
21 | "moduleResolution": "Bundler",
22 | "jsx": "preserve",
23 | "plugins": [{ "name": "next" }],
24 | "incremental": true,
25 |
26 | /* Path Aliases */
27 | "baseUrl": ".",
28 | "paths": {
29 | "~/*": ["./src/*"]
30 | }
31 | },
32 | "include": [
33 | ".eslintrc.cjs",
34 | "next-env.d.ts",
35 | "**/*.ts",
36 | "**/*.tsx",
37 | "**/*.cjs",
38 | "**/*.js"
39 | ],
40 | "exclude": ["node_modules", ".next"]
41 | }
42 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | import react from "@vitejs/plugin-react";
5 | import { defineConfig } from "vite";
6 | import tsconfigPaths from "vite-tsconfig-paths";
7 |
8 | // https://vitejs.dev/config/
9 | export default defineConfig({
10 | plugins: [react(), tsconfigPaths()],
11 | test: {
12 | globals: true,
13 | environment: "happy-dom",
14 | setupFiles: ["./src/test-setup.ts"],
15 | pool: "forks",
16 | },
17 | });
18 |
--------------------------------------------------------------------------------