diff --git a/app/allocation/[category]/components/RankingRow.tsx b/app/allocation/[category]/components/RankingRow.tsx index 28a9e1c..23c4ba9 100644 --- a/app/allocation/[category]/components/RankingRow.tsx +++ b/app/allocation/[category]/components/RankingRow.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react'; +import { FC } from 'react'; import Image from 'next/image'; import { NumericFormat } from 'react-number-format'; import { IProjectRanking } from '@/app/comparison/utils/types'; @@ -7,17 +7,28 @@ import { ExpandVertical } from '@/public/assets/icon-components/ExpandVertical'; import { LockIcon } from '@/public/assets/icon-components/Lock'; import { UnlockIcon } from '@/public/assets/icon-components/Unlock'; import styles from '@/app/styles/Project.module.css'; -// @ts-ignore - +import { formatBudget } from '@/app/comparison/utils/helpers'; interface IRankingRowProps { + index: number project: IProjectRanking + budget: number + locked: boolean + onLock: (id: number) => void selected: boolean onSelect: (id: number) => void + onVote: (id: number, share: number) => void } -const RankingRow: FC = ({ project, selected, onSelect }) => { - const [value, setValue] = useState(0); - +const RankingRow: FC = ({ + index, + project, + budget, + locked, + onLock, + selected, + onSelect, + onVote, +}) => { const handleAllowdValue = (values: any) => { const { floatValue } = values; return !floatValue || floatValue <= 100; @@ -54,36 +65,39 @@ const RankingRow: FC = ({ project, selected, onSelect }) => {
-

1

+

{index + 1}

{ - setValue(values?.floatValue || 0); + onVote( + project.projectId, + values?.floatValue ? values.floatValue / 100 : 0 + ); }} className="w-24 rounded-md border border-gray-200 bg-gray-50 px-4 py-2 text-center focus:outline-none focus:ring-1" placeholder="0.00%" isAllowed={values => handleAllowdValue(values)} /> - - 235.23 + + {formatBudget(budget)} diff --git a/app/allocation/[category]/page.tsx b/app/allocation/[category]/page.tsx index 31b68de..72cf6e9 100644 --- a/app/allocation/[category]/page.tsx +++ b/app/allocation/[category]/page.tsx @@ -1,17 +1,29 @@ 'use client'; -import { useState } from 'react'; -import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; import RankingRow from './components/RankingRow'; import HeaderRF6 from '../../comparison/card/Header-RF6'; import Spinner from '@/app/components/Spinner'; import SearchBar from './components/SearchBar'; -import { categorySlugIdMap, categoryIdTitleMap } from '../../comparison/utils/helpers'; +import { + categorySlugIdMap, + categoryIdTitleMap, + formatBudget, +} from '../../comparison/utils/helpers'; import { Checkbox } from '@/app/utils/Checkbox'; import { LockIcon } from '@/public/assets/icon-components/Lock'; import NotFoundComponent from '@/app/components/404'; -import { useProjectsRankingByCategoryId } from '@/app/comparison/utils/data-fetching/ranking'; +import { + useProjectsRankingByCategoryId, + useUpdateProjectRanking, + useCategoryRankings, + IProjectRankingObj, +} from '@/app/comparison/utils/data-fetching/ranking'; import { CheckIcon } from '@/public/assets/icon-components/Check'; +import { IProjectRanking } from '@/app/comparison/utils/types'; +import { ArrowLeft2Icon } from '@/public/assets/icon-components/ArrowLeft2'; +import { ArrowRightIcon } from '@/public/assets/icon-components/ArrowRight'; enum VotingStatus { VOTED, @@ -29,16 +41,23 @@ const votingStatusMap = { const RankingPage = () => { const params = useParams(); + const router = useRouter(); const category = categorySlugIdMap.get((params?.category as string) || ''); const [search, setSearch] = useState(''); const [checkedItems, setCheckedItems] = useState([]); + const [projects, setProjects] = useState(null); + const [rankingArray, setRankingArray] = useState([]); + const [totalShareError, setTotalShareError] = useState(null); + const [lockedItems, setLockedItems] = useState([]); + const { data: categoryRankings } = useCategoryRankings(); const { data: ranking, isLoading } = useProjectsRankingByCategoryId(category); - const projects = ranking?.ranking; - - console.log(projects); + const { mutate: updateProjectRanking } = useUpdateProjectRanking({ + cid: category, + ranking: rankingArray, + }); const handleBulkSelection = () => { if (!projects) return; @@ -51,17 +70,80 @@ const RankingPage = () => { } }; + const handleVote = (id: number, share: number) => { + if (!projects) return; + + const updatedProjects = projects.map(project => + project.projectId === id ? { ...project, share } : project + ); + + setProjects(updatedProjects); + }; + + const handleLocck = (id: number) => { + if (lockedItems.includes(id)) { + setLockedItems(lockedItems.filter(lockedId => lockedId !== id)); + } + else { + setLockedItems([...lockedItems, id]); + } + }; + + const selectItem = (id: number) => { + if (checkedItems.includes(id)) { + setCheckedItems(checkedItems.filter(checkedId => checkedId !== id)); + } + else { + setCheckedItems([...checkedItems, id]); + } + }; + + const submitVotes = () => { + if (!projects) return; + + const totalShare = projects.reduce( + (acc, project) => acc + project.share * 100, + 0 + ); + + if (totalShare !== 100) { + if (totalShare > 100) { + setTotalShareError( + `Percentages must add up to 100% (remove ${ + totalShare - 100 + }% from your ballot)` + ); + } + else { + setTotalShareError( + `Percentages must add up to 100% (add ${ + 100 - totalShare + }% to your ballot)` + ); + } + return; + } + + const rankingArray = projects.map(project => ({ + id: project.projectId, + share: project.share, + })); + + setRankingArray(rankingArray); + + updateProjectRanking(); + }; + + useEffect(() => { + if (ranking) setProjects(ranking?.ranking); + }, [ranking]); + if (!category) return ; return (
- -
+ +

Edit your votes

@@ -74,7 +156,9 @@ const RankingPage = () => {

OP calculations in this ballot are based on your budget of {' '} - 3,333,333 + + {formatBudget(categoryRankings?.budget)} +

@@ -120,21 +204,17 @@ const RankingPage = () => { ? ( - {projects.map(project => ( + {projects.map((project, index) => ( { - if (checkedItems.includes(id)) { - setCheckedItems( - checkedItems.filter(checkedId => checkedId !== id) - ); - } - else { - setCheckedItems([...checkedItems, id]); - } - }} + locked={lockedItems.includes(project.projectId)} + onLock={handleLocck} + onSelect={selectItem} + onVote={handleVote} /> ))} @@ -143,6 +223,30 @@ const RankingPage = () => { : (

No projects found

)} + + {totalShareError && ( +
+

+ {totalShareError} +

+
+ )} +
+ + +
diff --git a/app/allocation/components/CategoryAllocation.tsx b/app/allocation/components/CategoryAllocation.tsx index 0fd9531..a460be8 100644 --- a/app/allocation/components/CategoryAllocation.tsx +++ b/app/allocation/components/CategoryAllocation.tsx @@ -1,6 +1,7 @@ import debounce from 'lodash.debounce'; import Image from 'next/image'; import { ChangeEventHandler, FC, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { roundFractions } from '../utils'; import { useAuth } from '@/app/utils/wallet/AuthProvider'; @@ -13,6 +14,8 @@ import { } from '@/app/comparison/utils/types'; import { CheckIcon } from '@/public/assets/icon-components/Check'; import { UserColabGroupIcon } from '@/public/assets/icon-components/UserColabGroup'; +import { categoryIdSlugMap } from '@/app/comparison/utils/helpers'; + export interface Category { id: number imageSrc: string @@ -34,6 +37,7 @@ interface CategoryAllocationProps extends Category { } const CategoryAllocation: FC = ({ + id, allocatingBudget, imageSrc, title, @@ -49,6 +53,7 @@ const CategoryAllocation: FC = ({ onPercentageChange, }) => { const { isAutoConnecting } = useAuth(); + const router = useRouter(); const inputRef = useRef(null); const handleInputChange: ChangeEventHandler = debounce( @@ -188,7 +193,11 @@ const CategoryAllocation: FC = ({ : status === CollectionProgressStatusEnum.Finished ? (
-
diff --git a/app/allocation/page.tsx b/app/allocation/page.tsx index a62df81..56c28ef 100644 --- a/app/allocation/page.tsx +++ b/app/allocation/page.tsx @@ -113,8 +113,11 @@ const AllocationPage = () => { null ); - const [delegationState, setDelegationState] = useState(DelegationState.Initial); - const [categoryToDelegate, setCategoryToDelegate] = useState>(); + const [delegationState, setDelegationState] = useState( + DelegationState.Initial + ); + const [categoryToDelegate, setCategoryToDelegate] + = useState>(); const [targetDelegate, setTargetDelegate] = useState(); const handleDelegate = async (username: string, target: TargetDelegate) => { @@ -186,14 +189,18 @@ const AllocationPage = () => { return (
{delegationState === DelegationState.DelegationMethod && ( { setDelegationState(DelegationState.Lookup); }} + onFindDelegatesFarcaster={() => { + setDelegationState(DelegationState.Lookup); + }} onFindDelegatesTwitter={() => {}} /> )} @@ -225,12 +232,7 @@ const AllocationPage = () => { selectedCategoryId={selectedCategoryId} /> - + { diff --git a/app/comparison/[category]/page.tsx b/app/comparison/[category]/page.tsx index c529fdf..d89cbb6 100644 --- a/app/comparison/[category]/page.tsx +++ b/app/comparison/[category]/page.tsx @@ -7,7 +7,7 @@ import { useAccount } from 'wagmi'; import { JWTPayload } from '@/app/utils/wallet/types'; import { AutoScrollAction, ProjectCard } from '../card/ProjectCard'; import ConflictButton from '../card/CoIButton'; -import Header from '../card/Header'; +import HeaderRF6 from '../card/Header-RF6'; import { Rating } from '../card/Rating'; import UndoButton from '../card/UndoButton'; import VoteButton from '../card/VoteButton'; @@ -476,7 +476,7 @@ export default function Home() { /> )} -
= ({ children, customClass }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close the dropdown when clicking outside of it + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current + && !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; + +export default Dropdown; diff --git a/app/comparison/card/Header-RF6.tsx b/app/comparison/card/Header-RF6.tsx index 5c776e9..40518e6 100644 --- a/app/comparison/card/Header-RF6.tsx +++ b/app/comparison/card/Header-RF6.tsx @@ -1,33 +1,45 @@ -import React, { useMemo } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; +import { useDisconnect } from 'wagmi'; import { ConnectButton } from '@/app/utils/wallet/Connect'; import { PwLogo } from '@/public/assets/icon-components/PairwiseLogo'; import { ThinExternalLinkIcon } from '@/public/assets/icon-components/ThinExternalLink'; import ActiveBadges, { BadgesEnum, IActiveBadge } from './ActiveBadges'; import Modal from '../../utils/Modal'; import BadgesModal from './modals/BadgesModal'; +import Dropdown from './DropDown'; +import { shortenWalletAddress } from '@/app/comparison/utils/helpers'; +import { useAuth } from '@/app/utils/wallet/AuthProvider'; +import { PowerIcon } from '@/public/assets/icon-components/Power'; import { useGetPublicBadges } from '@/app/utils/getBadges'; interface HeaderProps { - progress: number - category: string - question: string + progress?: number + category?: string + question?: string isFirstSelection?: boolean } -const PAIRWISE_REPORT_URL = `https://github.com/GeneralMagicio/pairwise-rf6/issues/new? - assignees=MoeNick&labels=&projects=&template=report-an-issue.md&title=%5BFeedback%5D+`; +const PAIRWISE_REPORT_URL + = 'https://github.com/GeneralMagicio/pairwise-rf6/issues/new?assignees=MoeNick&labels=&projects=&template=report-an-issue.md&title=%5BFeedback%5D+'; -const HeaderRF6: React.FC = ({ isFirstSelection, question }) => { - const [isBadgesModalOpen, setIsBadgesModalOpen] = React.useState(false); +const HeaderRF6: React.FC = ({ + progress, + category, + question, + isFirstSelection = false, +}) => { + const { disconnectAsync } = useDisconnect(); + const { signOut, loginAddress } = useAuth(); const { data: badges } = useGetPublicBadges(); + + const [isBadgesModalOpen, setIsBadgesModalOpen] = React.useState(false); + const [isBarFixed, setIsBarFixed] = useState(false); + const activeBadges = useMemo(() => { - if (!badges) return []; - const { - recipientsPoints, - badgeholderPoints, - holderType, - delegateType, - } = badges; + if (!badges || !Object.keys(badges).length) return []; + + const { recipientsPoints, badgeholderPoints, holderType, delegateType } + = badges; const activeBadgesArray: IActiveBadge[] = []; if (holderType) { activeBadgesArray.push({ @@ -53,39 +65,138 @@ const HeaderRF6: React.FC = ({ isFirstSelection, question }) => { } return activeBadgesArray; }, [badges]); + + useEffect(() => { + const handleScroll = () => { + if (window.scrollY > 100) { + setIsBarFixed(true); + } + else { + setIsBarFixed(false); + } + }; + + window.addEventListener('scroll', handleScroll); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, []); + + const logout = async () => { + await disconnectAsync(); + signOut(); + }; + return ( <> - { setIsBadgesModalOpen(false); }} showCloseButton> + { + setIsBadgesModalOpen(false); + }} + showCloseButton + >
-
+
{!isFirstSelection && ( -
-
- -
+
+ +
+ )} + {question && ( +
+

{question}

)}
- - - + {activeBadges.length > 0 && ( + + )} + + +
+ + +
+ {activeBadges.length > 0 && ( + <> +
+

Your budges

+ +
+
+ + )} +
+ {loginAddress?.value && ( +

+ {shortenWalletAddress(loginAddress?.value)} +

+ )} +
+ +
+ +
+
+ + {category && ( +
+
+
+
+ )}
); diff --git a/app/comparison/card/Header.tsx b/app/comparison/card/Header.tsx index 4fab16d..6abd2a3 100644 --- a/app/comparison/card/Header.tsx +++ b/app/comparison/card/Header.tsx @@ -2,9 +2,7 @@ import React, { useState, useEffect } from 'react'; import Image from 'next/image'; import { ConnectButton } from '@/app/utils/wallet/Connect'; -const PAIRWISE_REPPORT_URL - = `https://github.com/GeneralMagicio/pairwise-rpgf5/issues/new? - assignees=MoeNick&labels=&projects=&template=report-an-issue.md&title=%5BFeedback%5D+`; +const PAIRWISE_REPORT_URL = 'https://github.com/GeneralMagicio/pairwise-rf6/issues/new?assignees=MoeNick&labels=&projects=&template=report-an-issue.md&title=%5BFeedback%5D+'; interface HeaderProps { progress: number @@ -72,7 +70,7 @@ const Header: React.FC = ({ diff --git a/app/comparison/utils/data-fetching/ranking.ts b/app/comparison/utils/data-fetching/ranking.ts index a099678..b2941dd 100644 --- a/app/comparison/utils/data-fetching/ranking.ts +++ b/app/comparison/utils/data-fetching/ranking.ts @@ -1,17 +1,42 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { axiosInstance } from '@/app/utils/axiosInstance'; -import { IProjectRanking } from '@/app/comparison/utils/types'; +import { IProjectRanking, ICategory } from '@/app/comparison/utils/types'; + +interface ICategoryRankingResponse + extends Omit { + ranking: ICategory[] +} export interface IProjectsRankingResponse { ranking: IProjectRanking[] hasRanking: boolean isFinished: boolean progress: string + budget: number name: string share: number id: number } +export interface IProjectRankingObj { + id: number + share: number +} + +export const getCategoryRankings + = async (): Promise => { + const res = await axiosInstance.get('flow/ranking'); + + return res.data; + }; + +export const useCategoryRankings = () => { + return useQuery({ + queryKey: ['category-ranking'], + queryFn: () => getCategoryRankings(), + }); +}; + export const getProjectsRankingByCategoryId = async ( cid: number | undefined ): Promise => { @@ -31,3 +56,42 @@ export const useProjectsRankingByCategoryId = (cid: number | undefined) => { staleTime: Infinity, }); }; + +export const updateProjectRanking = async ({ + cid, + ranking, +}: { + cid: number + ranking: IProjectRankingObj[] +}) => { + return ( + await axiosInstance.post('flow/ranking/custom', { + collectionId: cid, + ranking, + }) + ).data; +}; + +export const useUpdateProjectRanking = ({ + cid, + ranking, +}: { + cid: number | undefined + ranking: IProjectRankingObj[] +}) => { + const queryClient = useQueryClient(); + + if (!cid) { + throw new Error('Invalid category id'); + } + + return useMutation({ + mutationFn: () => updateProjectRanking({ cid, ranking }), + onSuccess: () => { + console.log('OnSuccess'); + queryClient.refetchQueries({ + queryKey: ['projects-ranking', cid], + }); + }, + }); +}; diff --git a/app/comparison/utils/helpers.ts b/app/comparison/utils/helpers.ts index bcc176e..6532038 100644 --- a/app/comparison/utils/helpers.ts +++ b/app/comparison/utils/helpers.ts @@ -48,3 +48,33 @@ export const getCategoryCount = (category: JWTPayload['category']) => { }; return category in labels ? labels[category] : 30; }; + +export function shortenWalletAddress( + address: string, + startLength: number = 7, + endLength: number = 7 +): string { + // Check if the address is valid (starts with '0x' and has 42 characters) + if (!address.startsWith('0x') || address.length !== 42) { + throw new Error('Invalid wallet address format'); + } + + // Ensure start and end lengths are not greater than half the remaining address length + const maxLength = Math.floor((address.length - 2) / 2); + startLength = Math.min(startLength, maxLength); + endLength = Math.min(endLength, maxLength); + + // Extract the start and end parts of the address + const start = address.slice(0, startLength); + const end = address.slice(-endLength); + + // Combine the parts with ellipsis + return `${start}...${end}`; +} + +export function formatBudget(budget: number | undefined): string { + if (budget === undefined) { + return 'N/A'; + } + return budget.toLocaleString('en-US'); +} diff --git a/app/components/Spinner.tsx b/app/components/Spinner.tsx index 88ab040..ed23bfb 100644 --- a/app/components/Spinner.tsx +++ b/app/components/Spinner.tsx @@ -1,7 +1,7 @@ import styles from '../styles/Spinner.module.css'; const Spinner = () => ( -
+
); diff --git a/app/utils/wallet/ConnectedButton.tsx b/app/utils/wallet/ConnectedButton.tsx index 3a6d6cf..9a09dfb 100644 --- a/app/utils/wallet/ConnectedButton.tsx +++ b/app/utils/wallet/ConnectedButton.tsx @@ -2,35 +2,12 @@ import { FC, useState } from 'react'; import { ArrowDownIcon } from '@/public/assets/icon-components/ArrowDown'; import { ArrowUpIcon } from '@/public/assets/icon-components/ArrowUp'; import { PowerIcon } from '@/public/assets/icon-components/Power'; - +import { shortenWalletAddress } from '@/app/comparison/utils/helpers'; interface Props { wallet: string onLogout: () => void } -export function shortenWalletAddress( - address: string, - startLength: number = 7, - endLength: number = 7 -): string { - // Check if the address is valid (starts with '0x' and has 42 characters) - if (!address.startsWith('0x') || address.length !== 42) { - throw new Error('Invalid wallet address format'); - } - - // Ensure start and end lengths are not greater than half the remaining address length - const maxLength = Math.floor((address.length - 2) / 2); - startLength = Math.min(startLength, maxLength); - endLength = Math.min(endLength, maxLength); - - // Extract the start and end parts of the address - const start = address.slice(0, startLength); - const end = address.slice(-endLength); - - // Combine the parts with ellipsis - return `${start}...${end}`; -} - const LogoutButton: FC> = ({ onLogout }) => { return (