diff --git a/.env.example b/.env.example index 5b530678..b465ecd2 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,9 @@ NEXT_PUBLIC_WALLETCONNECT_ID= # What the message will say when you sign in with the wallet NEXT_PUBLIC_SIGN_STATEMENT="Sign in to MACI-RPGF" +# Event title for the round, just for display +NEXT_PUBLIC_EVENT_NAME="ETH GLOBAL" + # Unique identifier for your applications and lists - your app will group attestations by this id NEXT_PUBLIC_ROUND_ID="open-rpgf-1" # Event title for the round, just for display @@ -52,7 +55,6 @@ NEXT_PUBLIC_MAX_VOTES_PROJECT=50 # Determine when users can register applications, admins review them, voters vote, and results are published NEXT_PUBLIC_START_DATE=2024-01-01T00:00:00.000Z NEXT_PUBLIC_REGISTRATION_END_DATE=2024-01-01T00:00:00.000Z -NEXT_PUBLIC_REVIEW_END_DATE=2024-01-01T00:00:00.000Z NEXT_PUBLIC_RESULTS_DATE=2024-01-01T00:00:00.000Z # Collect user feedback. Is shown as a link when user has voted @@ -100,3 +102,5 @@ NEXT_PUBLIC_MACI_SUBGRAPH_URL= # URL with tally-{pollId}.json hosted NEXT_PUBLIC_TALLY_URL=https://upblxu2duoxmkobt.public.blob.vercel-storage.com + +NEXT_PUBLIC_ROUND_LOGO="round-logo.png" diff --git a/package.json b/package.json index aa5fd868..d00af076 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@vercel/blob": "^0.19.0", "clsx": "^2.1.0", "cmdk": "^0.2.0", - "date-fns": "^3.3.1", + "date-fns": "^3.6.0", "ethers": "^6.11.2", "formidable": "^3.5.1", "graphql-request": "^6.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdd4cd8e..1c31521c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,7 +69,7 @@ dependencies: specifier: ^0.2.0 version: 0.2.1(@types/react@18.3.2)(react-dom@18.2.0)(react@18.2.0) date-fns: - specifier: ^3.3.1 + specifier: ^3.6.0 version: 3.6.0 ethers: specifier: ^6.11.2 @@ -10717,7 +10717,6 @@ packages: /glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} - deprecated: Glob versions prior to v9 are no longer supported dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 diff --git a/public/Logo.svg b/public/Logo.svg new file mode 100644 index 00000000..d3feab19 --- /dev/null +++ b/public/Logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/arrow-go-to.svg b/public/arrow-go-to.svg new file mode 100644 index 00000000..05fffe11 --- /dev/null +++ b/public/arrow-go-to.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/check-black.svg b/public/check-black.svg new file mode 100644 index 00000000..79c1d55c --- /dev/null +++ b/public/check-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/check-white.svg b/public/check-white.svg new file mode 100644 index 00000000..51145219 --- /dev/null +++ b/public/check-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/circle-check-blue.svg b/public/circle-check-blue.svg new file mode 100644 index 00000000..6392b913 --- /dev/null +++ b/public/circle-check-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/dropdown.svg b/public/dropdown.svg new file mode 100644 index 00000000..4562ea6d --- /dev/null +++ b/public/dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/fonts/DM_Sans.woff2 b/public/fonts/DM_Sans.woff2 new file mode 100644 index 00000000..469840ce Binary files /dev/null and b/public/fonts/DM_Sans.woff2 differ diff --git a/public/fonts/Share_Tech_Mono.woff2 b/public/fonts/Share_Tech_Mono.woff2 new file mode 100644 index 00000000..50371e42 Binary files /dev/null and b/public/fonts/Share_Tech_Mono.woff2 differ diff --git a/public/round-logo.svg b/public/round-logo.svg new file mode 100644 index 00000000..d8bb29c7 --- /dev/null +++ b/public/round-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/AddedProjects.tsx b/src/components/AddedProjects.tsx new file mode 100644 index 00000000..f993ff3f --- /dev/null +++ b/src/components/AddedProjects.tsx @@ -0,0 +1,25 @@ +import { useBallot } from "~/contexts/Ballot"; +import { useProjectCount } from "~/features/projects/hooks/useProjects"; + +export const AddedProjects = () => { + const { ballot } = useBallot(); + const allocations = ballot?.votes ?? []; + const { data: projectCount } = useProjectCount(); + + return ( +
+

Projects Added

+
+ + {allocations.length} + + + of + + + {projectCount?.count} + +
+
+ ); +}; diff --git a/src/components/BallotOverview.tsx b/src/components/BallotOverview.tsx new file mode 100644 index 00000000..3a62a958 --- /dev/null +++ b/src/components/BallotOverview.tsx @@ -0,0 +1,12 @@ +import { AddedProjects } from "./AddedProjects"; +import { VotingUsage } from "./VotingUsage"; + +export function BallotOverview() { + return ( +
+

My Ballot

+ + +
+ ); +} diff --git a/src/components/ConnectButton.tsx b/src/components/ConnectButton.tsx index e905397d..754c09cd 100644 --- a/src/components/ConnectButton.tsx +++ b/src/components/ConnectButton.tsx @@ -1,118 +1,34 @@ import { ConnectButton as RainbowConnectButton } from "@rainbow-me/rainbowkit"; -import Image from "next/image"; -import Link from "next/link"; -import { type ComponentPropsWithRef, useCallback } from "react"; -import { FaListCheck } from "react-icons/fa6"; import { createBreakpoint } from "react-use"; -import { toast } from "sonner"; -import { useEnsAvatar, useEnsName } from "wagmi"; - -import { config } from "~/config"; -import { useBallot } from "~/contexts/Ballot"; -import { useMaci } from "~/contexts/Maci"; -import { useLayoutOptions } from "~/layouts/BaseLayout"; - -import type { Address } from "viem"; +import Image from "next/image"; import { Button } from "./ui/Button"; import { Chip } from "./ui/Chip"; +import { config } from "~/config"; const useBreakpoint = createBreakpoint({ XL: 1280, L: 768, S: 350 }); -const UserInfo = ({ address, children, ...props }: { address: Address } & ComponentPropsWithRef) => { - const ens = useEnsName({ - address, - chainId: 1, - query: { enabled: Boolean(address) }, - }); - const name = ens.data ?? undefined; - const avatar = useEnsAvatar({ - name, - chainId: 1, - query: { enabled: Boolean(name) }, - }); - - return ( - -
- {avatar.data ? ( - {name!} - ) : ( -
- )} -
- - {children} - - ); -}; - -const SignupButton = ({ - loading, - ...props -}: ComponentPropsWithRef & { loading: boolean }): JSX.Element => ( - - {loading ? "Loading..." : "Sign up"} - -); - -const ConnectedDetails = ({ - openAccountModal, - account, - isMobile, -}: { - account: { address: string; displayName: string }; - openAccountModal: () => void; - isMobile: boolean; -}) => { - const { isLoading, isRegistered, isEligibleToVote, onSignup } = useMaci(); - const { ballot } = useBallot(); - const ballotSize = (ballot?.votes ?? []).length; - - const { showBallot } = useLayoutOptions(); - - const onError = useCallback(() => toast.error("Signup error"), []); - const handleSignup = useCallback(() => onSignup(onError), [onSignup, onError]); - - return ( -
-
- {!isEligibleToVote && You are not allowed to vote} - - {isEligibleToVote && !isRegistered && ( - - )} - - {isRegistered && showBallot && ballot?.published && Already submitted} - - {isRegistered && showBallot && !ballot?.published && ( - - {isMobile ? : `View Ballot`} - -
- {ballotSize} -
-
- )} - - - {isMobile ? null : account.displayName} - -
-
- ); -}; - -export const ConnectButton = (): JSX.Element => { +export const ConnectButton = () => { const breakpoint = useBreakpoint(); const isMobile = breakpoint === "S"; return ( - {({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted, authenticationStatus }) => { + {({ + account, + chain, + openAccountModal, + openChainModal, + openConnectModal, + mounted, + authenticationStatus, + }) => { const ready = mounted && authenticationStatus !== "loading"; const connected = - ready && account && chain && (!authenticationStatus || authenticationStatus === "authenticated"); + ready && + account && + chain && + (!authenticationStatus || authenticationStatus === "authenticated"); return (
{ return ( ); } - if (chain.unsupported ?? ![Number(config.network.id)].includes(chain.id)) { - return Wrong network; + if ( + chain.unsupported ?? + ![Number(config.network.id)].includes(chain.id) + ) { + return ( + + Wrong network + + ); } - return ; + return ( + + ); })()}
); @@ -151,3 +79,24 @@ export const ConnectButton = (): JSX.Element => {
); }; + +const ConnectedDetails = ({ + openAccountModal, + account, + isMobile, +}: { + account: { address: string; displayName: string }; + openAccountModal: () => void; + isMobile: boolean; +}) => { + return ( +
+
+ + {isMobile ? null : account.displayName} + + +
+
+ ); +}; diff --git a/src/components/EligibilityDialog.tsx b/src/components/EligibilityDialog.tsx index 774427bf..a549aead 100644 --- a/src/components/EligibilityDialog.tsx +++ b/src/components/EligibilityDialog.tsx @@ -1,35 +1,90 @@ import { useAccount, useDisconnect } from "wagmi"; - -import { metadata } from "~/config"; -import { useApprovedVoter } from "~/features/voters/hooks/useApprovedVoter"; +import { toast } from "sonner"; +import { useState, useCallback, useEffect } from "react"; +import { useRouter } from "next/router"; import { Dialog } from "./ui/Dialog"; +import { useMaci } from "~/contexts/Maci"; export const EligibilityDialog = (): JSX.Element | null => { const { address } = useAccount(); const { disconnect } = useDisconnect(); - const { data, isLoading, error } = useApprovedVoter(address!); - if (isLoading || !address || error) { - return null; - } + const [openDialog, setOpenDialog] = useState(!!address); + const { onSignup, isEligibleToVote, isRegistered } = useMaci(); + const router = useRouter(); + + const onError = useCallback(() => toast.error("Signup error"), []); + + const handleSignup = useCallback(async () => { + await onSignup(onError); + setOpenDialog(false); + }, [onSignup, onError, setOpenDialog]); + + useEffect(() => { + setOpenDialog(!!address); + }, [address, setOpenDialog]); return ( - - You are not eligible to vote 😔 - - } - onOpenChange={() => { - disconnect(); - }} - > -
-

Only badgeholders are able to vote in {metadata.title}

-
-
+
+ {isRegistered && ( + setOpenDialog(false)} + title="You're all set to vote" + description={ +
+

You have X voice credits to vote with.

+

+ Get started by adding projects to your ballot, then adding the + amount of votes you want to allocate to each one. +

+

Please submit your ballot by X date!

+
+ } + button="secondary" + buttonName="See all projects" + buttonAction={() => router.push("/projects")} + /> + )} + {!isRegistered && isEligibleToVote && ( + setOpenDialog(false)} + title="Account verified!" + description={ +
+

Next, you will need to join the voting round.

+

+ + Learn more about this process{" "} + + here + + . + +

+
+ } + button="secondary" + buttonName="Join voting round" + buttonAction={handleSignup} + /> + )} + {!isEligibleToVote && ( + setOpenDialog(false)} + title="Sorry, this account does not have the credentials to be verified." + description="To participate in this round, you must be in the voter's registry. Contact the round organizers to get access as a voter." + button="secondary" + buttonName="Disconnect" + buttonAction={() => disconnect()} + /> + )} +
); }; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index b46b8fb5..5778229b 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,29 +1,48 @@ -import { GithubIcon } from "lucide-react"; +import { FaXTwitter } from "react-icons/fa6"; +import { FaTelegramPlane, FaGithub, FaDiscord } from "react-icons/fa"; +import Image from "next/image"; -export const Footer = (): JSX.Element => ( - + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index bea1e7f1..b46b4178 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,35 +1,22 @@ -import clsx from "clsx"; -import { Menu, X } from "lucide-react"; -import dynamic from "next/dynamic"; -import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { type ComponentPropsWithRef, useState } from "react"; - -import { config, metadata } from "~/config"; +import clsx from "clsx"; +import { Menu, X } from "lucide-react"; +import dynamic from "next/dynamic"; import { ConnectButton } from "./ConnectButton"; import { IconButton } from "./ui/Button"; - -const Logo = () => ( -
- {config.logoUrl ? ( - logo - ) : ( -
- {metadata.title} -
- )} -
-); +import { Logo } from "./ui/Logo"; +import { useBallot } from "~/contexts/Ballot"; +import { useAppState } from "~/utils/state"; +import { EAppState } from "~/utils/types"; const NavLink = ({ isActive, ...props }: { isActive: boolean } & ComponentPropsWithRef) => ( @@ -58,9 +45,11 @@ interface INavLink { const Header = ({ navLinks }: { navLinks: INavLink[] }) => { const { asPath } = useRouter(); const [isOpen, setOpen] = useState(false); + const { ballot } = useBallot(); + const appState = useAppState(); return ( -
+
{
- -
- {navLinks.map((link) => ( - - {link.children} - - ))} +
+ {navLinks?.map((link) => { + const pageName = `/${link.href.split("/")[1]}`; + return ( + + {link.children} + {appState === EAppState.VOTING && + pageName === "/ballot" && + ballot && + ballot.votes.length > 0 && ( +
+ {ballot.votes.length} +
+ )} +
+ ); + })}
diff --git a/src/components/Info.tsx b/src/components/Info.tsx new file mode 100644 index 00000000..77c1050a --- /dev/null +++ b/src/components/Info.tsx @@ -0,0 +1,94 @@ +import { tv } from "tailwind-variants"; + +import { createComponent } from "~/components/ui"; +import { EInfoCardState } from "~/utils/types"; +import { useMaci } from "~/contexts/Maci"; +import { config } from "~/config"; +import { getAppState } from "~/utils/state"; +import { EAppState } from "~/utils/types"; + +import { RoundInfo } from "./RoundInfo"; +import { VotingInfo } from "./VotingInfo"; +import { InfoCard } from "./InfoCard"; + +const InfoContainer = createComponent( + "div", + tv({ + base: "flex items-center justify-center gap-2 rounded-lg bg-white p-5 shadow-lg", + variants: { + size: { + sm: "flex-col", + default: "flex-col max-lg:w-full lg:flex-row", + }, + }, + }), +); + +interface InfoProps { + size: string; + showVotingInfo?: boolean; +} + +export function Info({ size, showVotingInfo }: InfoProps) { + const { votingEndsAt } = useMaci(); + const appState = getAppState(); + + const steps = [ + { + label: "application", + start: config.startsAt, + end: config.registrationEndsAt, + }, + { + label: "voting", + start: config.registrationEndsAt, + end: votingEndsAt, + }, + { + label: "tallying", + start: votingEndsAt, + end: config.resultsAt, + }, + { + label: "results", + start: config.resultsAt, + end: config.resultsAt, + }, + ]; + + return ( +
+ + {showVotingInfo && ( +
+ + {appState === EAppState.VOTING && } +
+ )} + {steps.map((step, i) => ( + + ))} +
+
+ ); +} + +function defineState({ + start, + end, +}: { + start: Date; + end: Date; +}): EInfoCardState { + const now = new Date(); + + if (end < now) return EInfoCardState.PASSED; + else if (end > now && start < now) return EInfoCardState.ONGOING; + else return EInfoCardState.UPCOMING; +} diff --git a/src/components/InfoCard.tsx b/src/components/InfoCard.tsx new file mode 100644 index 00000000..af6914d6 --- /dev/null +++ b/src/components/InfoCard.tsx @@ -0,0 +1,65 @@ +import { tv } from "tailwind-variants"; +import Image from "next/image"; +import { format } from "date-fns"; + +import { createComponent } from "~/components/ui"; +import { EInfoCardState } from "~/utils/types"; + +const InfoCardContainer = createComponent( + "div", + tv({ + base: "rounded-md p-2 max-lg:w-full lg:w-64", + variants: { + state: { + [EInfoCardState.PASSED]: + "border border-blue-500 bg-blue-50 text-blue-500", + [EInfoCardState.ONGOING]: + "border border-blue-500 bg-blue-500 text-white", + [EInfoCardState.UPCOMING]: + "border border-gray-200 bg-transparent text-gray-200", + }, + }, + }), +); + +interface InfoCardProps { + state: EInfoCardState; + title: string; + start: Date; + end: Date; +} + +export const InfoCard = ({ state, title, start, end }: InfoCardProps) => { + return ( + +
+

+ {title} +

+ {state === EInfoCardState.PASSED ? ( + + ) : state == EInfoCardState.ONGOING ? ( +
+ ) : ( +
+ )} +
+

{formatDateString({ start, end })}

+
+ ); +}; + +function formatDateString({ start, end }: { start: Date; end: Date }): string { + const fullFormat = "d MMM yyyy"; + + if ( + start.getMonth() === end.getMonth() && + start.getFullYear() === end.getFullYear() + ) { + return `${start.getDate()} - ${format(end, fullFormat)}`; + } else if (start.getFullYear() === end.getFullYear()) { + return `${format(start, "d MMM")} - ${format(end, fullFormat)}`; + } else { + return `${format(start, fullFormat)} - ${format(end, fullFormat)}`; + } +} diff --git a/src/components/JoinButton.tsx b/src/components/JoinButton.tsx new file mode 100644 index 00000000..8aab4bfd --- /dev/null +++ b/src/components/JoinButton.tsx @@ -0,0 +1,59 @@ +import { toast } from "sonner"; +import { useCallback } from "react"; + +import { useMaci } from "~/contexts/Maci"; +import { Button } from "./ui/Button"; +import { getAppState } from "~/utils/state"; +import { EAppState } from "~/utils/types"; + +export const JoinButton = () => { + const { isLoading, isRegistered, isEligibleToVote, onSignup } = useMaci(); + const appState = getAppState(); + + const onError = useCallback(() => toast.error("Signup error"), []); + const handleSignup = useCallback( + () => onSignup(onError), + [onSignup, onError], + ); + + const applyApplication = () => {}; + + const viewResults = () => {}; + + return ( +
+ {appState === EAppState.VOTING && !isEligibleToVote && ( + + )} + + {appState === EAppState.VOTING && isEligibleToVote && !isRegistered && ( + + )} + + {appState === EAppState.APPLICATION && ( + + )} + + {appState === EAppState.TALLYING && ( + + )} + + {appState === EAppState.RESULTS && ( + + )} +
+ ); +}; diff --git a/src/components/RoundInfo.tsx b/src/components/RoundInfo.tsx new file mode 100644 index 00000000..d80f62fd --- /dev/null +++ b/src/components/RoundInfo.tsx @@ -0,0 +1,17 @@ +import Image from "next/image"; + +import { config } from "~/config"; + +export const RoundInfo = () => { + return ( +
+

Round

+
+ {config.roundLogo && ( + + )} +

{config.roundId}

+
+
+ ); +}; diff --git a/src/components/SortByDropdown.tsx b/src/components/SortByDropdown.tsx index ee5a7e5c..6e7f1a8e 100644 --- a/src/components/SortByDropdown.tsx +++ b/src/components/SortByDropdown.tsx @@ -16,7 +16,7 @@ interface IRadioItemProps { value?: string; } -const RadioItem = ({ value = "", label = "" }: IRadioItemProps): JSX.Element => ( +const RadioItem = ({ value = "", label = "" }: IRadioItemProps) => ( - {label} ); -export const SortByDropdown = ({ value, onChange, options = [] }: ISortByDropdownProps): JSX.Element => ( - - - - Sort by: {sortLabels[value]} - - - - - { + return ( + + - - Sort By - + + Sort by: {value && sortLabels[value]} + + - { - onChange(v); - }} + + - {options.map((option) => ( - - ))} - - - - -); + + Sort By + + onChange(v)} + > + {options.map((value) => ( + + ))} + + + + + ); +}; diff --git a/src/components/SortFilter.tsx b/src/components/SortFilter.tsx index 46cf4919..62549bc0 100644 --- a/src/components/SortFilter.tsx +++ b/src/components/SortFilter.tsx @@ -24,8 +24,8 @@ export const SortFilter = (): JSX.Element => { return (
diff --git a/src/components/TimeSlot.tsx b/src/components/TimeSlot.tsx new file mode 100644 index 00000000..f0444763 --- /dev/null +++ b/src/components/TimeSlot.tsx @@ -0,0 +1,15 @@ +interface TimeSlotProps { + num: number; + unit: string; +} + +export const TimeSlot = ({ num, unit }: TimeSlotProps) => { + return ( +
+

+ {num} +

+

{unit}

+
+ ); +}; diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index 918a87cc..fc9bddea 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -7,10 +7,12 @@ export const Toaster = (): JSX.Element => { { + const { isLoading, votingEndsAt } = useMaci(); + const [timeLeft, setTimeLeft] = useState<[number, number, number, number]>([ + 0, 0, 0, 0, + ]); + + useHarmonicIntervalFn( + () => setTimeLeft(calculateTimeLeft(votingEndsAt)), + 1000, + ); + + return ( +
+

Voting Ends In

+ {isLoading &&

Loading...

} + {!isLoading && ( +
+ + + + +
+ )} +
+ ); +}; diff --git a/src/components/VotingUsage.tsx b/src/components/VotingUsage.tsx new file mode 100644 index 00000000..478cafb9 --- /dev/null +++ b/src/components/VotingUsage.tsx @@ -0,0 +1,29 @@ +import { useMemo } from "react"; + +import { useBallot } from "~/contexts/Ballot"; +import { useMaci } from "~/contexts/Maci"; + +export const VotingUsage = () => { + const { initialVoiceCredits } = useMaci(); + const { ballot, sumBallot } = useBallot(); + + const sum = useMemo(() => sumBallot(ballot?.votes), [sumBallot, ballot]); + + return ( +
+

Voting Power

+
+

+ {initialVoiceCredits} +

+

Votes Left

+
+
+

+ {sum} +

+

Votes Used

+
+
+ ); +}; diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx index b6a42f90..551e5b75 100644 --- a/src/components/ui/Avatar.tsx +++ b/src/components/ui/Avatar.tsx @@ -7,7 +7,7 @@ import { createComponent } from "."; export const Avatar = createComponent( BackgroundImage, tv({ - base: "bg-gray-200 dark:bg-gray-800", + base: "bg-gray-200 dark:bg-gray-800 border-2 border-white", variants: { size: { xs: "w-5 h-5 rounded-xs", diff --git a/src/components/ui/Banner.tsx b/src/components/ui/Banner.tsx index 20f7bd83..dad55a68 100644 --- a/src/components/ui/Banner.tsx +++ b/src/components/ui/Banner.tsx @@ -10,11 +10,8 @@ export const Banner = createComponent( base: "bg-gray-200 dark:bg-gray-800", variants: { size: { - md: "h-24 rounded-2xl", - lg: "h-80 rounded-3xl", - }, - rounded: { - full: "rounded-full", + md: "h-24 rounded-t-xl", + lg: "h-80 rounded-t-xl", }, }, defaultVariants: { diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index cd17c494..29848d56 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -5,20 +5,25 @@ import { tv } from "tailwind-variants"; import { createComponent } from "."; const button = tv({ - 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", + base: "inline-flex items-center justify-center font-semibold uppercase rounded-lg text-center transition-colors duration-150 whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", variants: { variant: { - primary: - "bg-primary-600 hover:bg-primary-700 dark:bg-white dark:hover:bg-primary-500 dark:text-gray-900 text-white dark:disabled:bg-gray-500", + primary: "bg-black text-white hover:bg-blue-950", + inverted: + "text-black border border-black hover:text-blue-500 hover:border-blue-500", + tertiary: + "bg-blue-50 text-blue-500 border border-blue-500 hover:bg-blue-100", + secondary: "bg-blue-500 text-white hover:bg-blue-600", ghost: "hover:bg-gray-100 dark:hover:bg-gray-800", - default: "bg-gray-100 dark:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700", - inverted: "bg-white text-black hover:bg-white/90", - link: "bg-none hover:underline", - outline: "border-2 hover:bg-white/5", + outline: "border border-gray-200 hover:border-gray-300", + disabled: + "border border-gray-200 bg-gray-50 text-gray-200 cursor-not-allowed", + none: "", }, size: { - sm: "px-3 py-2 h-10 min-w-[40px]", - default: "px-4 py-2 h-12", + sm: "px-3 py-2 h-8 text-xs rounded-md", + default: "px-4 py-2 h-10 w-full", + auto: "px-4 py-2 h-10 w-auto", icon: "h-12 w-12", }, disabled: { @@ -26,7 +31,7 @@ const button = tv({ }, }, defaultVariants: { - variant: "default", + variant: "none", size: "default", }, }); diff --git a/src/components/ui/Chip.tsx b/src/components/ui/Chip.tsx index 5e83c2e3..78cf4c27 100644 --- a/src/components/ui/Chip.tsx +++ b/src/components/ui/Chip.tsx @@ -3,8 +3,16 @@ import { tv } from "tailwind-variants"; import { createComponent } from "."; const chip = tv({ - base: "border border-gray-700 rounded-full min-w-[42px] px-2 md:px-3 py-2 cursor-pointer inline-flex justify-center items-center whitespace-nowrap text-neutral-200 hover:text-neutral-100", - variants: {}, + base: "rounded-md min-w-[42px] px-2 md:px-3 py-2 cursor-pointer inline-flex justify-center items-center whitespace-nowrap uppercase", + variants: { + color: { + primary: "text-white bg-black border-none", + secondary: "text-black bg-white border border-black", + neutral: "text-blue-500 bg-blue-50 border border-blue-500", + disabled: + "cursor-not-allowed text-gray-500 bg-gray-50 border border-gray-500", + }, + }, }); export const Chip = createComponent("button", chip); diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx index 0b14943f..1bc6019f 100644 --- a/src/components/ui/Dialog.tsx +++ b/src/components/ui/Dialog.tsx @@ -1,26 +1,17 @@ import * as RadixDialog from "@radix-ui/react-dialog"; +import type { ReactNode, PropsWithChildren, ComponentProps } from "react"; import { X } from "lucide-react"; import { tv } from "tailwind-variants"; -import { theme } from "~/config"; - -import type { ReactNode, PropsWithChildren, ComponentProps } from "react"; - -import { IconButton } from "./Button"; - +import { IconButton, Button } from "./Button"; import { createComponent } from "."; - -export interface IDialogProps extends PropsWithChildren { - title?: string | ReactNode; - isOpen?: boolean; - size?: "sm" | "md"; - onOpenChange?: ComponentProps["onOpenChange"]; -} +import { theme } from "~/config"; +import { Spinner } from "./Spinner"; const Content = createComponent( RadixDialog.Content, tv({ - 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", + base: "z-20 fixed bottom-0 rounded-md bg-white p-12 flex flex-col justify-center gap-4 items-center text-center w-full font-sans sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2", variants: { size: { sm: "sm:w-[456px] md:w-[456px]", @@ -34,30 +25,59 @@ const Content = createComponent( ); export const Dialog = ({ - title = 0, - size = undefined, - isOpen = false, + title, + description, + size, + isOpen, + isLoading, + button, + buttonName, + buttonAction, children, - onOpenChange = undefined, -}: IDialogProps): JSX.Element => ( - - - - - {/* Because of Portal we need to set the theme here */} -
- - {title} - - {children} - - {onOpenChange ? ( - - - - ) : null} - -
-
-
-); + onOpenChange, +}: { + title?: string | ReactNode; + description?: string | ReactNode; + size?: "sm" | "md"; + isOpen?: boolean; + isLoading?: boolean; + button?: "primary" | "secondary"; + buttonName?: string; + buttonAction?: () => void; + onOpenChange?: ComponentProps["onOpenChange"]; +} & PropsWithChildren) => { + return ( + + + + {/* Because of Portal we need to set the theme here */} +
+ + + {title} + + + {description} + + {children} + {isLoading && } + {!isLoading && button && buttonName && buttonAction && ( + + )} + {onOpenChange ? ( + + + + ) : null} + +
+
+
+ ); +}; diff --git a/src/components/ui/Form.tsx b/src/components/ui/Form.tsx index 4969aa07..1eac80ea 100644 --- a/src/components/ui/Form.tsx +++ b/src/components/ui/Form.tsx @@ -24,57 +24,10 @@ import { type z } from "zod"; import { cn } from "~/utils/classNames"; import { IconButton } from "./Button"; +import { inputBase, Input, InputWrapper, InputIcon } from "./Input"; import { createComponent } from "."; -const inputBase = [ - "dark:bg-gray-900", - "dark:text-gray-300", - "dark:border-gray-700", - "rounded", - "disabled:opacity-30", - "checked:bg-gray-800", -]; - -export const Input = createComponent( - "input", - tv({ - base: ["w-full", ...inputBase], - variants: { - error: { - true: "!border-red-900", - }, - }, - }), -); - -export const InputWrapper = createComponent( - "div", - tv({ - base: "flex w-full relative", - variants: {}, - }), -); - -export const InputAddon = createComponent( - "div", - tv({ - base: "absolute right-0 text-gray-900 dark:text-gray-300 inline-flex items-center justify-center h-full border-gray-300 dark:border-gray-800 border-l px-4 font-semibold", - variants: { - disabled: { - true: "text-gray-500 dark:text-gray-500", - }, - }, - }), -); - -export const InputIcon = createComponent( - "div", - tv({ - base: "absolute text-gray-600 left-0 inline-flex items-center justify-center h-full px-4", - }), -); - export const Select = createComponent( "select", tv({ @@ -182,8 +135,11 @@ export const FieldArray = ({ return (
- {error &&
{String(error)}
} - + {error && ( +
+ {String(error)} +
+ )} {fields.map((field, i) => (
{renderField(field, i)} diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 00000000..a50c013b --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,53 @@ +import { tv } from "tailwind-variants"; +import { createComponent } from "."; + +export const inputBase = [ + "dark:bg-gray-900", + "dark:text-gray-300", + "dark:border-gray-700", + "disabled:opacity-30", + "checked:bg-gray-800", + "outline-none", + "border-gray-200", + "rounded-lg", + "border", +]; + +export const Input = createComponent( + "input", + tv({ + base: ["w-full", ...inputBase], + variants: { + error: { + true: "!border-red-900", + }, + }, + }), +); + +export const InputWrapper = createComponent( + "div", + tv({ + base: "flex w-full relative", + variants: {}, + }), +); + +export const InputAddon = createComponent( + "div", + tv({ + base: "absolute right-0 text-gray-900 dark:text-gray-300 inline-flex items-center justify-center h-full border-gray-300 dark:border-gray-800 border-l px-4 font-semibold", + variants: { + disabled: { + true: "text-gray-500 dark:text-gray-500", + }, + }, + }), +); + +export const InputIcon = createComponent( + "div", + tv({ + base: "absolute text-gray-600 left-0 inline-flex items-center justify-center h-full px-4", + }), +); diff --git a/src/components/ui/Link.tsx b/src/components/ui/Link.tsx index aef90b5b..5b8bbba8 100644 --- a/src/components/ui/Link.tsx +++ b/src/components/ui/Link.tsx @@ -9,7 +9,7 @@ import { createComponent } from "."; export const Link = createComponent( NextLink, tv({ - base: "font-semibold underline-offset-2 hover:underline text-secondary-600", + base: "flex items-center gap-1 text-blue-400 hover:underline", }), ); diff --git a/src/components/ui/Logo.tsx b/src/components/ui/Logo.tsx new file mode 100644 index 00000000..1dce0aee --- /dev/null +++ b/src/components/ui/Logo.tsx @@ -0,0 +1,14 @@ +import { config, metadata } from "~/config"; +import Image from "next/image"; + +export const Logo = () => ( +
+ {config.logoUrl ? ( + logo + ) : ( +
+ {metadata.title} +
+ )} +
+); diff --git a/src/components/ui/Navigator.tsx b/src/components/ui/Navigator.tsx new file mode 100644 index 00000000..493930f2 --- /dev/null +++ b/src/components/ui/Navigator.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +interface NavigatorProps { + projectName: string; +} + +export const Navigator = ({ projectName }: NavigatorProps) => { + return ( +
+ + Projects + + {">"} + + {projectName} + +
+ ); +}; diff --git a/src/components/ui/Notification.tsx b/src/components/ui/Notification.tsx new file mode 100644 index 00000000..273148b3 --- /dev/null +++ b/src/components/ui/Notification.tsx @@ -0,0 +1,46 @@ +import { RiErrorWarningLine } from "react-icons/ri"; +import { tv } from "tailwind-variants"; +import clsx from "clsx"; + +import { createComponent } from "."; + +const notification = tv({ + base: "w-full flex items-start text-sm justify-center gap-1 text-base", + variants: { + variant: { + default: "text-blue-400", + block: "text-blue-700 bg-blue-400 border border-blue-700 rounded-lg p-4", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +const NotificationContainer = createComponent("div", notification); + +interface NotificationProps { + content: string; + variant?: string; + italic?: boolean; + title?: string; +} + +export const Notification = ({ + content, + variant, + italic, + title, +}: NotificationProps) => { + return ( + + + + +
+ {title ?? null} +

{content}

+
+
+ ); +}; diff --git a/src/components/ui/Table.tsx b/src/components/ui/Table.tsx index ad263ccb..7be40608 100644 --- a/src/components/ui/Table.tsx +++ b/src/components/ui/Table.tsx @@ -5,16 +5,21 @@ import { createComponent } from "."; export const Table = createComponent( "table", tv({ - base: "w-full", + base: "w-full border-separate border-spacing-y-4 border-spacing-x-0", }), ); export const Thead = createComponent("thead", tv({ base: "" })); export const Tbody = createComponent("tbody", tv({ base: "" })); -export const Tr = createComponent( - "tr", +export const Tr = createComponent("tr", tv({ base: "" })); +export const Td = createComponent( + "td", tv({ - base: "border-b dark:border-gray-800 last:border-none", + base: "p-4 border-y border-gray-200", + variants: { + variant: { + first: "border-l rounded-l-lg", + last: "border-r rounded-r-lg", + }, + }, }), ); -export const Th = createComponent("th", tv({ base: "text-left" })); -export const Td = createComponent("td", tv({ base: "px-1 py-2" })); diff --git a/src/components/ui/Tag.tsx b/src/components/ui/Tag.tsx index ead931dd..953545da 100644 --- a/src/components/ui/Tag.tsx +++ b/src/components/ui/Tag.tsx @@ -5,7 +5,7 @@ import { createComponent } from "."; export const Tag = createComponent( "div", tv({ - base: "cursor-pointer inline-flex items-center border border-gray-200 justify-center gap-2 bg-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 text-gray-700 whitespace-nowrap transition", + base: "cursor-pointer inline-flex items-center border border-blue-400 justify-center gap-2 text-blue-400 whitespace-nowrap transition hover:bg-blue-50", variants: { size: { sm: "rounded py-1 px-2 text-xs", @@ -13,10 +13,10 @@ export const Tag = createComponent( lg: "rounded-xl py-2 px-4 text-lg", }, selected: { - true: "border-gray-900 dark:border-gray-300", + true: "bg-blue-400 text-white", }, disabled: { - true: "opacity-50 cursor-not-allowed", + true: "border-gray-200 text-gray-200 cursor-not-allowed", }, }, defaultVariants: { diff --git a/src/config.ts b/src/config.ts index 39769816..3cb58ad2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,17 +8,17 @@ export const metadata = { }; export const config = { - logoUrl: "", + logoUrl: "/Logo.svg", pageSize: 3 * 4, // TODO: temp solution until we come up with solid one // https://github.com/privacy-scaling-explorations/maci-rpgf/issues/31 voteLimit: 50, startsAt: new Date(process.env.NEXT_PUBLIC_START_DATE!), registrationEndsAt: new Date(process.env.NEXT_PUBLIC_REGISTRATION_END_DATE!), - reviewEndsAt: new Date(process.env.NEXT_PUBLIC_REVIEW_END_DATE!), resultsAt: new Date(process.env.NEXT_PUBLIC_RESULTS_DATE!), skipApprovedVoterCheck: ["true", "1"].includes(process.env.NEXT_PUBLIC_SKIP_APPROVED_VOTER_CHECK!), tokenName: process.env.NEXT_PUBLIC_TOKEN_NAME!, + eventName: process.env.NEXT_PUBLIC_EVENT_NAME ?? "MACI-RPGF", roundId: process.env.NEXT_PUBLIC_ROUND_ID!, admin: (process.env.NEXT_PUBLIC_ADMIN_ADDRESS ?? "") as `0x${string}`, network: wagmiChains[process.env.NEXT_PUBLIC_CHAIN_NAME as keyof typeof wagmiChains], @@ -27,10 +27,11 @@ export const config = { maciSubgraphUrl: process.env.NEXT_PUBLIC_MACI_SUBGRAPH_URL ?? "", tallyUrl: process.env.NEXT_PUBLIC_TALLY_URL, roundOrganizer: process.env.NEXT_PUBLIC_ROUND_ORGANIZER ?? "Optimism", + roundLogo: process.env.NEXT_PUBLIC_ROUND_LOGO, }; export const theme = { - colorMode: "dark", + colorMode: "light", }; export const eas = { diff --git a/src/contexts/Ballot.tsx b/src/contexts/Ballot.tsx index ef1a5464..9d5c90a0 100644 --- a/src/contexts/Ballot.tsx +++ b/src/contexts/Ballot.tsx @@ -10,6 +10,7 @@ const defaultBallot = { votes: [], published: false }; export const BallotProvider: React.FC = ({ children }: BallotProviderProps) => { const [ballot, setBallot] = useState(defaultBallot); + const [isLoading, setLoading] = useState(true); const { isDisconnected } = useAccount(); @@ -83,6 +84,7 @@ export const BallotProvider: React.FC = ({ children }: Ball ) as typeof defaultBallot; setBallot(savedBallot); + setLoading(false); }, [setBallot]); /// store ballot to localStorage once it changes @@ -101,6 +103,7 @@ export const BallotProvider: React.FC = ({ children }: Ball const value = useMemo( () => ({ ballot, + isLoading, addToBallot, removeFromBallot, deleteBallot, @@ -108,7 +111,7 @@ export const BallotProvider: React.FC = ({ children }: Ball sumBallot, publishBallot, }), - [ballot, addToBallot, removeFromBallot, deleteBallot, ballotContains, sumBallot, publishBallot], + [ballot, isLoading, addToBallot, removeFromBallot, deleteBallot, ballotContains, sumBallot, publishBallot], ); return {children}; diff --git a/src/contexts/Maci.tsx b/src/contexts/Maci.tsx index b3b3151b..45045446 100644 --- a/src/contexts/Maci.tsx +++ b/src/contexts/Maci.tsx @@ -128,7 +128,7 @@ export const MaciProvider: React.FC = ({ children }: MaciProv } if (!votes.length) { - await onError(); + onError(); setError("No votes provided"); return; } diff --git a/src/contexts/types.ts b/src/contexts/types.ts index 02a8f8c5..17bdaf34 100644 --- a/src/contexts/types.ts +++ b/src/contexts/types.ts @@ -33,7 +33,8 @@ export interface MaciProviderProps { } export interface BallotContextType { - ballot?: Ballot; + ballot: Ballot; + isLoading: boolean; addToBallot: (votes: Vote[], pollId: string) => void; removeFromBallot: (projectId: string) => void; deleteBallot: () => void; diff --git a/src/env.js b/src/env.js index 03d445fc..028b4fd8 100644 --- a/src/env.js +++ b/src/env.js @@ -60,6 +60,7 @@ export const env = createEnv({ NEXT_PUBLIC_APPROVAL_SCHEMA: z.string().startsWith("0x"), NEXT_PUBLIC_METADATA_SCHEMA: z.string().startsWith("0x"), + NEXT_PUBLIC_EVENT_NAME: z.string().optional(), NEXT_PUBLIC_ROUND_ID: z.string(), NEXT_PUBLIC_WALLETCONNECT_ID: z.string().optional(), NEXT_PUBLIC_ALCHEMY_ID: z.string().optional(), @@ -71,6 +72,8 @@ export const env = createEnv({ NEXT_PUBLIC_MACI_SUBGRAPH_URL: z.string().url().optional(), NEXT_PUBLIC_TALLY_URL: z.string().url(), + + NEXT_PUBLIC_ROUND_LOGO: z.string().optional(), }, /** @@ -104,6 +107,7 @@ export const env = createEnv({ NEXT_PUBLIC_APPROVAL_SCHEMA: process.env.NEXT_PUBLIC_APPROVAL_SCHEMA, NEXT_PUBLIC_METADATA_SCHEMA: process.env.NEXT_PUBLIC_METADATA_SCHEMA, + NEXT_PUBLIC_EVENT_NAME: process.env.NEXT_PUBLIC_EVENT_NAME, NEXT_PUBLIC_ROUND_ID: process.env.NEXT_PUBLIC_ROUND_ID, NEXT_PUBLIC_MACI_ADDRESS: process.env.NEXT_PUBLIC_MACI_ADDRESS, @@ -111,6 +115,8 @@ export const env = createEnv({ NEXT_PUBLIC_MACI_SUBGRAPH_URL: process.env.NEXT_PUBLIC_MACI_SUBGRAPH_URL, NEXT_PUBLIC_TALLY_URL: process.env.NEXT_PUBLIC_TALLY_URL, + + NEXT_PUBLIC_ROUND_LOGO: process.env.NEXT_PUBLIC_ROUND_LOGO, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/features/applications/components/ApplicationForm.tsx b/src/features/applications/components/ApplicationForm.tsx index 2573d372..3d9888eb 100644 --- a/src/features/applications/components/ApplicationForm.tsx +++ b/src/features/applications/components/ApplicationForm.tsx @@ -13,11 +13,11 @@ import { Form, FormControl, FormSection, - Input, Label, Select, Textarea, } from "~/components/ui/Form"; +import { Input } from "~/components/ui/Input"; import { Spinner } from "~/components/ui/Spinner"; import { Tag } from "~/components/ui/Tag"; import { impactCategories } from "~/config"; diff --git a/src/features/ballot/components/AllocationInput.tsx b/src/features/ballot/components/AllocationInput.tsx index 8d9d5b70..6cccb8e2 100644 --- a/src/features/ballot/components/AllocationInput.tsx +++ b/src/features/ballot/components/AllocationInput.tsx @@ -2,7 +2,7 @@ import { type ComponentPropsWithRef } from "react"; import { useFormContext, Controller } from "react-hook-form"; import { NumericFormat } from "react-number-format"; -import { Input, InputAddon, InputWrapper } from "~/components/ui/Form"; +import { Input, InputAddon, InputWrapper } from "~/components/ui/Input"; import { config } from "~/config"; export interface IAllocationInputProps extends ComponentPropsWithRef<"input"> { @@ -22,7 +22,7 @@ export const AllocationInput = ({ const form = useFormContext(); return ( - + { - const { data: projects } = useProjectById(id); - const project = projects?.[0]; - const Component = link ? Link : "div"; +import { useMaci } from "~/contexts/Maci"; +import { useBallot } from "~/contexts/Ballot"; +import { config } from "~/config"; +import { ProjectAvatarWithName } from "./ProjectAvatarWithName"; +export const AllocationList = ({ votes }: { votes?: Vote[] }) => { return ( - - - -
-
{project?.name}
- -
{subtitle}
-
-
- ); -}; - -export const AllocationList = ({ votes = [] }: IAllocationListProps): JSX.Element => ( - - {votes.map((project) => ( + {votes?.map((project) => ( - + - - ))}
- + + + + {formatNumber(project.amount)} {config.tokenName} {`${formatNumber(project.amount)} ${config.tokenName}`}
-
-); + ); +}; -interface AllocationFormProps { +type AllocationFormProps = { disabled?: boolean; projectIsLink?: boolean; renderHeader?: () => ReactNode; - renderExtraColumn?: ( - { form, project }: { form: UseFormReturn<{ votes: Vote[] }>; project: Vote }, - i: number, - ) => ReactNode; } -export const AllocationFormWrapper = ({ - disabled = false, - projectIsLink = false, - renderHeader = undefined, - renderExtraColumn = undefined, -}: AllocationFormProps): JSX.Element => { +export function AllocationFormWrapper({ + disabled, + projectIsLink, + renderHeader, +}: AllocationFormProps) { const form = useFormContext<{ votes: Vote[] }>(); const { initialVoiceCredits, pollId } = useMaci(); const { addToBallot: onSave, removeFromBallot: onRemove } = useBallot(); @@ -106,63 +54,48 @@ export const AllocationFormWrapper = ({ }); return ( - - - {renderHeader?.()} - - - {fields.map((project, i) => { - const idx = i; - - return ( - - - - - - - - - - ); - })} - -
- - {renderExtraColumn?.({ project, form }, i)} - { - onSave(form.getValues().votes, pollId!); - }} - /> - - { - remove(idx); - onRemove(project.projectId); - }} - /> -
-
+ + {renderHeader?.()} + + {fields.map((project, i) => { + return ( + + + + + + ); + })} + +
+ + + onSave?.(form.getValues().votes, pollId)} + /> + + { + remove(i); + onRemove?.(project.projectId); + }} + /> +
); -}; - -export const DistributionForm = ({ ...props }: AllocationFormProps): JSX.Element => ( - ( - - - - )} - /> -); +} diff --git a/src/features/ballot/components/BallotConfirmation.tsx b/src/features/ballot/components/BallotConfirmation.tsx index 0408a902..deec1f6d 100644 --- a/src/features/ballot/components/BallotConfirmation.tsx +++ b/src/features/ballot/components/BallotConfirmation.tsx @@ -1,85 +1,136 @@ -import { Lock } from "lucide-react"; -import Link from "next/link"; -import React from "react"; import { tv } from "tailwind-variants"; +import Link from "next/link"; +import { useMemo } from "react"; +import { format } from "date-fns"; -import { createComponent } from "~/components/ui"; import { Button } from "~/components/ui/Button"; +import { Notification } from "~/components/ui/Notification"; +import { createComponent } from "~/components/ui"; import { config } from "~/config"; - -import { type Vote } from "../types"; - -import { AllocationList } from "./AllocationList"; +import { useAppState } from "~/utils/state"; +import { useBallot } from "~/contexts/Ballot"; +import { useProjectCount } from "~/features/projects/hooks/useProjects"; +import { ProjectAvatarWithName } from "./ProjectAvatarWithName"; +import { formatNumber } from "~/utils/formatNumber"; +import { EAppState } from "~/utils/types"; const feedbackUrl = process.env.NEXT_PUBLIC_FEEDBACK_URL; -const Card = createComponent("div", tv({ base: "rounded-3xl border p-8 dark:border-gray-700" })); - -export interface IBallotConfirmationProps { - votes: Vote[]; -} - -export const BallotConfirmation = ({ votes }: IBallotConfirmationProps): JSX.Element => ( -
-
- -
-
-

- Your vote has been received 🥳 -

+const Card = createComponent( + "div", + tv({ + base: "rounded-lg border border-blue-400 p-8 bg-blue-50 flex justify-between items-center gap-8 my-14", + }), +); -

- Thank you for participating in this round. If you have 5 minutes, we'd love to hear your feedback on - what we could do better to improve! Your feedback would always remain anonymous. It would, however, - greatly help us continue to iterate on the MACI-RPGF stack to keep learning and implementing improvements - to continue to build a better experience. +export const BallotConfirmation = () => { + const { ballot, sumBallot } = useBallot(); + const allocations = ballot?.votes ?? []; + const { data: projectCount } = useProjectCount(); + const appState = useAppState(); + + const sum = useMemo( + () => formatNumber(sumBallot(ballot?.votes)), + [ballot, sumBallot], + ); + + return ( +

+

+ Your votes have been successfully submitted 🥳 +

+

+ Thank you for participating in {config.eventName} {config.roundId}{" "} + round. +

+
+ Summary of your voting +

+ Round you voted in: {config.roundId}
+ Number of projects you voted for: {allocations.length} of{" "} + {projectCount?.count} +

+
+ {allocations.map((project) => { + return ( +
+ +
+ ); + })} +
+
+

Total votes allocated:

+

{sum}

+
+
+ + {appState === EAppState.VOTING && ( + +
+ + Wanna change your mind? + +

+ Your can edit your ballot and resubmit it anytime during the + voting period.

- -
+
+
- -
-
-
- + + )} -
-
Here's how you voted!
- -
- - -

Your vote will always be private

-
+
+ + Help us improve our next round of {config.eventName} + +

+ Your anonymized feedback will be influential to help us iterate on + {config.eventName} process. +

- -
-

Project name

- -

{config.tokenName} allocated by you

+
+
- -
- -
- -
-
Help us improve the next round of MACI RPGF
- -

- Your anonymized feedback will be influential to help us iterate on the MACI RPGF process. +

+ Want to run a round? +

+ Our code is open source so you can fork it and run a round anytime. + If you need any assistance or want to share with us your + awesomeness, find us at #🗳️-maci channel in PSE Discord.

- -
+
+
-
-
-); +
+ ); +}; diff --git a/src/features/ballot/components/BallotOverview.tsx b/src/features/ballot/components/BallotOverview.tsx deleted file mode 100644 index da6ac32c..00000000 --- a/src/features/ballot/components/BallotOverview.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import clsx from "clsx"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { type PropsWithChildren, type ReactNode, useState, useCallback } from "react"; -import { toast } from "sonner"; -import { useAccount } from "wagmi"; - -import { Alert } from "~/components/ui/Alert"; -import { Button } from "~/components/ui/Button"; -import { Dialog } from "~/components/ui/Dialog"; -import { Progress } from "~/components/ui/Progress"; -import { Spinner } from "~/components/ui/Spinner"; -import { config } from "~/config"; -import { useBallot } from "~/contexts/Ballot"; -import { useMaci } from "~/contexts/Maci"; -import { useProjectCount, useProjectIdMapping } from "~/features/projects/hooks/useProjects"; -import { formatNumber } from "~/utils/formatNumber"; -import { useAppState } from "~/utils/state"; -import { EAppState } from "~/utils/types"; - -import { VotingEndsIn } from "./VotingEndsIn"; - -const BallotHeader = ({ children, ...props }: PropsWithChildren): JSX.Element => ( -

- {children} -

-); - -const BallotSection = ({ title, children }: { title: string | ReactNode } & PropsWithChildren) => ( -
-

{title}

- -
{children}
-
-); - -interface ISubmitBallotButtonProps { - disabled?: boolean; -} - -const SubmitBallotButton = ({ disabled = false }: ISubmitBallotButtonProps): JSX.Element => { - const [isOpen, setOpen] = useState(false); - const { isLoading, error, onVote } = useMaci(); - const { ballot, publishBallot } = useBallot(); - - const projectIndices = useProjectIdMapping(ballot); - - const router = useRouter(); - - const handleOpen = useCallback(() => { - setOpen(true); - }, [setOpen]); - - const handleClose = useCallback(() => { - setOpen(false); - }, [setOpen]); - - const submit = { - isLoading, - error, - mutate: async () => { - const votes = - ballot?.votes.map(({ amount, projectId }) => ({ - voteOptionIndex: BigInt(projectIndices[projectId]!), - newVoteWeight: BigInt(amount), - })) ?? []; - - await onVote( - votes, - () => { - toast.error("Voting failed"); - }, - async () => { - await router.push("/ballot/confirmation"); - publishBallot(); - }, - ); - }, - }; - - const messages = { - signing: { - title: "Sign vote", - instructions: "Confirm the transactions in your wallet to submit your vote.", - }, - submitting: { - title: "Submit vote", - instructions: "Once you submit your vote, you won’t be able to change it. If you are ready, go ahead and submit!", - }, - error: { - title: "Error submitting vote", - instructions: ( - - There was an error submitting the vote. - - ), - }, - }; - - const messageKey = submit.error ? "error" : "submitting"; - const { title, instructions } = messages[submit.isLoading ? "signing" : messageKey]; - - return ( - <> - - - -

{instructions}

- -
- - - -
-
- - ); -}; - -const BallotOverview = () => { - const router = useRouter(); - - const { isRegistered, isEligibleToVote, initialVoiceCredits } = useMaci(); - const { sumBallot, ballot } = useBallot(); - - const sum = sumBallot(ballot?.votes); - - const allocations = ballot?.votes ?? []; - const canSubmit = router.route === "/ballot" && allocations.length; - const viewBallot = router.route !== "/ballot" && allocations.length; - - const { data: projectCount } = useProjectCount(); - - const appState = useAppState(); - - const { address } = useAccount(); - - if (appState === EAppState.LOADING) { - return ; - } - - if (appState === EAppState.RESULTS) { - return ( -
- Results are live! - - -
- ); - } - - if (appState === EAppState.TALLYING) { - return ( -
- Voting has ended - - -
- ); - } - - if (appState !== EAppState.VOTING) { - return ( -
- Voting has not started yet - - {appState === EAppState.REVIEWING ? ( - - ) : ( - - )} -
- ); - } - - return ( -
- Voting Round: {config.roundId} - - - - - - {address && isRegistered && ( - <> - Your vote - - -
- {`${allocations.length}/${projectCount?.count}`} -
-
- - - {config.tokenName} allocated: - -
initialVoiceCredits, - })} - > - {`${formatNumber(sum)} ${config.tokenName}`} -
-
- } - > - - -
-
Total
- -
{`${formatNumber(initialVoiceCredits)} ${config.tokenName}`}
-
- - - )} - - {isRegistered && isEligibleToVote ? ( - <> - {ballot?.published && ( - - )} - - {!ballot?.published && canSubmit && initialVoiceCredits} />} - - {!ballot?.published && !canSubmit && viewBallot ? ( - - ) : ( - - )} - - ) : null} -
- ); -}; - -export default dynamic(() => Promise.resolve(BallotOverview), { ssr: false }); diff --git a/src/features/ballot/components/ProjectAvatarWithName.tsx b/src/features/ballot/components/ProjectAvatarWithName.tsx new file mode 100644 index 00000000..2837b530 --- /dev/null +++ b/src/features/ballot/components/ProjectAvatarWithName.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; + +import { ProjectAvatar } from "~/features/projects/components/ProjectAvatar"; +import { + useProjectById, + useProjectMetadata, +} from "~/features/projects/hooks/useProjects"; + +interface ProjectAvatarWithNameProps { + id?: string; + isLink?: boolean; + showDescription?: boolean; + allocation?: number; +} + +export const ProjectAvatarWithName = ({ + id, + isLink, + showDescription, + allocation, +}: ProjectAvatarWithNameProps) => { + const { data: project } = useProjectById(id!); + const metadata = useProjectMetadata(project?.metadataPtr); + + const Component = isLink ? Link : "div"; + + return ( + + +
+
{project?.name}
+
+

{showDescription && (metadata.data?.bio ?? null)}

+

{allocation && `Votes you have allocated: ${allocation}`}

+
+
+
+ ); +}; diff --git a/src/features/ballot/components/SubmitBallotButton.tsx b/src/features/ballot/components/SubmitBallotButton.tsx new file mode 100644 index 00000000..d1e37f80 --- /dev/null +++ b/src/features/ballot/components/SubmitBallotButton.tsx @@ -0,0 +1,56 @@ +import { useState, useCallback } from "react"; +import { useRouter } from "next/router"; +import { toast } from "sonner"; + +import { useProjectIdMapping } from "~/features/projects/hooks/useProjects"; +import { useMaci } from "~/contexts/Maci"; +import { useBallot } from "~/contexts/Ballot"; +import { Button } from "~/components/ui/Button"; +import { Dialog } from "~/components/ui/Dialog"; + +export const SubmitBallotButton = () => { + const router = useRouter(); + const [isOpen, setOpen] = useState(false); + const { onVote, isLoading } = useMaci(); + const { ballot, publishBallot } = useBallot(); + const projectIndices = useProjectIdMapping(ballot); + + const handleSubmitBallot = useCallback(async () => { + const votes = + ballot?.votes.map(({ amount, projectId }) => ({ + voteOptionIndex: BigInt(projectIndices[projectId]), + newVoteWeight: BigInt(amount), + })) ?? []; + + await onVote( + votes, + () => toast.error("Voting error"), + async () => { + publishBallot(); + await router.push("/ballot/confirmation"); + }, + ); + }, [ballot, router, onVote, publishBallot]); + + const handleOpenDialog = useCallback(() => setOpen(true), [setOpen]); + + return ( + <> + + + + + ); +}; diff --git a/src/features/projects/components/AddToBallot.tsx b/src/features/projects/components/AddToBallot.tsx index 2b325fea..7aab5eb1 100644 --- a/src/features/projects/components/AddToBallot.tsx +++ b/src/features/projects/components/AddToBallot.tsx @@ -88,63 +88,62 @@ const ProjectAllocation = ({ ); }; -export const ProjectAddToBallot = ({ id = "", name = "" }: IProjectAddToBallotProps): JSX.Element | null => { +export const ProjectAddToBallot = ({ id, name }: IProjectAddToBallotProps) => { const { address } = useAccount(); const [isOpen, setOpen] = useState(false); - const { isRegistered, isEligibleToVote, initialVoiceCredits, pollId } = useMaci(); - const { ballot, ballotContains, sumBallot, addToBallot, removeFromBallot } = useBallot(); + const { isRegistered, isEligibleToVote, initialVoiceCredits, pollId } = + useMaci(); + const { ballot, ballotContains, sumBallot, addToBallot, removeFromBallot } = + useBallot(); - const inBallot = ballotContains(id); + const inBallot = ballotContains(id!); const allocations = ballot?.votes ?? []; const sum = sumBallot(allocations.filter((p) => p.projectId !== id)); - const numVotes = ballot?.votes.length ?? 0; + const numVotes = ballot?.votes?.length ?? 0; - const dialogMessage = `How much ${config.tokenName} should this Project receive to fill the gap between the impact they generated for - ${config.roundOrganizer} and the profit they received for generating this impact`; - - const handleOpen = useCallback(() => { - setOpen(true); - }, [setOpen]); - - if (useAppState() !== EAppState.VOTING) { - return null; - } + if (useAppState() !== EAppState.VOTING) return null; return (
{numVotes > config.voteLimit && ( - You have exceeded your vote limit. You can only vote for {config.voteLimit} options. + You have exceeded your vote limit. You can only vote for{" "} + {config.voteLimit} options. )} - {isEligibleToVote && isRegistered ? ( - <> - {ballot?.published && } - - {!ballot?.published && inBallot && ( - - {formatNumber(inBallot.amount)} allocated - - )} - - {!ballot?.published && !inBallot && ( - - )} - - ) : null} - - -

{dialogMessage}

- + {!isEligibleToVote || !isRegistered ? null : ballot?.published ? ( + + ) : inBallot ? ( + setOpen(true)} + variant="primary" + icon={Check} + > + {formatNumber(inBallot.amount)} allocated + + ) : ( + + )} + +

+ How much {config.tokenName} should this Project receive to fill the + gap between the impact they generated for Optimism and the profit they + received for generating this impact +

{ - addToBallot([{ projectId: id, amount }], pollId!); + addToBallot([{ projectId: id!, amount }], pollId); setOpen(false); }} > @@ -163,7 +162,7 @@ export const ProjectAddToBallot = ({ id = "", name = "" }: IProjectAddToBallotPr current={sum} inBallot={Boolean(inBallot)} onRemove={() => { - removeFromBallot(id); + removeFromBallot(id!); setOpen(false); }} /> diff --git a/src/features/projects/components/ProjectContacts.tsx b/src/features/projects/components/ProjectContacts.tsx new file mode 100644 index 00000000..2fb0ddc5 --- /dev/null +++ b/src/features/projects/components/ProjectContacts.tsx @@ -0,0 +1,45 @@ +import { FaXTwitter } from "react-icons/fa6"; +import { FaGithub, FaEthereum } from "react-icons/fa"; +import { RiGlobalLine } from "react-icons/ri"; +import { Link } from "~/components/ui/Link"; + +export const ProjectContacts = ({ + author, + website, + github, + twitter, +}: { + author?: string; + website?: string; + github?: string; + twitter?: string; +}) => { + return ( +
+ {author && ( + + + {author} + + )} + {twitter && ( + + + x.com + + )} + {website && ( + + + {website} + + )} + {github && ( + + + {github} + + )} +
+ ); +}; diff --git a/src/features/projects/components/ProjectContributions.tsx b/src/features/projects/components/ProjectContributions.tsx index 7e8b1a62..0577890f 100644 --- a/src/features/projects/components/ProjectContributions.tsx +++ b/src/features/projects/components/ProjectContributions.tsx @@ -51,4 +51,4 @@ const ProjectContributions = ({ isLoading, project = undefined }: IProjectContri ); -export default ProjectContributions; +export default ProjectContributions; \ No newline at end of file diff --git a/src/features/projects/components/ProjectDescriptionSection.tsx b/src/features/projects/components/ProjectDescriptionSection.tsx new file mode 100644 index 00000000..157ebf77 --- /dev/null +++ b/src/features/projects/components/ProjectDescriptionSection.tsx @@ -0,0 +1,67 @@ +import { FaGithub, FaEthereum } from "react-icons/fa"; +import { RiGlobalLine } from "react-icons/ri"; +import { Link } from "~/components/ui/Link"; + +import { + type ImpactMetrix, + type ContributionLink, + type FundingSource, + EContributionType, +} from "../types"; + +interface ProjectDescriptionSectionProps { + title: string; + description?: string; + links?: ContributionLink[] | ImpactMetrix[]; + fundings?: FundingSource[]; +} + +export const ProjectDescriptionSection = ({ + title, + description, + links, + fundings, +}: ProjectDescriptionSectionProps) => { + return ( +
+

{title}

+ {description &&

{description}

} + {links && ( +
+

{title} links

+ {links.map((link) => ( + + {link.type && link.type === EContributionType.GITHUB_REPO && ( + + )} + {link.type && + link.type === EContributionType.CONTRACT_ADDRESS && ( + + )} + {link.type && link.type === EContributionType.OTHER && ( + + )} + {link.description} + {link.number && ` - ${link.number}k`} + + ))} +
+ )} + {fundings && ( +
+ {fundings.map((funding) => ( +
+ {funding.description} +
+

+ {funding.type.split("_").join(" ").toLowerCase()} +

+

{funding.amount}

+

{funding.currency}

+
+ ))} +
+ )} +
+ ); +}; diff --git a/src/features/projects/components/ProjectDetails.tsx b/src/features/projects/components/ProjectDetails.tsx index ebb91863..7d4f9c2f 100644 --- a/src/features/projects/components/ProjectDetails.tsx +++ b/src/features/projects/components/ProjectDetails.tsx @@ -1,35 +1,39 @@ -import { type ReactNode } from "react"; +import { useMemo } from "react"; -import { NameENS } from "~/components/ENS"; -import { Heading } from "~/components/ui/Heading"; -import { ProjectAvatar } from "~/features/projects/components/ProjectAvatar"; import { ProjectBanner } from "~/features/projects/components/ProjectBanner"; -import { type Attestation } from "~/utils/fetchAttestations"; -import { suffixNumber } from "~/utils/suffixNumber"; - +import { ProjectAvatar } from "~/features/projects/components/ProjectAvatar"; import { useProjectMetadata } from "../hooks/useProjects"; - -import ProjectContributions from "./ProjectContributions"; -import ProjectImpact from "./ProjectImpact"; +import { type Attestation } from "~/utils/fetchAttestations"; +import { Navigator } from "~/components/ui/Navigator"; +import { VotingWidget } from "~/features/projects/components/VotingWidget"; +import { ProjectContacts } from "./ProjectContacts"; +import { ProjectDescriptionSection } from "./ProjectDescriptionSection"; export interface IProjectDetailsProps { - action: ReactNode; + projectId: string; attestation?: Attestation; } -const ProjectDetails = ({ attestation = undefined, action }: IProjectDetailsProps): JSX.Element => { +const ProjectDetails({ + projectId, + attestation = undefined, +}: IProjectDetailsProps) { const metadata = useProjectMetadata(attestation?.metadataPtr); const { bio, websiteUrl, payoutAddress, fundingSources } = metadata.data ?? {}; + const github = useMemo( + () => + metadata.data?.contributionLinks + ? metadata.data.contributionLinks.find((l) => l.type === "GITHUB_REPO") + : undefined, + [metadata, useProjectMetadata], + ); + return (
-
-
-

{attestation?.name}

- - {action} -
+
+
@@ -37,55 +41,42 @@ const ProjectDetails = ({ attestation = undefined, action }: IProjectDetailsProp
- - -
- -
+
- -

{bio}

- -
- - Impact statements - - - - - - - - Past grants and funding - - -
- {fundingSources?.map((source) => { - const type = - { - OTHER: "Other", - RETROPGF_2: "RetroPGF2", - GOVERNANCE_FUND: "Governance Fund", - PARTNER_FUND: "Partner Fund", - REVENUE: "Revenue", - }[source.type] ?? source.type; - return ( -
-
{source.description}
- -
{type}
- -
{`${suffixNumber(source.amount)} ${source.currency}`}
-
- ); - })} -
+
+

{attestation?.name}

+ +
+ +

{bio}

+
+

+ Impact statements +

+ + +
); diff --git a/src/features/projects/components/ProjectItem.tsx b/src/features/projects/components/ProjectItem.tsx index 1ff2d3cc..6e30a559 100644 --- a/src/features/projects/components/ProjectItem.tsx +++ b/src/features/projects/components/ProjectItem.tsx @@ -1,8 +1,14 @@ +import Image from "next/image"; + import { Heading } from "~/components/ui/Heading"; import { Skeleton } from "~/components/ui/Skeleton"; +import { Button } from "~/components/ui/Button"; import { config } from "~/config"; import { type Attestation } from "~/utils/fetchAttestations"; import { formatNumber } from "~/utils/formatNumber"; +import { useAppState } from "~/utils/state"; +import { EAppState } from "~/utils/types"; +import { EProjectState } from "../types"; import { useProjectMetadata } from "../hooks/useProjects"; @@ -13,37 +19,64 @@ import { ProjectBanner } from "./ProjectBanner"; export interface IProjectItemProps { attestation: Attestation; isLoading: boolean; + state: EProjectState; + action: (e: Event) => void; } -export const ProjectItem = ({ attestation, isLoading }: IProjectItemProps): JSX.Element => { - const metadata = useProjectMetadata(attestation.metadataPtr); +export function ProjectItem({ + attestation, + isLoading, + state, + action, +}: IProjectItemProps) { + const metadata = useProjectMetadata(attestation?.metadataPtr); + const appState = useAppState(); return (
- - - {attestation.name} - - -
-

- +

+ + {attestation?.name} + +

+ {metadata.data?.bio}

+ + + + {!isLoading && appState === EAppState.VOTING && ( +
+ + {state === EProjectState.DEFAULT && ( + + )} + {state === EProjectState.ADDED && ( + + )} + {state === EProjectState.SUBMITTED && ( + + )} + +
+ )}
- - - -
); }; diff --git a/src/features/projects/components/ProjectSelectButton.tsx b/src/features/projects/components/ProjectSelectButton.tsx deleted file mode 100644 index 421c34b6..00000000 --- a/src/features/projects/components/ProjectSelectButton.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { AlbumIcon, CheckIcon, PlusIcon } from "lucide-react"; -import { type ComponentProps } from "react"; -import { tv } from "tailwind-variants"; - -import { createComponent } from "~/components/ui"; - -const ActionButton = createComponent( - "button", - tv({ - 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", - variants: { - color: { - default: - "dark:border-white/50 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:border-white", - highlight: "hover:bg-white dark:hover:bg-gray-800 dark:border-white dark:text-white", - green: "border-transparent border-gray-100 dark:border-gray-900 text-gray-500", - }, - }, - defaultVariants: { color: "default" }, - }), -); - -interface IProjectSelectButtonProps extends ComponentProps { - state: 0 | 1 | 2; -} - -export const ProjectSelectButton = ({ state, ...props }: IProjectSelectButtonProps): JSX.Element => { - const { color, icon: Icon } = { - 0: { color: "default", icon: PlusIcon }, - 1: { color: "highlight", icon: CheckIcon }, - 2: { color: "green", icon: AlbumIcon }, - }[state]; - - return ( - - - - ); -}; diff --git a/src/features/projects/components/Projects.tsx b/src/features/projects/components/Projects.tsx index 362fa396..44955dfb 100644 --- a/src/features/projects/components/Projects.tsx +++ b/src/features/projects/components/Projects.tsx @@ -1,97 +1,83 @@ import clsx from "clsx"; -import { XIcon } from "lucide-react"; import Link from "next/link"; -import { useCallback } from "react"; import { InfiniteLoading } from "~/components/InfiniteLoading"; -import { SortFilter } from "~/components/SortFilter"; -import { Alert } from "~/components/ui/Alert"; -import { Button } from "~/components/ui/Button"; -import { config } from "~/config"; -import { useMaci } from "~/contexts/Maci"; -import { useResults } from "~/hooks/useResults"; +import { useSearchProjects } from "../hooks/useProjects"; import { useAppState } from "~/utils/state"; import { EAppState } from "~/utils/types"; - -import { useSearchProjects } from "../hooks/useProjects"; -import { useSelectProjects } from "../hooks/useSelectProjects"; - +import { EProjectState } from "../types"; +import { useResults } from "~/hooks/useResults"; +import { SortFilter } from "~/components/SortFilter"; import { ProjectItem, ProjectItemAwarded } from "./ProjectItem"; -import { ProjectSelectButton } from "./ProjectSelectButton"; +import { useMaci } from "~/contexts/Maci"; +import { useBallot } from "~/contexts/Ballot"; export const Projects = (): JSX.Element => { const projects = useSearchProjects(); - const select = useSelectProjects(); const appState = useAppState(); - const { isRegistered, pollData } = useMaci(); + const { pollData, pollId, isRegistered } = useMaci(); + const { addToBallot, removeFromBallot, ballotContains, ballot } = useBallot(); const results = useResults(pollData); - const handleAdd = useCallback(() => { - select.add(); - }, [select]); + const handleAction = (e: Event, projectId: string) => { + e.preventDefault(); + + if (!ballotContains(projectId)) { + addToBallot( + [ + { + projectId, + amount: 0, + }, + ], + pollId, + ); + } else { + removeFromBallot(projectId); + } + }; - const handleReset = useCallback(() => { - select.reset(); - }, [select]); + const defineState = (projectId: string): EProjectState => { + if (!isRegistered) return EProjectState.UNREGISTERED; + else if (ballotContains(projectId) && ballot?.published) + return EProjectState.SUBMITTED; + else if (ballotContains(projectId) && !ballot?.published) + return EProjectState.ADDED; + else return EProjectState.DEFAULT; + }; return (
- {select.count > config.voteLimit && ( - - You have exceeded your vote limit. You can only vote for {config.voteLimit} options. - - )} - -
- - - -
- -
- +
+

Projects

+
+ +
( - - {isRegistered && !isLoading && appState === EAppState.VOTING ? ( -
- { - e.preventDefault(); - select.toggle(item.id); - }} + renderItem={(item, { isLoading }) => { + return ( + + {!results.isLoading && appState === EAppState.RESULTS ? ( + -
- ) : null} - - {!results.isLoading && appState === EAppState.RESULTS ? ( - - ) : null} - - - - )} + ) : null} + handleAction(e, item.id)} + /> + + ); + }} />
); diff --git a/src/features/projects/components/VotingWidget.tsx b/src/features/projects/components/VotingWidget.tsx new file mode 100644 index 00000000..4ffa1a5a --- /dev/null +++ b/src/features/projects/components/VotingWidget.tsx @@ -0,0 +1,101 @@ +import { useMemo, useCallback, useState } from "react"; + +import { useMaci } from "~/contexts/Maci"; +import { useBallot } from "~/contexts/Ballot"; +import { Input } from "~/components/ui/Input"; +import { Button } from "~/components/ui/Button"; +import { EButtonState } from "../types"; + +export const VotingWidget = ({ projectId }: { projectId: string }) => { + const { pollId } = useMaci(); + const { ballotContains, removeFromBallot, addToBallot } = useBallot(); + const projectBallot = useMemo( + () => ballotContains(projectId), + [ballotContains, projectId], + ); + const projectIncluded = useMemo(() => !!projectBallot, [projectBallot]); + const [amount, setAmount] = useState( + projectBallot?.amount, + ); + + /** + * buttonState + * 0. this project is not included in the ballot before + * 1. this project is included in the ballot before + * 2. after onChange from a value to another value (original state is 1) + * 3. after edited + */ + const [buttonState, setButtonState] = useState( + projectIncluded ? EButtonState.ADDED : EButtonState.DEFAULT, + ); + + const handleRemove = useCallback(() => { + removeFromBallot(projectId); + setAmount(undefined); + setButtonState(0); + }, [removeFromBallot]); + + const handleInput = (e: Event) => { + setAmount(e.target?.value as number); + + if ( + buttonState === EButtonState.ADDED || + buttonState === EButtonState.UPDATED + ) { + setButtonState(EButtonState.EDIT); + } + }; + + const handleButtonAction = () => { + if (!amount) return; + + addToBallot([{ projectId, amount }], pollId); + if (buttonState === EButtonState.DEFAULT) + setButtonState(EButtonState.ADDED); + else setButtonState(EButtonState.UPDATED); + }; + + return ( +
+ {projectIncluded && ( +
+ Remove from My Ballot +
+ )} +
+ + {buttonState === EButtonState.DEFAULT && ( + + )} + {buttonState === EButtonState.ADDED && ( +
+ votes added + +
+ )} + {buttonState === EButtonState.EDIT && ( + + )} + {buttonState === EButtonState.UPDATED && ( +
+ votes updated + +
+ )} +
+
+ ); +}; diff --git a/src/features/projects/types.ts b/src/features/projects/types.ts new file mode 100644 index 00000000..1ca21a80 --- /dev/null +++ b/src/features/projects/types.ts @@ -0,0 +1,46 @@ +export enum EContributionType { + CONTRACT_ADDRESS = "CONTRACT_ADDRESS", + GITHUB_REPO = "GIGHUB_REPO", + OTHER = "OTHER", +} + +export enum EFundingSourceType { + OTHER = "OTHER", + RETROPGF_2 = "RETROPGF_2", + GOVERNANCE_FUND = "GOVERNANCE_FUND", + PARTNER_FUND = "PARTNER_FUND", + REVENUE = "REVENUE", +} + +export enum EButtonState { + DEFAULT, + ADDED, + EDIT, + UPDATED, +} + +export enum EProjectState { + UNREGISTERED, + DEFAULT, + ADDED, + SUBMITTED, +} + +export interface ImpactMetrix { + url: string; + description: string; + number: number; +} + +export interface ContributionLink { + url: string; + type: EContributionType; + description: string; +} + +export interface FundingSource { + type: EFundingSourceType; + description: string; + currency: string; + amount: number; +} diff --git a/src/layouts/BaseLayout.tsx b/src/layouts/BaseLayout.tsx index ee4f9bcc..c3a9aebf 100644 --- a/src/layouts/BaseLayout.tsx +++ b/src/layouts/BaseLayout.tsx @@ -2,7 +2,15 @@ import clsx from "clsx"; import Head from "next/head"; import { useRouter } from "next/router"; import { useTheme } from "next-themes"; -import { type ReactNode, type PropsWithChildren, createContext, useContext, useEffect, useMemo } from "react"; +import { + type ReactNode, + type PropsWithChildren, + createContext, + useContext, + useEffect, + useCallback, + useMemo, +} from "react"; import { useAccount } from "wagmi"; import { Footer } from "~/components/Footer"; @@ -28,7 +36,9 @@ export interface LayoutProps { requireAuth?: boolean; eligibilityCheck?: boolean; showBallot?: boolean; -} + showInfo?: boolean; + showSubmitButton?: boolean; +}; export const BaseLayout = ({ header = null, @@ -50,12 +60,16 @@ export const BaseLayout = ({ const router = useRouter(); const { address, isConnecting } = useAccount(); - useEffect(() => { + const manageDisplay = useCallback(async () => { if (requireAuth && !address && !isConnecting) { - router.push("/"); + await router.push("/"); } }, [requireAuth, address, isConnecting, router]); + useEffect(() => { + manageDisplay(); + }, [manageDisplay]); + const wrappedSidebar = {sidebarComponent}; const contextValue = useMemo(() => ({ eligibilityCheck, showBallot }), [eligibilityCheck, showBallot]); @@ -91,11 +105,19 @@ export const BaseLayout = ({ - -
+
{header} - -
+
{sidebar === "left" ? wrappedSidebar : null}
{ const { address } = useAccount(); const appState = useAppState(); + const { ballot } = useBallot(); - const navLinks = [ - { - href: "/projects", - children: "Projects", - }, - { - href: "/info", - children: "Info", - }, - ]; + const navLinks = useMemo(() => { + const navLinks = [ + { + href: "/projects", + children: "Projects", + }, + ]; - if (appState === EAppState.RESULTS) { - navLinks.push({ - href: "/stats", - children: "Stats", - }); - } + if (ballot?.published) { + navLinks.push({ + href: "/ballot/confirmation", + children: "My Ballot", + }); + } else { + navLinks.push({ + href: "/ballot", + children: "My Ballot", + }); + } - if (config.admin === address!) { - navLinks.push( - ...[ - { - href: "/applications", - children: "Applications", - }, - { - href: "/voters", - children: "Voters", - }, - ], - ); - } + if (appState === EAppState.RESULTS) { + navLinks.push({ + href: "/stats", + children: "Stats", + }); + } + + if (config.admin === address!) { + navLinks.push( + ...[ + { + href: "/applications", + children: "Applications", + }, + { + href: "/voters", + children: "Voters", + }, + ], + ); + } + + return navLinks; + }, [ballot, appState, address]); return ( }> @@ -61,6 +77,31 @@ export const Layout = ({ children = null, ...props }: Props): JSX.Element => { ); }; -export const LayoutWithBallot = ({ ...props }: Props): JSX.Element => ( - } {...props} /> -); +export function LayoutWithSidebar({ ...props }: Props) { + const { isRegistered } = useMaci(); + const { address } = useAccount(); + const { ballot } = useBallot(); + + return ( + + {props.showInfo && } + {props.showBallot && address && isRegistered && } + {props.showSubmitButton && ballot && ballot.votes.length > 0 && ( +
+ + +
+ )} +
+
+ } + {...props} + /> + ); +} diff --git a/src/pages/ballot/confirmation.tsx b/src/pages/ballot/confirmation.tsx index 555d3252..d385edfd 100644 --- a/src/pages/ballot/confirmation.tsx +++ b/src/pages/ballot/confirmation.tsx @@ -1,17 +1,34 @@ +import { useRouter } from "next/router"; +import { useEffect, useState, useCallback } from "react"; + import { useBallot } from "~/contexts/Ballot"; import { BallotConfirmation } from "~/features/ballot/components/BallotConfirmation"; import { Layout } from "~/layouts/DefaultLayout"; +import { Spinner } from "~/components/ui/Spinner"; const BallotConfirmationPage = (): JSX.Element | null => { - const { ballot } = useBallot(); + const [isLoading, setIsLoading] = useState(true); + + const { ballot, isLoading: isBallotLoading } = useBallot(); + const router = useRouter(); + + const manageDisplay = useCallback(async () => { + if (isBallotLoading) return; + + if (ballot.published) { + setIsLoading(false); + } else { + await router.push("/ballot"); + } + }, [router, ballot]); - if (!ballot) { - return null; - } + useEffect(() => { + manageDisplay(); + }, [manageDisplay]); return ( - +b.amount - +a.amount)} /> + {isLoading ? : } ); }; diff --git a/src/pages/ballot/index.tsx b/src/pages/ballot/index.tsx index 34261506..cd4c9ba4 100644 --- a/src/pages/ballot/index.tsx +++ b/src/pages/ballot/index.tsx @@ -7,11 +7,10 @@ import { useAccount } from "wagmi"; import { Button } from "~/components/ui/Button"; import { Dialog } from "~/components/ui/Dialog"; import { Form } from "~/components/ui/Form"; -import { config } from "~/config"; import { useBallot } from "~/contexts/Ballot"; import { AllocationFormWrapper } from "~/features/ballot/components/AllocationList"; import { BallotSchema, type Vote } from "~/features/ballot/types"; -import { LayoutWithBallot } from "~/layouts/DefaultLayout"; +import { LayoutWithSidebar } from "~/layouts/DefaultLayout"; import { formatNumber } from "~/utils/formatNumber"; import { useAppState } from "~/utils/state"; import { EAppState } from "~/utils/types"; @@ -33,28 +32,23 @@ const ClearBallot = () => { return ( <> - - - -

This will empty your vote and remove all the projects you have added.

- -
- -
-
+ Remove all projects +
+ + ); }; @@ -69,7 +63,7 @@ const EmptyBallot = () => (

-
@@ -77,49 +71,44 @@ const EmptyBallot = () => (
); -const TotalAllocation = () => { - const { sumBallot } = useBallot(); - const form = useFormContext<{ votes: Vote[] }>(); - const votes = form.watch("votes"); - const sum = sumBallot(votes); - - return
{`${formatNumber(sum)} ${config.tokenName}`}
; -}; - const BallotAllocationForm = () => { const appState = useAppState(); - const { ballot } = useBallot(); - - return ( -
-

Review your vote

- -

Once you have reviewed your votes allocation, you can submit your vote.

+ const { ballot, sumBallot } = useBallot(); -
{ballot?.votes.length ? : null}
+ const sum = useMemo( + () => formatNumber(sumBallot(ballot?.votes)), + [ballot, sumBallot], + ); -
+ return ( +
+

My Ballot

+

+ Once you have reviewed your vote allocation, you can submit your ballot. +

+
+ {ballot?.votes?.length ? : null} +
+
-
- {ballot?.votes.length ? ( - - ) : ( - - )} -
+ {ballot?.votes?.length ? ( + + ) : ( + + )}
-
-
Total votes
- -
- -
+
+

Total votes:

+

{sum}

); -}; +} const BallotPage = (): JSX.Element => { const { address, isConnecting } = useAccount(); @@ -128,26 +117,31 @@ const BallotPage = (): JSX.Element => { useEffect(() => { if (!address && !isConnecting) { - // eslint-disable-next-line no-console - router.push("/").catch(console.error); + router.push("/").catch(console.log); } }, [address, isConnecting, router]); - const votes = useMemo(() => ballot?.votes.sort((a, b) => b.amount - a.amount), [ballot]); + const votes = useMemo( + () => ballot?.votes?.sort((a, b) => b.amount - a.amount), + [ballot], + ); if (!votes) { return ; } return ( - - null}> + + - -
- + ); -}; +} export default BallotPage; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 91e87ccf..669ea527 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,15 +1,8 @@ import { type GetServerSideProps } from "next"; -import { Layout } from "~/layouts/DefaultLayout"; - -const ProjectsPage = (): JSX.Element => ...; - -export default ProjectsPage; - -export const getServerSideProps: GetServerSideProps = async () => - Promise.resolve({ - redirect: { - destination: "/projects", - permanent: false, - }, - }); +export const getServerSideProps: GetServerSideProps = async () => ({ + redirect: { + destination: "/signup", + permanent: false, + }, +}); diff --git a/src/pages/projects/[projectId]/Project.tsx b/src/pages/projects/[projectId]/Project.tsx index 713d857c..1e3da589 100644 --- a/src/pages/projects/[projectId]/Project.tsx +++ b/src/pages/projects/[projectId]/Project.tsx @@ -1,12 +1,8 @@ import { type GetServerSideProps } from "next"; -import { ProjectAddToBallot } from "~/features/projects/components/AddToBallot"; -import { ProjectAwarded } from "~/features/projects/components/ProjectAwarded"; +import { LayoutWithSidebar } from "~/layouts/DefaultLayout"; import ProjectDetails from "~/features/projects/components/ProjectDetails"; import { useProjectById } from "~/features/projects/hooks/useProjects"; -import { LayoutWithBallot } from "~/layouts/DefaultLayout"; -import { useAppState } from "~/utils/state"; -import { EAppState } from "~/utils/types"; export interface IProjectDetailsProps { projectId?: string; @@ -14,19 +10,18 @@ export interface IProjectDetailsProps { const ProjectDetailsPage = ({ projectId = "" }: IProjectDetailsProps): JSX.Element => { const projects = useProjectById(projectId); - const { name } = projects.data?.[0] ?? {}; - const appState = useAppState(); + const { name } = projects.data?.[0] ?? {};; - const action = - appState === EAppState.RESULTS ? ( - - ) : ( - - ); return ( - - - + + + ); }; diff --git a/src/pages/projects/index.tsx b/src/pages/projects/index.tsx index ab50c306..24d7f967 100644 --- a/src/pages/projects/index.tsx +++ b/src/pages/projects/index.tsx @@ -1,10 +1,12 @@ +import { LayoutWithSidebar } from "~/layouts/DefaultLayout"; import { Projects } from "~/features/projects/components/Projects"; -import { LayoutWithBallot } from "~/layouts/DefaultLayout"; -const ProjectsPage = (): JSX.Element => ( - - - -); +const ProjectsPage = (): JSX.Element => { + return ( + + + + ); +} export default ProjectsPage; diff --git a/src/pages/signup/index.tsx b/src/pages/signup/index.tsx new file mode 100644 index 00000000..fc4ba519 --- /dev/null +++ b/src/pages/signup/index.tsx @@ -0,0 +1,49 @@ +import { useAccount } from "wagmi"; +import Link from "next/link"; +import { format } from "date-fns"; + +import { Layout } from "~/layouts/DefaultLayout"; +import { config } from "~/config"; +import { ConnectButton } from "~/components/ConnectButton"; +import { JoinButton } from "~/components/JoinButton"; +import { Info } from "~/components/Info"; +import { EligibilityDialog } from "~/components/EligibilityDialog"; +import { useMaci } from "~/contexts/Maci"; +import { Button } from "~/components/ui/Button"; + +const SignupPage = (): JSX.Element => { + const { isConnected } = useAccount(); + const { isRegistered } = useMaci(); + + return ( + + + +
+

+ {config.eventName.toUpperCase()} +

+

+ {config.roundId.toUpperCase()} +

+

+ {format(config.startsAt, "d MMMM, yyyy")} + - + {format(config.resultsAt, "d MMMM, yyyy")} +

+ {isConnected && isRegistered && ( + + )} + {isConnected && !isRegistered && } + {!isConnected && } +
+ +
+
+
+ ); +} + +export default SignupPage; diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 506f65d2..bc08499b 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -1,22 +1,49 @@ -import { type Chain, getDefaultConfig, RainbowKitProvider } from "@rainbow-me/rainbowkit"; +import { + type Chain, + getDefaultConfig, + RainbowKitProvider, + type Theme, + lightTheme, +} from "@rainbow-me/rainbowkit"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "next-themes"; import { useMemo, type PropsWithChildren } from "react"; import { http, WagmiProvider } from "wagmi"; + import { Toaster } from "~/components/Toaster"; import * as appConfig from "~/config"; import { BallotProvider } from "~/contexts/Ballot"; import { MaciProvider } from "~/contexts/Maci"; -export const Providers = ({ children }: PropsWithChildren): JSX.Element => { +const theme = lightTheme(); + +const customTheme: Theme = { + blurs: { + ...theme.blurs, + }, + colors: { + ...theme.colors, + }, + fonts: { + body: "Share Tech Mono", + }, + radii: { + ...theme.radii, + }, + shadows: { + ...theme.shadows, + }, +}; + +export function Providers({ children }: PropsWithChildren) { const { config, queryClient } = useMemo(() => createWagmiConfig(), []); return ( - + {children} diff --git a/src/styles/globals.css b/src/styles/globals.css index 80419286..e4a35015 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -10,3 +10,48 @@ -ms-overflow-style: none; scrollbar-width: 0; } + +@layer base { + @font-face { + font-family: "Share Tech Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Share_Tech_Mono.woff2) format("woff2"); + } + + @font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/DM_Sans.woff2) format("woff2"); + } + + html { + font-family: "Share Tech Mono", "DM Sans"; + } + + h1 { + font-size: 60px; + font-family: "Share Tech Mono"; + } + + h2 { + font-size: 40px; + } + + h3 { + font-family: "Share Tech Mono"; + font-size: 32px; + color: black; + text-transform: uppercase; + } + + h4 { + font-size: 16px; + font-weight: 800; + text-transform: uppercase; + color: #888888; + } +} diff --git a/src/utils/types.ts b/src/utils/types.ts index c145313f..2bf5bc62 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,8 +1,13 @@ export enum EAppState { LOADING = "LOADING", APPLICATION = "APPLICATION", - REVIEWING = "REVIEWING", VOTING = "VOTING", - RESULTS = "RESULTS", TALLYING = "TALLYING", + RESULTS = "RESULTS", +} + +export enum EInfoCardState { + PASSED = "PASSED", + ONGOING = "ONGOING", + UPCOMING = "UPCOMING", } diff --git a/tailwind.config.ts b/tailwind.config.ts index 2884f0af..a8dc0538 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -32,6 +32,37 @@ const customColors = { highlight: { 600: "#F3CF00", }, + gray: { + 50: "#F6F6F6", + 100: "#E7E7E7", + 200: "#D1D1D1", + 300: "#B0B0B0", + 400: "#888888", + 500: "#6D6D6D", + 600: "#5D5D5D", + 700: "#4F4F4F", + 800: "#454545", + 900: "#3D3D3D", + 950: "#0B0B0B", + }, + blue: { + 50: "#F0F7FE", + 100: "#DEECFB", + 200: "#C4E0F9", + 300: "#9BCCF5", + 400: "#6BB1EF", + 500: "#579BEA", + 600: "#3476DC", + 700: "#2B62CA", + 800: "#2950A4", + 900: "#264682", + 950: "#1B2B50", + }, + black: "#0B0B0B", + darkGray: "#5E5E5E", + lightGray: "#CDCDCD", + green: "#00FF00", + red: "#EF4444", }; export default { @@ -42,10 +73,14 @@ export default { colors: { ...colors, ...customColors, - gray: colors.stone, }, fontFamily: { - sans: ["var(--font-inter)", ...theme.fontFamily.sans], + sans: ["DM Sans", ...theme.fontFamily.sans], + mono: ["Share Tech Mono", ...theme.fontFamily.mono], + }, + width: { + "112": "28rem", + "128": "32rem", }, }, },