├── .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 |
4 | 5 | Website 6 | | 7 | Telegram Group 8 | 9 |
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 | ![](./images/voting_ended_sidebar.png) 10 | 11 | You can also see statistics of the round: 12 | 13 | - https://easy-retro-pgf.vercel.app/stats 14 | 15 | ![](./images/stats.png) 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 | ![](./images/create_allo_profile.png) 19 | ![](./images/create_pool.png) 20 | ![](./images/distribute.png) 21 | ![](./images/distribute_modal.png) 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 | 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 | 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 |
42 |
43 |
44 | setOpen(!isOpen)} 49 | /> 50 | 51 | 52 | 53 |
54 |
55 | {navLinks?.map((link) => ( 56 | 61 | {link.children} 62 | 63 | ))} 64 |
65 | 66 | 67 |
68 |
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 | 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 | 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 | 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 | 29 | 30 | {voters.data?.map((voter) => ( 31 | 32 | 33 | 36 | 37 | ))} 38 |
{voter.voterId} 34 | {voter.maxVotesProject} / {voter.maxVotesTotal} 35 |
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 | 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 | 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 | 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 |
44 | 45 |
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 | 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 |
25 | 26 | 31 |
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 | 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 |
29 | 33 |
34 | 37 |
38 | 39 | { 41 | const admins = data?.admins.filter( 42 | (addr) => !removed.includes(addr), 43 | ); 44 | 45 | update.mutate( 46 | { id: data?.id, admins }, 47 | { 48 | async onSuccess() { 49 | return utils.rounds.invalidate(); 50 | }, 51 | }, 52 | ); 53 | }} 54 | /> 55 |
56 |
57 | setOpen(false)} 63 | onSubmit={(added) => { 64 | // Merge with existing and only unique addresses 65 | const admins = [...new Set((data?.admins ?? []).concat(added))]; 66 | 67 | update.mutate( 68 | { id: data?.id, admins }, 69 | { 70 | async onSuccess() { 71 | setOpen(false); 72 | return utils.rounds.invalidate(); 73 | }, 74 | }, 75 | ); 76 | }} 77 | /> 78 | 82 |
83 |
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 |
20 | 21 | 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 |
43 | create.mutate(values, { 44 | async onSuccess(round) { 45 | await router.push(`/${round?.domain}/admin`); 46 | }, 47 | }) 48 | } 49 | schema={z.object({ name: RoundNameSchema })} 50 | > 51 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 70 | 71 | {create.error?.message.includes("Unique constraint") && 72 | "A round with this name already exists."} 73 | 74 |
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 | 18 | 19 |
20 | } 21 | > 22 |
23 |

24 | Retroactive Public Goods Funding 25 |

26 |

for everyone

27 |
28 |
29 | 38 | 47 | 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 | --------------------------------------------------------------------------------