diff --git a/web/src/app.tsx b/web/src/app.tsx index 7fc3b8af8..b3f29e38d 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -73,7 +73,7 @@ const App: React.FC = () => { } /> }> diff --git a/web/src/assets/svgs/icons/voted-ballot.svg b/web/src/assets/svgs/icons/voted-ballot.svg new file mode 100644 index 000000000..3b9bb7c04 --- /dev/null +++ b/web/src/assets/svgs/icons/voted-ballot.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/components/DisputeView/PeriodBanner.tsx b/web/src/components/DisputeView/PeriodBanner.tsx index 6a5287afa..1a120e1c6 100644 --- a/web/src/components/DisputeView/PeriodBanner.tsx +++ b/web/src/components/DisputeView/PeriodBanner.tsx @@ -58,13 +58,14 @@ const StyledLabel = styled.label<{ frontColor: string; withDot?: boolean; isCard ` : null} `; + export interface IPeriodBanner { id: number; period: Periods; isCard?: boolean; } -const getPeriodColors = (period: Periods, theme: Theme): [string, string] => { +export const getPeriodColors = (period: Periods, theme: Theme): [string, string] => { switch (period) { case Periods.appeal: return [theme.tint, theme.tintMedium]; diff --git a/web/src/components/EvidenceCard.tsx b/web/src/components/EvidenceCard.tsx index 07c4bb955..ed09e4567 100644 --- a/web/src/components/EvidenceCard.tsx +++ b/web/src/components/EvidenceCard.tsx @@ -223,7 +223,7 @@ const EvidenceCard: React.FC = ({ description, fileURI, }) => { - const profileLink = `/profile/1/desc/all?address=${sender}`; + const profileLink = `/profile/stakes/1?address=${sender}`; const transactionExplorerLink = useMemo(() => { return getTxnExplorerLink(transactionHash ?? ""); diff --git a/web/src/pages/Home/TopJurors/JurorCard/JurorTitle.tsx b/web/src/components/JurorLink.tsx similarity index 57% rename from web/src/pages/Home/TopJurors/JurorCard/JurorTitle.tsx rename to web/src/components/JurorLink.tsx index 437aea26a..072c530d3 100644 --- a/web/src/pages/Home/TopJurors/JurorCard/JurorTitle.tsx +++ b/web/src/components/JurorLink.tsx @@ -1,11 +1,15 @@ -import React from "react"; +import React, { useMemo } from "react"; import styled from "styled-components"; +import { useAccount } from "wagmi"; + +import { DEFAULT_CHAIN, getChain } from "consts/chains"; + import ArrowIcon from "svgs/icons/arrow.svg"; +import NewTabIcon from "svgs/icons/new-tab.svg"; import { IdenticonOrAvatar, AddressOrName } from "components/ConnectWallet/AccountDisplay"; import { StyledArrowLink } from "components/StyledArrowLink"; -import { useAccount } from "wagmi"; const Container = styled.div` display: flex; @@ -36,26 +40,34 @@ export const ReStyledArrowLink = styled(StyledArrowLink)` } `; -interface IJurorTitle { +interface IJurorLink { address: string; + isInternalLink?: boolean; } -const JurorTitle: React.FC = ({ address }) => { +const JurorLink: React.FC = ({ address, isInternalLink = true }) => { const { isConnected, address: connectedAddress } = useAccount(); const profileLink = isConnected && connectedAddress?.toLowerCase() === address.toLowerCase() - ? "/profile/1/desc/all" - : `/profile/1/desc/all?address=${address}`; + ? "/profile" + : `/profile/stakes/1?address=${address}`; + const addressExplorerLink = useMemo(() => { + return `${getChain(DEFAULT_CHAIN)?.blockExplorers?.default.url}/address/${address}`; + }, [address]); return ( - + - + {isInternalLink ? : } ); }; -export default JurorTitle; +export default JurorLink; diff --git a/web/src/components/NumberDisplay.tsx b/web/src/components/NumberDisplay.tsx index ee67ec43e..351380c98 100644 --- a/web/src/components/NumberDisplay.tsx +++ b/web/src/components/NumberDisplay.tsx @@ -1,6 +1,9 @@ import React from "react"; import { Tooltip } from "@kleros/ui-components-library"; + +import { commify } from "utils/commify"; + interface INumberDisplay { value: string | number; unit?: string; @@ -19,7 +22,7 @@ const getFormattedValue = (value: number, decimals: number) => { return `> -0.${"0".repeat(decimals - 1)}1`; } } - return withFixedDecimals; + return commify(withFixedDecimals); }; const NumberDisplay: React.FC = ({ @@ -32,7 +35,7 @@ const NumberDisplay: React.FC = ({ }) => { const parsedValue = Number(value); const formattedValue = getFormattedValue(parsedValue, decimals); - const tooltipValue = isCurrency ? `${unit} ${value}` : `${value} ${unit}`; + const tooltipValue = isCurrency ? `${unit} ${commify(value)}` : `${commify(value)} ${unit}`; const displayUnit = showUnitInDisplay ? unit : ""; const displayValue = isCurrency ? `${displayUnit} ${formattedValue}` : `${formattedValue} ${displayUnit}`; return ( diff --git a/web/src/components/Popup/MiniGuides/JurorLevels.tsx b/web/src/components/Popup/MiniGuides/JurorLevels.tsx index b8043e26f..c28de3cb1 100644 --- a/web/src/components/Popup/MiniGuides/JurorLevels.tsx +++ b/web/src/components/Popup/MiniGuides/JurorLevels.tsx @@ -5,8 +5,8 @@ import { Card as _Card } from "@kleros/ui-components-library"; import { landscapeStyle } from "styles/landscapeStyle"; -import Coherence from "pages/Profile/JurorInfo/Coherence"; -import PixelArt from "pages/Profile/JurorInfo/PixelArt"; +import Coherence from "pages/Profile/JurorCard/BottomContent/Coherence"; +import PixelArt from "pages/Profile/JurorCard/BottomContent/PixelArt"; import Template from "./MainStructureTemplate"; import { Title, ParagraphsContainer, LeftContentContainer } from "./PageContentsTemplate"; diff --git a/web/src/hooks/queries/useStakingHistory.ts b/web/src/hooks/queries/useStakingHistory.ts new file mode 100644 index 000000000..126c41bd1 --- /dev/null +++ b/web/src/hooks/queries/useStakingHistory.ts @@ -0,0 +1,62 @@ +import { useQuery } from "@tanstack/react-query"; + +// dynamic atlasUri would go here +const atlasUri = "https://url.example/graphql"; + +const AUTH_TOKEN = "Bearer tokenExampleGoesHere"; + +export const useStakingHistory = (take: number, lastCursorId?: number) => { + const variables = { + pagination: { take, lastCursorId: lastCursorId ?? null }, + }; + + return useQuery({ + queryKey: ["stakingHistoryQuery", take, lastCursorId], + enabled: true, + staleTime: 60000, + queryFn: async () => { + console.log("Fetching with variables:", variables); + + try { + const response = await fetch(atlasUri, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: AUTH_TOKEN, + }, + body: JSON.stringify({ + query: ` + query GetStakingEvents($pagination: PaginationArgs) { + userStakingEvents(pagination: $pagination) { + edges { + node { + name + args + blockTimestamp + transactionHash + } + cursor + } + count + hasNextPage + } + } + `, + variables, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(`GraphQL error: ${JSON.stringify(result)}`); + } + + return result; + } catch (error) { + console.error("GraphQL Fetch Error:", error); + throw error; + } + }, + }); +}; diff --git a/web/src/layout/Header/navbar/Menu/Settings/General/WalletAndProfile.tsx b/web/src/layout/Header/navbar/Menu/Settings/General/WalletAndProfile.tsx index 57931035c..bf7f0e5b5 100644 --- a/web/src/layout/Header/navbar/Menu/Settings/General/WalletAndProfile.tsx +++ b/web/src/layout/Header/navbar/Menu/Settings/General/WalletAndProfile.tsx @@ -52,7 +52,7 @@ const WalletAndProfile: React.FC = ({ toggleIsSettingsOpen }) => { - + My Profile diff --git a/web/src/pages/Cases/CaseDetails/Voting/VotesDetails/AccordionTitle.tsx b/web/src/pages/Cases/CaseDetails/Voting/VotesDetails/AccordionTitle.tsx index 6c208351c..6d6de16c3 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/VotesDetails/AccordionTitle.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/VotesDetails/AccordionTitle.tsx @@ -8,7 +8,7 @@ import { getVoteChoice } from "utils/getVoteChoice"; import { isUndefined } from "utils/index"; import { InternalLink } from "components/InternalLink"; -import JurorTitle from "pages/Home/TopJurors/JurorCard/JurorTitle"; +import JurorLink from "components/JurorLink"; const TitleContainer = styled.div` display: flex; @@ -86,13 +86,13 @@ const AccordionTitle: React.FC<{ commited: boolean; hiddenVotes: boolean; }> = ({ juror, choice, voteCount, period, answers, isActiveRound, commited, hiddenVotes }) => { - const profileLink = `/profile/1/desc/all?address=${juror}`; + const profileLink = `/profile/stakes/1?address=${juror}`; return ( - + diff --git a/web/src/pages/Home/TopJurors/JurorCard/DesktopCard.tsx b/web/src/pages/Home/TopJurors/JurorCard/DesktopCard.tsx index 1538dcd67..e6a843201 100644 --- a/web/src/pages/Home/TopJurors/JurorCard/DesktopCard.tsx +++ b/web/src/pages/Home/TopJurors/JurorCard/DesktopCard.tsx @@ -7,9 +7,9 @@ import { hoverShortTransitionTiming } from "styles/commonStyles"; import Coherence from "./Coherence"; import JurorLevel from "./JurorLevel"; -import JurorTitle from "./JurorTitle"; import Rank from "./Rank"; import Rewards from "./Rewards"; +import JurorLink from "components/JurorLink"; const Container = styled.div<{ renderRank?: boolean }>` ${hoverShortTransitionTiming} @@ -57,7 +57,7 @@ const DesktopCard: React.FC = ({ return ( {renderRank && } - + diff --git a/web/src/pages/Home/TopJurors/JurorCard/JurorLevel.tsx b/web/src/pages/Home/TopJurors/JurorCard/JurorLevel.tsx index eba34f350..4dd0d9b9f 100644 --- a/web/src/pages/Home/TopJurors/JurorCard/JurorLevel.tsx +++ b/web/src/pages/Home/TopJurors/JurorCard/JurorLevel.tsx @@ -6,7 +6,7 @@ import { landscapeStyle } from "styles/landscapeStyle"; import { getUserLevelData } from "utils/userLevelCalculation"; import { getCoherencePercent } from "utils/getCoherencePercent"; -import PixelArt from "pages/Profile/JurorInfo/PixelArt"; +import PixelArt from "pages/Profile/JurorCard/BottomContent/PixelArt"; const Container = styled.div` display: flex; diff --git a/web/src/pages/Home/TopJurors/JurorCard/MobileCard.tsx b/web/src/pages/Home/TopJurors/JurorCard/MobileCard.tsx index faffb821a..5118670a6 100644 --- a/web/src/pages/Home/TopJurors/JurorCard/MobileCard.tsx +++ b/web/src/pages/Home/TopJurors/JurorCard/MobileCard.tsx @@ -9,9 +9,9 @@ import HeaderRewards from "../Header/Rewards"; import Coherence from "./Coherence"; import JurorLevel from "./JurorLevel"; -import JurorTitle from "./JurorTitle"; import Rank from "./Rank"; import Rewards from "./Rewards"; +import JurorLink from "components/JurorLink"; const Container = styled.div` ${hoverShortTransitionTiming} @@ -97,7 +97,7 @@ const MobileCard: React.FC = ({ {rank ? : null} - + diff --git a/web/src/pages/Jurors/index.tsx b/web/src/pages/Jurors/index.tsx index 2d7666521..2b5fcc0fc 100644 --- a/web/src/pages/Jurors/index.tsx +++ b/web/src/pages/Jurors/index.tsx @@ -55,7 +55,7 @@ const Jurors: React.FC = () => {
Jurors Leaderboard {isConnected ? ( - + My Profile ) : null} diff --git a/web/src/pages/Profile/Cases/index.tsx b/web/src/pages/Profile/Cases/index.tsx new file mode 100644 index 000000000..52e40d495 --- /dev/null +++ b/web/src/pages/Profile/Cases/index.tsx @@ -0,0 +1,68 @@ +import React, { useMemo } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; + +import styled from "styled-components"; +import { responsiveSize } from "styles/responsiveSize"; + +import { isUndefined } from "utils/index"; +import { decodeURIFilter, useRootPath } from "utils/uri"; + +import { DisputeDetailsFragment, OrderDirection } from "src/graphql/graphql"; +import { useMyCasesQuery } from "queries/useCasesQuery"; +import { useUserQuery } from "queries/useUser"; +import CasesDisplay from "components/CasesDisplay"; + +const StyledCasesDisplay = styled(CasesDisplay)` + margin-top: ${responsiveSize(24, 32)}; + + .title { + margin-bottom: ${responsiveSize(12, 24)}; + } +`; + +interface ICases { + searchParamAddress: `0x${string}`; +} + +const Cases: React.FC = ({ searchParamAddress }) => { + const { page, order, filter } = useParams(); + const [searchParams] = useSearchParams(); + const location = useRootPath(); + const navigate = useNavigate(); + + const casesPerPage = 3; + const pageNumber = parseInt(page ?? "1"); + const disputeSkip = casesPerPage * (pageNumber - 1); + const decodedFilter = decodeURIFilter(filter ?? "all"); + const { data: disputesData } = useMyCasesQuery( + searchParamAddress, + disputeSkip, + decodedFilter, + order === "asc" ? OrderDirection.Asc : OrderDirection.Desc + ); + + const { data: userData } = useUserQuery(searchParamAddress, decodedFilter); + const totalCases = userData?.user?.disputes.length; + const totalResolvedCases = parseInt(userData?.user?.totalResolvedDisputes); + const totalPages = useMemo( + () => (!isUndefined(totalCases) ? Math.ceil(totalCases / casesPerPage) : 1), + [totalCases, casesPerPage] + ); + + return ( + + navigate(`${location}/${newPage}/${order}/${filter}?${searchParams.toString()}`) + } + {...{ casesPerPage }} + /> + ); +}; + +export default Cases; diff --git a/web/src/pages/Profile/Courts/CourtCard/Stake.tsx b/web/src/pages/Profile/Courts/CourtCard/Stake.tsx deleted file mode 100644 index c1617b621..000000000 --- a/web/src/pages/Profile/Courts/CourtCard/Stake.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import styled, { css } from "styled-components"; - -import { formatUnits } from "viem"; - -import { landscapeStyle } from "styles/landscapeStyle"; - -import NumberDisplay from "components/NumberDisplay"; - -import PnkIcon from "svgs/icons/pnk.svg"; - -const Container = styled.div` - display: flex; - flex-direction: row; - gap: 8px; - width: 100%; - justify-content: flex-start; - align-items: center; - - ${landscapeStyle( - () => css` - width: auto; - gap: 12px; - ` - )} -`; - -const StyledLabel = styled.label` - display: flex; - font-weight: 600; - color: ${({ theme }) => theme.primaryText}; - font-size: 16px; - align-items: center; - gap: 4px; -`; - -const StyledPnkIcon = styled(PnkIcon)` - display: inline-block; - width: 16px; - height: 16px; - fill: ${({ theme }) => theme.secondaryPurple}; -`; - -interface IStake { - stake: string; -} - -const Stake: React.FC = ({ stake }) => { - const formattedStake = formatUnits(stake, 18); - - return ( - - - - - - - ); -}; -export default Stake; diff --git a/web/src/pages/Profile/Courts/CourtCard/index.tsx b/web/src/pages/Profile/Courts/CourtCard/index.tsx deleted file mode 100644 index 360f97ca6..000000000 --- a/web/src/pages/Profile/Courts/CourtCard/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from "react"; -import styled, { css } from "styled-components"; - -import { Card as _Card } from "@kleros/ui-components-library"; - -import { landscapeStyle } from "styles/landscapeStyle"; - -import CourtName from "./CourtName"; -import Stake from "./Stake"; - -const Container = styled(_Card)` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - height: auto; - width: 100%; - padding: 20px 16px 24px; - border-left: 5px solid ${({ theme }) => theme.secondaryPurple}; - flex-wrap: wrap; - gap: 16px; - - :hover { - cursor: auto; - } - - ${({ theme }) => (theme.name === "light" ? `box-shadow: 0px 2px 3px 0px ${theme.stroke};` : "")} - - ${landscapeStyle( - () => css` - padding: 21.5px 32px; - ` - )} -`; - -interface ICourtCard { - name: string; - stake: string; - id: string; -} - -const CourtCard: React.FC = ({ name, stake, id }) => { - return ( - - - - - ); -}; - -export default CourtCard; diff --git a/web/src/pages/Profile/Courts/Header.tsx b/web/src/pages/Profile/Courts/Header.tsx deleted file mode 100644 index 07f61e11c..000000000 --- a/web/src/pages/Profile/Courts/Header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from "react"; -import styled, { css } from "styled-components"; - -import { formatUnits } from "viem"; -import { useSearchParams } from "react-router-dom"; - -import LockerIcon from "svgs/icons/locker.svg"; - -import { isUndefined } from "utils/index"; - -import { landscapeStyle } from "styles/landscapeStyle"; -import { responsiveSize } from "styles/responsiveSize"; - -import NumberDisplay from "components/NumberDisplay"; - -const Container = styled.div` - display: flex; - flex-direction: row; - flex-wrap: wrap; - width: 100%; - gap: 4px 16px; - align-items: center; - margin-bottom: ${responsiveSize(16, 24)}; - - ${landscapeStyle( - () => css` - justify-content: space-between; - ` - )} -`; - -const LockedPnk = styled.div` - display: flex; - flex-wrap: nowrap; - gap: 8px; - justify-content: flex-start; - - ${landscapeStyle( - () => css` - align-self: center; - ` - )} -`; - -const StyledTitle = styled.h1` - margin-bottom: 0; - font-size: ${responsiveSize(20, 24)}; -`; - -const StyledLockerIcon = styled(LockerIcon)` - fill: ${({ theme }) => theme.secondaryPurple}; - width: 14px; -`; - -interface IHeader { - lockedStake: bigint; -} - -const Header: React.FC = ({ lockedStake }) => { - const formattedLockedStake = !isUndefined(lockedStake) && formatUnits(lockedStake, 18); - const [searchParams] = useSearchParams(); - const searchParamAddress = searchParams.get("address")?.toLowerCase(); - - return ( - - {searchParamAddress ? "Their" : "My"} Courts - {!isUndefined(lockedStake) ? ( - - - - - - - - ) : null} - - ); -}; -export default Header; diff --git a/web/src/pages/Profile/Courts/index.tsx b/web/src/pages/Profile/Courts/index.tsx deleted file mode 100644 index 512478b1d..000000000 --- a/web/src/pages/Profile/Courts/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -import styled, { css } from "styled-components"; - -import Skeleton from "react-loading-skeleton"; -import { useSearchParams } from "react-router-dom"; - -import { useReadSortitionModuleGetJurorBalance } from "hooks/contracts/generated"; - -import { useJurorStakeDetailsQuery } from "queries/useJurorStakeDetailsQuery"; - -import { landscapeStyle } from "styles/landscapeStyle"; -import { responsiveSize } from "styles/responsiveSize"; - -import CourtCard from "./CourtCard"; -import Header from "./Header"; - -const Container = styled.div` - margin-top: ${responsiveSize(24, 48)}; -`; - -const CourtCardsContainer = styled.div` - display: flex; - flex-direction: column; - gap: 12px; - z-index: 0; - - ${landscapeStyle( - () => css` - gap: 16px; - ` - )} -`; - -const StyledLabel = styled.label` - font-size: ${responsiveSize(14, 16)}; -`; - -interface ICourts { - addressToQuery: `0x${string}`; -} - -const Courts: React.FC = ({ addressToQuery }) => { - const { data: stakeData, isLoading } = useJurorStakeDetailsQuery(addressToQuery); - const { data: jurorBalance } = useReadSortitionModuleGetJurorBalance({ - args: [addressToQuery, BigInt(1)], - }); - const [searchParams] = useSearchParams(); - const searchParamAddress = searchParams.get("address")?.toLowerCase(); - const stakedCourts = stakeData?.jurorTokensPerCourts?.filter(({ staked }) => staked > 0); - const isStaked = stakedCourts && stakedCourts.length > 0; - const lockedStake = jurorBalance?.[1]; - - return ( - -
- {isLoading ? : null} - {!isStaked && !isLoading ? ( - {searchParamAddress ? "They" : "You"} are not staked in any court - ) : null} - {isStaked && !isLoading ? ( - - {stakeData?.jurorTokensPerCourts - ?.filter(({ staked }) => staked > 0) - .map(({ court: { id, name }, staked }) => ( - - ))} - - ) : null} - - ); -}; - -export default Courts; diff --git a/web/src/pages/Profile/JurorInfo/Coherence.tsx b/web/src/pages/Profile/JurorCard/BottomContent/Coherence.tsx similarity index 94% rename from web/src/pages/Profile/JurorInfo/Coherence.tsx rename to web/src/pages/Profile/JurorCard/BottomContent/Coherence.tsx index 712884d22..44e1f9786 100644 --- a/web/src/pages/Profile/JurorInfo/Coherence.tsx +++ b/web/src/pages/Profile/JurorCard/BottomContent/Coherence.tsx @@ -1,9 +1,11 @@ import React from "react"; import styled, { css } from "styled-components"; +import { landscapeStyle } from "styles/landscapeStyle"; + import { CircularProgress } from "@kleros/ui-components-library"; -import { landscapeStyle } from "styles/landscapeStyle"; +import { ILevelCriteria } from "utils/userLevelCalculation"; import WithHelpTooltip from "components/WithHelpTooltip"; @@ -26,10 +28,7 @@ const tooltipMsg = " the majority of jurors it's considered a Coherent Vote."; interface ICoherence { - userLevelData: { - level: number; - title: string; - }; + userLevelData: ILevelCriteria; totalCoherentVotes: number; totalResolvedVotes: number; isMiniGuide: boolean; diff --git a/web/src/pages/Profile/JurorInfo/JurorRewards.tsx b/web/src/pages/Profile/JurorCard/BottomContent/JurorRewards.tsx similarity index 58% rename from web/src/pages/Profile/JurorInfo/JurorRewards.tsx rename to web/src/pages/Profile/JurorCard/BottomContent/JurorRewards.tsx index 0a4471252..96adeab1f 100644 --- a/web/src/pages/Profile/JurorInfo/JurorRewards.tsx +++ b/web/src/pages/Profile/JurorCard/BottomContent/JurorRewards.tsx @@ -1,7 +1,7 @@ import React from "react"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; -import { useAccount } from "wagmi"; +import { landscapeStyle } from "styles/landscapeStyle"; import { CoinIds } from "consts/coingecko"; import { useCoinPrice } from "hooks/useCoinPrice"; @@ -10,14 +10,28 @@ import { getFormattedRewards } from "utils/jurorRewardConfig"; import { useUserQuery } from "queries/useUser"; import WithHelpTooltip from "components/WithHelpTooltip"; - -import TokenRewards from "./TokenRewards"; +import TokenRewards from "../TokenRewards"; const Container = styled.div` display: flex; flex-direction: column; - align-items: flex-start; + align-items: center; width: auto; + gap: 12px; + + ${landscapeStyle( + () => css` + align-items: flex-start; + gap: 24px; + ` + )} +`; + +const TokenRewardsContainer = styled.div` + display: flex; + flex-direction: column; + align-items: start; + gap: 16px; `; const tooltipMsg = @@ -27,11 +41,11 @@ const tooltipMsg = "arbitration fees (ETH) + PNK redistribution between jurors."; interface IJurorRewards { - addressToQuery: `0x${string}`; + searchParamAddress: `0x${string}`; } -const JurorRewards: React.FC = ({ addressToQuery }) => { - const { data } = useUserQuery(addressToQuery); +const JurorRewards: React.FC = ({ searchParamAddress }) => { + const { data } = useUserQuery(searchParamAddress); const coinIds = [CoinIds.PNK, CoinIds.ETH]; const { prices: pricesData } = useCoinPrice(coinIds); @@ -42,9 +56,11 @@ const JurorRewards: React.FC = ({ addressToQuery }) => { - {formattedRewards.map(({ token, amount, value }) => ( - - ))} + + {formattedRewards.map(({ token, amount, value }) => ( + + ))} + ); }; diff --git a/web/src/pages/Profile/JurorInfo/PixelArt.tsx b/web/src/pages/Profile/JurorCard/BottomContent/PixelArt.tsx similarity index 93% rename from web/src/pages/Profile/JurorInfo/PixelArt.tsx rename to web/src/pages/Profile/JurorCard/BottomContent/PixelArt.tsx index 48e15dd93..59a909a82 100644 --- a/web/src/pages/Profile/JurorInfo/PixelArt.tsx +++ b/web/src/pages/Profile/JurorCard/BottomContent/PixelArt.tsx @@ -9,6 +9,11 @@ import platoImage from "assets/pngs/dashboard/plato.png"; import pythagorasImage from "assets/pngs/dashboard/pythagoras.png"; import socratesImage from "assets/pngs/dashboard/socrates.png"; +const Container = styled.div` + display: flex; + justify-content: center; +`; + interface IStyledImage { show: boolean; width: number | string; @@ -42,7 +47,7 @@ interface IPixelArt { const PixelArt: React.FC = ({ level, width, height }) => { const [imageLoaded, setImageLoaded] = useState(false); return ( -
+ {!imageLoaded && } = ({ level, width, height }) => { width={width} height={height} /> -
+ ); }; diff --git a/web/src/pages/Profile/JurorCard/BottomContent/index.tsx b/web/src/pages/Profile/JurorCard/BottomContent/index.tsx new file mode 100644 index 000000000..34749232b --- /dev/null +++ b/web/src/pages/Profile/JurorCard/BottomContent/index.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import { landscapeStyle } from "styles/landscapeStyle"; + +import { ILevelCriteria } from "utils/userLevelCalculation"; + +import PixelArt from "./PixelArt"; +import Coherence from "./Coherence"; +import JurorRewards from "./JurorRewards"; +import StakingRewards from "../StakingRewards"; + +const Container = styled.div` + display: flex; + flex-direction: column; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + + gap: 32px; + width: 100%; + height: auto; + + ${landscapeStyle( + () => css` + flex-direction: row; + align-items: flex-start; + ` + )} +`; + +const LeftContent = styled.div` + display: flex; + flex-direction: row; + gap: 48px; + flex-direction: column; + + ${landscapeStyle( + () => css` + flex-direction: row; + ` + )} +`; + +interface IBottomContent { + userLevelData: ILevelCriteria; + totalCoherentVotes: number; + totalResolvedVotes: number; + searchParamAddress: `0x${string}`; +} + +const BottomContent: React.FC = ({ + userLevelData, + totalCoherentVotes, + totalResolvedVotes, + searchParamAddress, +}) => { + return ( + + + + + + + + + ); +}; +export default BottomContent; diff --git a/web/src/pages/Profile/JurorInfo/Header.tsx b/web/src/pages/Profile/JurorCard/Header.tsx similarity index 70% rename from web/src/pages/Profile/JurorInfo/Header.tsx rename to web/src/pages/Profile/JurorCard/Header.tsx index 8e90df5b6..fed750f01 100644 --- a/web/src/pages/Profile/JurorInfo/Header.tsx +++ b/web/src/pages/Profile/JurorCard/Header.tsx @@ -1,17 +1,12 @@ -import React, { useMemo } from "react"; +import React from "react"; import styled from "styled-components"; import { responsiveSize } from "styles/responsiveSize"; import { useToggle } from "react-use"; -import { useSearchParams } from "react-router-dom"; -import { Copiable } from "@kleros/ui-components-library"; import XIcon from "svgs/socialmedia/x.svg"; -import { DEFAULT_CHAIN, getChain } from "consts/chains"; -import { shortenAddress } from "utils/shortenAddress"; - import HowItWorks from "components/HowItWorks"; import JurorLevels from "components/Popup/MiniGuides/JurorLevels"; import { ExternalLink } from "components/ExternalLink"; @@ -51,18 +46,12 @@ const StyledLink = styled(ExternalLink)` gap: 8px; `; -const StyledExternalLink = styled(ExternalLink)` - font-size: ${responsiveSize(18, 22)}; - margin-left: ${responsiveSize(4, 8)}; - font-weight: 600; -`; - interface IHeader { levelTitle: string; levelNumber: number; totalCoherentVotes: number; totalResolvedVotes: number; - addressToQuery: `0x${string}`; + searchParamAddress: `0x${string}`; } const Header: React.FC = ({ @@ -70,31 +59,17 @@ const Header: React.FC = ({ levelNumber, totalCoherentVotes, totalResolvedVotes, - addressToQuery, + searchParamAddress, }) => { const [isJurorLevelsMiniGuideOpen, toggleJurorLevelsMiniGuide] = useToggle(false); - const [searchParams] = useSearchParams(); - const coherencePercentage = parseFloat(((totalCoherentVotes / Math.max(totalResolvedVotes, 1)) * 100).toFixed(2)); const courtUrl = window.location.origin; const xPostText = `Hey I've been busy as a Juror on the Kleros court, check out my score: \n\nLevel: ${levelNumber} (${levelTitle})\nCoherence Percentage: ${coherencePercentage}%\nCoherent Votes: ${totalCoherentVotes}/${totalResolvedVotes}\n\nBe a juror with me! ➡️ ${courtUrl}`; const xShareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(xPostText)}`; - const searchParamAddress = searchParams.get("address")?.toLowerCase(); - - const addressExplorerLink = useMemo(() => { - return `${getChain(DEFAULT_CHAIN)?.blockExplorers?.default.url}/address/${addressToQuery}`; - }, [addressToQuery]); return ( - - Juror Profile - - - - {shortenAddress(addressToQuery)} - - - + Juror Profile { const tooltipMsg = "Staking Rewards are the rewards won by staking your PNK on a court during " + - "the Kleros' Jurors incentive program."; + "the Kleros' Jurors incentive program. This will start as soon as the " + + "corresponding KIP (Kleros Improvement Proposal) goes into effect."; -const Coherence: React.FC = () => { +const StakingRewards: React.FC = () => { return ( + // + // + // + // Coming soon + // + // + // + // - + - - + ); }; -export default Coherence; +export default StakingRewards; diff --git a/web/src/pages/Profile/JurorInfo/TokenRewards.tsx b/web/src/pages/Profile/JurorCard/TokenRewards.tsx similarity index 100% rename from web/src/pages/Profile/JurorInfo/TokenRewards.tsx rename to web/src/pages/Profile/JurorCard/TokenRewards.tsx diff --git a/web/src/pages/Profile/JurorCard/TopContent/index.tsx b/web/src/pages/Profile/JurorCard/TopContent/index.tsx new file mode 100644 index 000000000..d917ff537 --- /dev/null +++ b/web/src/pages/Profile/JurorCard/TopContent/index.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import styled from "styled-components"; + +import JurorLink from "components/JurorLink"; + +const Container = styled.div` + display: flex; + flex-direction: row + align-items: center; + gap: 16px 24px; + flex-wrap: wrap; +`; + +const StyledLabel = styled.label` + font-size: 14px; +`; + +interface ITopContent { + address: `0x${string}`; + totalResolvedDisputes: number; +} + +const TopContent: React.FC = ({ address, totalResolvedDisputes }) => { + return ( + + + {totalResolvedDisputes > 0 ? ( + + Juror in {totalResolvedDisputes} {totalResolvedDisputes === 1 ? "case" : "cases"} + + ) : null} + + ); +}; +export default TopContent; diff --git a/web/src/pages/Profile/JurorInfo/index.tsx b/web/src/pages/Profile/JurorCard/index.tsx similarity index 53% rename from web/src/pages/Profile/JurorInfo/index.tsx rename to web/src/pages/Profile/JurorCard/index.tsx index f85122738..2533fc09c 100644 --- a/web/src/pages/Profile/JurorInfo/index.tsx +++ b/web/src/pages/Profile/JurorCard/index.tsx @@ -1,5 +1,5 @@ import React from "react"; -import styled, { css } from "styled-components"; +import styled from "styled-components"; import { Card as _Card } from "@kleros/ui-components-library"; @@ -8,42 +8,30 @@ import { getCoherencePercent } from "utils/getCoherencePercent"; import { useUserQuery } from "queries/useUser"; -import { landscapeStyle } from "styles/landscapeStyle"; -import { responsiveSize } from "styles/responsiveSize"; - -import Coherence from "./Coherence"; import Header from "./Header"; -import JurorRewards from "./JurorRewards"; -import PixelArt from "./PixelArt"; +import BottomContent from "./BottomContent"; +import { Divider } from "components/Divider"; +import TopContent from "./TopContent"; const Container = styled.div``; const Card = styled(_Card)` display: flex; flex-direction: column; - align-items: center; justify-content: center; - gap: 40px; + gap: 24px; width: 100%; height: auto; - padding: 24px 0; - - ${landscapeStyle( - () => css` - flex-direction: row; - gap: ${responsiveSize(24, 64)}; - height: 236px; - ` - )} + padding: 24px; `; -interface IJurorInfo { - addressToQuery: `0x${string}`; +interface IJurorCard { + searchParamAddress: `0x${string}`; } -const JurorInfo: React.FC = ({ addressToQuery }) => { - const { data } = useUserQuery(addressToQuery); +const JurorCard: React.FC = ({ searchParamAddress }) => { + const { data } = useUserQuery(searchParamAddress); const totalCoherentVotes = data?.user ? parseInt(data?.user?.totalCoherentVotes) : 0; const totalResolvedVotes = data?.user ? parseInt(data?.user?.totalResolvedVotes) : 0; const totalResolvedDisputes = data?.user ? parseInt(data?.user?.totalResolvedDisputes) : 0; @@ -55,15 +43,15 @@ const JurorInfo: React.FC = ({ addressToQuery }) => {
- - - + + + ); }; -export default JurorInfo; +export default JurorCard; diff --git a/web/src/pages/Profile/Courts/CourtCard/CourtName.tsx b/web/src/pages/Profile/Stakes/CourtCard/CourtName.tsx similarity index 64% rename from web/src/pages/Profile/Courts/CourtCard/CourtName.tsx rename to web/src/pages/Profile/Stakes/CourtCard/CourtName.tsx index 6b53b480d..443503dc9 100644 --- a/web/src/pages/Profile/Courts/CourtCard/CourtName.tsx +++ b/web/src/pages/Profile/Stakes/CourtCard/CourtName.tsx @@ -3,17 +3,14 @@ import styled, { css } from "styled-components"; import { landscapeStyle } from "styles/landscapeStyle"; -import ArrowIcon from "svgs/icons/arrow.svg"; - -import { StyledArrowLink } from "components/StyledArrowLink"; - const Container = styled.div` display: flex; width: 100%; flex-direction: row; - gap: 16px; + gap: 8px 16px; align-items: center; justify-content: space-between; + flex-wrap: wrap; small { height: 100%; @@ -28,15 +25,6 @@ const Container = styled.div` )} `; -const ReStyledArrowLink = styled(StyledArrowLink)` - font-size: 14px; - - > svg { - height: 15px; - width: 15px; - } -`; - interface ICourtName { name: string; id: string; @@ -46,9 +34,6 @@ const CourtName: React.FC = ({ name, id }) => { return ( {name} - - Open Court - ); }; diff --git a/web/src/pages/Profile/Stakes/CourtCard/Stake.tsx b/web/src/pages/Profile/Stakes/CourtCard/Stake.tsx new file mode 100644 index 000000000..168d30ff5 --- /dev/null +++ b/web/src/pages/Profile/Stakes/CourtCard/Stake.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import styled from "styled-components"; + +import { formatUnits } from "viem"; + +import NumberDisplay from "components/NumberDisplay"; + +const StyledLabel = styled.label` + display: flex; + font-weight: 600; + color: ${({ theme }) => theme.primaryText}; + font-size: 16px; + align-items: center; + gap: 4px; +`; + +interface IStake { + stake: string; +} + +const Stake: React.FC = ({ stake }) => { + const formattedStake = formatUnits(stake, 18); + + return ( + + + + ); +}; +export default Stake; diff --git a/web/src/pages/Profile/Stakes/CourtCard/index.tsx b/web/src/pages/Profile/Stakes/CourtCard/index.tsx new file mode 100644 index 000000000..670a8ec92 --- /dev/null +++ b/web/src/pages/Profile/Stakes/CourtCard/index.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import { landscapeStyle } from "styles/landscapeStyle"; + +import { Card as _Card } from "@kleros/ui-components-library"; + +import ArrowIcon from "svgs/icons/arrow.svg"; +import NewTabIcon from "svgs/icons/new-tab.svg"; + +import { formatDate } from "utils/date"; +import { getTxnExplorerLink } from "utils/index"; + +import { StyledArrowLink } from "components/StyledArrowLink"; +import CourtName from "./CourtName"; +import Stake from "./Stake"; + +const Container = styled(_Card)<{ isCurrentStakeCard?: boolean }>` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + height: auto; + width: 100%; + padding: 20px 16px 24px; + border-left: 5px solid + ${({ theme, isCurrentStakeCard }) => (isCurrentStakeCard ? theme.secondaryPurple : theme.secondaryText)}; + flex-wrap: wrap; + gap: 16px; + + :hover { + cursor: auto; + } + + ${({ theme }) => (theme.name === "light" ? `box-shadow: 0px 2px 3px 0px ${theme.stroke};` : "")} + + ${landscapeStyle( + () => css` + padding: 21.5px 28px; + ` + )} +`; + +const LeftContent = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px 24px; +`; + +const StakeAndLinkAndDateContainer = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px; +`; + +const StakeAndLink = styled.div` + display: flex; + flex-direction: row; + gap: 8px; +`; + +const ReStyledArrowLink = styled(StyledArrowLink)` + font-size: 14px; + + > svg { + height: 15px; + width: 15px; + } +`; + +interface ICourtCard { + name: string; + stake: string; + id: string; + timestamp?: number; + transactionHash?: string; + isCurrentStakeCard?: boolean; +} + +const CourtCard: React.FC = ({ + name, + stake, + id, + timestamp, + transactionHash, + isCurrentStakeCard = true, +}) => { + return ( + + + + + + + {transactionHash ? ( + + + + ) : null} + + {timestamp ? : null} + + + + Open Court + + + ); +}; + +export default CourtCard; diff --git a/web/src/pages/Profile/Stakes/CurrentStakes/Header.tsx b/web/src/pages/Profile/Stakes/CurrentStakes/Header.tsx new file mode 100644 index 000000000..1184f59a3 --- /dev/null +++ b/web/src/pages/Profile/Stakes/CurrentStakes/Header.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import { landscapeStyle } from "styles/landscapeStyle"; +import { responsiveSize } from "styles/responsiveSize"; + +import { formatUnits } from "viem"; + +import PnkIcon from "svgs/icons/pnk.svg"; +import LockerIcon from "svgs/icons/locker.svg"; + +import { isUndefined } from "utils/index"; + +import NumberDisplay from "components/NumberDisplay"; + +const Container = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 100%; + gap: 4px 16px; + align-items: center; + margin-bottom: 20px; + + ${landscapeStyle( + () => css` + justify-content: space-between; + ` + )} +`; + +const StakedPnk = styled.div` + display: flex; + gap: 8px; + align-items: center; +`; + +const LockedPnk = styled.div` + display: flex; + gap: 8px; + align-items: center; +`; + +const StyledTitle = styled.h1` + margin-bottom: 0; + font-size: ${responsiveSize(20, 24)}; +`; + +const TotalStakeAndLockedPnk = styled.div` + display: flex; + flex-direction: row; + gap: 12px 24px; + flex-wrap: wrap; +`; + +const StyledPnkIcon = styled(PnkIcon)` + fill: ${({ theme }) => theme.secondaryPurple}; + width: 16px; +`; + +const StyledLockerIcon = styled(LockerIcon)` + fill: ${({ theme }) => theme.secondaryPurple}; + width: 14px; +`; + +interface IHeader { + totalStake: string; + lockedStake: string; +} + +const Header: React.FC = ({ totalStake, lockedStake }) => { + const formattedTotalStake = formatUnits(BigInt(totalStake), 18); + const formattedLockedStake = formatUnits(BigInt(lockedStake), 18); + + return ( + + Current Stakes + + {!isUndefined(totalStake) ? ( + + + + + + + + ) : null} + {!isUndefined(lockedStake) ? ( + + + + + + + + ) : null} + + + ); +}; +export default Header; diff --git a/web/src/pages/Profile/Stakes/CurrentStakes/index.tsx b/web/src/pages/Profile/Stakes/CurrentStakes/index.tsx new file mode 100644 index 000000000..4967c7818 --- /dev/null +++ b/web/src/pages/Profile/Stakes/CurrentStakes/index.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import styled from "styled-components"; + +import { responsiveSize } from "styles/responsiveSize"; + +import Skeleton from "react-loading-skeleton"; + +import { JurorStakeDetailsQuery } from "src/graphql/graphql"; + +import Header from "./Header"; +import CourtCard from "../CourtCard"; +import { CourtCardsContainer } from "../index"; + +const Container = styled.div` + display: flex; + flex-direction: column; + flex-wrap: wrap; +`; + +const NoCurrentStakesLabel = styled.label` + font-size: ${responsiveSize(14, 16)}; +`; + +interface ICurrentStakes { + totalStake: string; + lockedStake: string; + currentStakeData: JurorStakeDetailsQuery | undefined; + isCurrentStakeLoading: boolean; +} + +const CurrentStakes: React.FC = ({ + totalStake, + lockedStake, + currentStakeData, + isCurrentStakeLoading, +}) => { + const stakedCourts = currentStakeData?.jurorTokensPerCourts?.filter(({ staked }) => staked > 0); + const isStaked = stakedCourts && stakedCourts.length > 0; + + return ( + +
+ {!isStaked && !isCurrentStakeLoading ? ( + No stakes found + ) : isCurrentStakeLoading ? ( + + ) : null} + {isStaked && !isCurrentStakeLoading ? ( + + {currentStakeData?.jurorTokensPerCourts + ?.filter(({ staked }) => staked > 0) + .map(({ court: { id, name }, staked }) => ( + + ))} + + ) : null} + + ); +}; +export default CurrentStakes; diff --git a/web/src/pages/Profile/Stakes/StakingHistory.tsx b/web/src/pages/Profile/Stakes/StakingHistory.tsx new file mode 100644 index 000000000..e12d21bcf --- /dev/null +++ b/web/src/pages/Profile/Stakes/StakingHistory.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import styled from "styled-components"; +import { responsiveSize } from "styles/responsiveSize"; + +import Skeleton from "react-loading-skeleton"; + +import { useStakingHistory } from "queries/useStakingHistory"; +import { useCourtTree } from "queries/useCourtTree"; + +import { findCourtNameById } from "utils/findCourtNameById"; +import { StandardPagination } from "@kleros/ui-components-library"; + +import CourtCard from "./CourtCard"; +import { CourtCardsContainer } from "./index"; + +const Container = styled.div``; + +const StyledPagination = styled(StandardPagination)` + margin-top: 24px; + margin-left: auto; + margin-right: auto; +`; + +const StyledTitle = styled.h1` + font-size: ${responsiveSize(20, 24)}; + margin-bottom: 20px; +`; + +const NoHistoryLabel = styled.label` + font-size: ${responsiveSize(14, 16)}; +`; + +interface IStakingHistory { + searchParamAddress: `0x${string}`; + totalNumberStakingEvents: number; +} + +const StakingHistory: React.FC = ({ searchParamAddress, totalNumberStakingEvents }) => { + const { page } = useParams(); + const navigate = useNavigate(); + const eventsPerPage = 10; + const currentPage = parseInt(page ?? "1"); + const skip = (currentPage - 1) * eventsPerPage; + const { data: stakingHistoryData, isLoading: isLoadingStakingHistory } = useStakingHistory(eventsPerPage, skip); + const { data: courtTreeData, isLoading: isLoadingCourtTree } = useCourtTree(); + const stakingEvents = stakingHistoryData?.data?.userStakingEvents?.edges ?? []; + const totalPages = useMemo(() => Math.ceil(totalNumberStakingEvents / eventsPerPage), [totalNumberStakingEvents]); + + const handlePageChange = (newPage: number) => { + navigate(`/profile/stakes/${newPage}?address=${searchParamAddress}`); + }; + + return ( + + Staking History + + {!isLoadingStakingHistory && totalNumberStakingEvents === 0 ? ( + No history found + ) : isLoadingStakingHistory || isLoadingCourtTree ? ( + Array.from({ length: 10 }).map((_, index) => ) + ) : ( + <> + {stakingEvents.map(({ node, cursor }) => { + const courtName = findCourtNameById(courtTreeData, node.args._courtID); + return ( + + ); + })} + + + )} + + + ); +}; + +export default StakingHistory; diff --git a/web/src/pages/Profile/Stakes/index.tsx b/web/src/pages/Profile/Stakes/index.tsx new file mode 100644 index 000000000..9984792bc --- /dev/null +++ b/web/src/pages/Profile/Stakes/index.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import { landscapeStyle } from "styles/landscapeStyle"; +import { useJurorStakeDetailsQuery } from "queries/useJurorStakeDetailsQuery"; +import { useStakingHistory } from "queries/useStakingHistory"; + +import { responsiveSize } from "styles/responsiveSize"; + +import CurrentStakes from "./CurrentStakes"; +import StakingHistory from "./StakingHistory"; + +const Container = styled.div` + display: flex; + flex-direction: column; + margin-top: ${responsiveSize(24, 32)}; + gap: 32px; +`; + +export const CourtCardsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + z-index: 0; + width: 100%; + + ${landscapeStyle( + () => css` + gap: 8px; + ` + )} +`; + +interface IStakes { + searchParamAddress: `0x${string}`; +} + +const Stakes: React.FC = ({ searchParamAddress }) => { + const { data: currentStakeData, isLoading: isCurrentStakeLoading } = useJurorStakeDetailsQuery(searchParamAddress); + const { data: stakingHistoryData } = useStakingHistory(1, 0); + const totalStake = currentStakeData?.jurorTokensPerCourts?.[0]?.effectiveStake ?? "0"; + const lockedStake = currentStakeData?.jurorTokensPerCourts?.[0]?.locked ?? "0"; + const totalNumberStakingEvents = stakingHistoryData?.data?.userStakingEvents?.count ?? 0; + + return ( + + + + + ); +}; + +export default Stakes; diff --git a/web/src/pages/Profile/Votes/StatsAndFilters/Filters.tsx b/web/src/pages/Profile/Votes/StatsAndFilters/Filters.tsx new file mode 100644 index 000000000..f02abb785 --- /dev/null +++ b/web/src/pages/Profile/Votes/StatsAndFilters/Filters.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import styled, { useTheme } from "styled-components"; + +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; + +import { DropdownSelect } from "@kleros/ui-components-library"; + +import { decodeURIFilter, encodeURIFilter, useRootPath } from "utils/uri"; + +const Container = styled.div` + display: flex; + justify-content: end; + gap: 12px; + width: fit-content; +`; + +const Filters: React.FC = () => { + const theme = useTheme(); + const { order, filter } = useParams(); + const { ruled, period, ...filterObject } = decodeURIFilter(filter ?? "all"); + const navigate = useNavigate(); + const location = useRootPath(); + const [searchParams] = useSearchParams(); + + const handleStatusChange = (value: string | number) => { + const parsedValue = JSON.parse(value as string); + const encodedFilter = encodeURIFilter({ ...filterObject, ...parsedValue }); + navigate(`${location}/1/${order}/${encodedFilter}?${searchParams.toString()}`); + }; + + const handleOrderChange = (value: string | number) => { + const encodedFilter = encodeURIFilter({ ruled, period, ...filterObject }); + navigate(`${location}/1/${value}/${encodedFilter}?${searchParams.toString()}`); + }; + + return ( + + + + + ); +}; + +export default Filters; diff --git a/web/src/pages/Profile/Votes/StatsAndFilters/Stats.tsx b/web/src/pages/Profile/Votes/StatsAndFilters/Stats.tsx new file mode 100644 index 000000000..e64e292a5 --- /dev/null +++ b/web/src/pages/Profile/Votes/StatsAndFilters/Stats.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import styled from "styled-components"; + +const FieldWrapper = styled.div` + display: inline-flex; + gap: 8px; +`; + +const SeparatorLabel = styled.label` + margin: 0 8px; + color: ${({ theme }) => theme.primaryText}; +`; + +const StyledLabel = styled.label` + color: ${({ theme }) => theme.primaryText}; +`; + +const Field: React.FC<{ label: string; value: string }> = ({ label, value }) => ( + + {label} + {value} + +); + +const Separator: React.FC = () => |; + +export interface IStats { + totalVotes: number; + votesPending: number; + resolvedVotes: number; +} + +const Stats: React.FC = ({ totalVotes, votesPending, resolvedVotes }) => { + const casesInProgress = (totalVotes - resolvedVotes).toString(); + + const fields = [ + { label: "Total", value: totalVotes.toString() }, + { label: "Vote Pending", value: votesPending }, + { label: "Case In Progress", value: casesInProgress }, + { label: "Resolved", value: resolvedVotes.toString() }, + ]; + + return ( +
+ {fields.map(({ label, value }, i) => ( + + + {i + 1 < fields.length ? : null} + + ))} +
+ ); +}; + +export default Stats; diff --git a/web/src/pages/Profile/Votes/StatsAndFilters/index.tsx b/web/src/pages/Profile/Votes/StatsAndFilters/index.tsx new file mode 100644 index 000000000..970e4c05e --- /dev/null +++ b/web/src/pages/Profile/Votes/StatsAndFilters/index.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import styled from "styled-components"; + +import { responsiveSize } from "styles/responsiveSize"; + +import Filters from "./Filters"; +import Stats, { IStats } from "./Stats"; + +const Container = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: ${responsiveSize(4, 12)}; + justify-content: space-between; +`; + +const StatsAndFilters: React.FC = ({ totalVotes, votesPending, resolvedVotes }) => ( + + + + +); + +export default StatsAndFilters; diff --git a/web/src/pages/Profile/Votes/VoteCard/CaseNumber.tsx b/web/src/pages/Profile/Votes/VoteCard/CaseNumber.tsx new file mode 100644 index 000000000..c7f37e32e --- /dev/null +++ b/web/src/pages/Profile/Votes/VoteCard/CaseNumber.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import { landscapeStyle } from "styles/landscapeStyle"; + +import { InternalLink } from "components/InternalLink"; + +const Container = styled.div` + display: flex; + width: 100%; + flex-direction: row; + gap: 8px 16px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + + small { + height: 100%; + font-weight: 600; + } + + ${landscapeStyle( + () => css` + justify-content: flex-start; + width: auto; + ` + )} +`; + +const StyledInternalLink = styled(InternalLink)` + font-weight: 600; +`; + +interface ICaseNumber { + id: string; +} + +const CaseNumber: React.FC = ({ id }) => { + return ( + + Case {id} + + ); +}; +export default CaseNumber; diff --git a/web/src/pages/Profile/Votes/VoteCard/CaseStatus.tsx b/web/src/pages/Profile/Votes/VoteCard/CaseStatus.tsx new file mode 100644 index 000000000..c1290cddc --- /dev/null +++ b/web/src/pages/Profile/Votes/VoteCard/CaseStatus.tsx @@ -0,0 +1,62 @@ +import React, { useMemo } from "react"; +import styled, { css, useTheme } from "styled-components"; + +import { Periods } from "consts/periods"; + +import { getPeriodColors } from "components/DisputeView/PeriodBanner"; + +interface ICaseStatus {} + +const StyledLabel = styled.label<{ frontColor: string; withDot?: boolean }>` + display: flex; + align-items: center; + width: auto; + color: ${({ frontColor }) => frontColor}; + ${({ withDot, frontColor }) => + withDot + ? css` + ::before { + content: ""; + display: inline-block; + height: 8px; + width: 8px; + border-radius: 50%; + margin-right: 8px; + background-color: ${frontColor}; + flex-shrink: 0; + } + ` + : null} +`; + +const getPeriodLabel = (period: Periods): string => { + switch (period) { + case Periods.evidence: + return "In Progress"; + case Periods.commit: + return "In Progress"; + case Periods.vote: + return "Voting"; + case Periods.appeal: + return "Crowdfunding Appeal"; + case Periods.execution: + return "Closed"; + default: + return "In Progress"; + } +}; + +const CaseStatus: React.FC = ({}) => { + const theme = useTheme(); + const [frontColor, backgroundColor] = useMemo( + () => getPeriodColors(Periods.evidence, theme), + [theme, Periods.evidence] + ); + + return ( + + {getPeriodLabel(Periods.evidence)} + + ); +}; +export default CaseStatus; diff --git a/web/src/pages/Profile/Votes/VoteCard/CourtName.tsx b/web/src/pages/Profile/Votes/VoteCard/CourtName.tsx new file mode 100644 index 000000000..74715c136 --- /dev/null +++ b/web/src/pages/Profile/Votes/VoteCard/CourtName.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import { landscapeStyle } from "styles/landscapeStyle"; + +const Container = styled.div` + display: flex; + width: 100%; + flex-direction: row; + gap: 8px 16px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + + small { + height: 100%; + font-weight: 400; + } + + ${landscapeStyle( + () => css` + justify-content: flex-start; + width: auto; + ` + )} +`; + +interface ICourtName { + name: string; +} + +const CourtName: React.FC = ({ name }) => { + return ( + + {name} + + ); +}; +export default CourtName; diff --git a/web/src/pages/Profile/Votes/VoteCard/Round.tsx b/web/src/pages/Profile/Votes/VoteCard/Round.tsx new file mode 100644 index 000000000..7b0e4f40b --- /dev/null +++ b/web/src/pages/Profile/Votes/VoteCard/Round.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import styled from "styled-components"; + +import RoundIcon from "svgs/icons/round.svg"; + +const Container = styled.div` + display: flex; + gap: 8px; + + small { + font-weight: 400; + } +`; + +interface IRound { + number: string; +} + +const Round: React.FC = ({ number }) => { + return ( + + + Round {number} + + ); +}; +export default Round; diff --git a/web/src/pages/Profile/Votes/VoteCard/Vote.tsx b/web/src/pages/Profile/Votes/VoteCard/Vote.tsx new file mode 100644 index 000000000..60fa5c431 --- /dev/null +++ b/web/src/pages/Profile/Votes/VoteCard/Vote.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import styled from "styled-components"; + +import VotedIcon from "svgs/icons/voted-ballot.svg"; + +const Container = styled.div` + display: flex; + flex-direction: row; + gap: 8px; + + small { + font-weight: 400; + } +`; + +const StyledVotedIcon = styled(VotedIcon)` + path { + fill: ${({ theme }) => theme.primaryBlue}; + } +`; + +const BlueSmall = styled.small` + color: ${({ theme }) => theme.primaryBlue}; +`; + +interface IVote { + choice: string; +} + +const Vote: React.FC = ({ choice }) => { + return ( + + + Vote: + {choice} + + ); +}; +export default Vote; diff --git a/web/src/pages/Profile/Votes/VoteCard/index.tsx b/web/src/pages/Profile/Votes/VoteCard/index.tsx new file mode 100644 index 000000000..6761bee6c --- /dev/null +++ b/web/src/pages/Profile/Votes/VoteCard/index.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import { landscapeStyle } from "styles/landscapeStyle"; + +import { Card as _Card } from "@kleros/ui-components-library"; + +import ArrowIcon from "svgs/icons/arrow.svg"; + +import { StyledArrowLink } from "components/StyledArrowLink"; +import CourtName from "./CourtName"; +import CaseNumber from "./CaseNumber"; +import Vote from "./Vote"; +import Round from "./Round"; +import CaseStatus from "./CaseStatus"; + +const Container = styled(_Card)` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + height: auto; + width: 100%; + padding: 20px 16px 24px; + border-left: 5px solid ${({ theme }) => theme.secondaryPurple}; + flex-wrap: wrap; + gap: 16px; + + :hover { + cursor: auto; + } + + ${({ theme }) => (theme.name === "light" ? `box-shadow: 0px 2px 3px 0px ${theme.stroke};` : "")} + + ${landscapeStyle( + () => css` + padding: 21.5px 28px; + ` + )} +`; + +const LeftContent = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px 24px; +`; + +const ReStyledArrowLink = styled(StyledArrowLink)` + font-size: 14px; + + > svg { + height: 15px; + width: 15px; + } +`; + +interface IVoteCard {} + +const VoteCard: React.FC = ({}) => { + const courtName = "Technical Court"; + const caseId = "10"; + + return ( + + + + + + + + + + View vote + + + ); +}; + +export default VoteCard; diff --git a/web/src/pages/Profile/Votes/index.tsx b/web/src/pages/Profile/Votes/index.tsx new file mode 100644 index 000000000..890dbe4da --- /dev/null +++ b/web/src/pages/Profile/Votes/index.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import styled from "styled-components"; + +import { responsiveSize } from "styles/responsiveSize"; + +import { StandardPagination } from "@kleros/ui-components-library"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; + +import { useRootPath } from "utils/uri"; + +import StatsAndFilters from "./StatsAndFilters"; +import VoteCard from "./VoteCard"; + +const Container = styled.div` + display: flex; + flex-direction: column; + margin-top: ${responsiveSize(24, 32)}; + gap: 20px; +`; + +const StyledTitle = styled.h1` + margin-bottom: 0; + font-size: ${responsiveSize(20, 24)}; +`; + +const StyledPagination = styled(StandardPagination)` + margin-top: 24px; + margin-left: auto; + margin-right: auto; +`; + +const VotesCardContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +interface IVotes { + searchParamAddress: `0x${string}`; +} + +const Votes: React.FC = ({ searchParamAddress }) => { + const { page, order, filter } = useParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const votesPerPage = 5; + const location = useRootPath(); + const totalPages = 20; //TODO, HARDCODED FOR NOW + const currentPage = parseInt(page ?? "1"); + + const handlePageChange = (newPage: number) => { + navigate(`${location}/${newPage}/${order}/${filter}?${searchParams.toString()}`); + }; + + return ( + + Votes + + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + + + + ); +}; + +export default Votes; diff --git a/web/src/pages/Profile/index.tsx b/web/src/pages/Profile/index.tsx index 8a329a719..c9ba9b115 100644 --- a/web/src/pages/Profile/index.tsx +++ b/web/src/pages/Profile/index.tsx @@ -1,24 +1,22 @@ -import React, { useMemo } from "react"; - +import React, { useEffect } from "react"; +import { Routes, Route, useNavigate, useSearchParams, useLocation, Navigate } from "react-router-dom"; +import { useAccount } from "wagmi"; import styled, { css } from "styled-components"; import { MAX_WIDTH_LANDSCAPE, landscapeStyle } from "styles/landscapeStyle"; import { responsiveSize } from "styles/responsiveSize"; +import { Tabs as TabsComponent } from "@kleros/ui-components-library"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { useAccount } from "wagmi"; - -import { isUndefined } from "utils/index"; -import { decodeURIFilter, useRootPath } from "utils/uri"; -import { DisputeDetailsFragment, useMyCasesQuery } from "queries/useCasesQuery"; -import { useUserQuery } from "queries/useUser"; -import { OrderDirection } from "src/graphql/graphql"; +import PnkIcon from "svgs/icons/pnk.svg"; +import DocIcon from "svgs/icons/doc.svg"; +import VotedIcon from "svgs/icons/voted-ballot.svg"; -import CasesDisplay from "components/CasesDisplay"; import ConnectWallet from "components/ConnectWallet"; import FavoriteCases from "components/FavoriteCases"; import ScrollTop from "components/ScrollTop"; -import Courts from "./Courts"; -import JurorInfo from "./JurorInfo"; +import JurorCard from "./JurorCard"; +import Stakes from "./Stakes"; +import Cases from "./Cases"; +import Votes from "./Votes"; const Container = styled.div` width: 100%; @@ -34,11 +32,16 @@ const Container = styled.div` )} `; -const StyledCasesDisplay = styled(CasesDisplay)` - margin-top: ${responsiveSize(24, 48)}; - - .title { - margin-bottom: ${responsiveSize(12, 24)}; +const StyledTabs = styled(TabsComponent)` + width: 100%; + margin-top: ${responsiveSize(16, 32)}; + > * { + display: flex; + flex-wrap: wrap; + font-size: ${responsiveSize(14, 16)}; + > svg { + margin-right: 8px !important; + } } `; @@ -50,58 +53,68 @@ const ConnectWalletContainer = styled.div` color: ${({ theme }) => theme.primaryText}; `; +const TABS = [ + { text: "Stakes", value: 0, Icon: PnkIcon, path: "stakes/1" }, + { text: "Cases", value: 1, Icon: DocIcon, path: "cases/1/desc/all" }, + { text: "Votes", value: 2, Icon: VotedIcon, path: "votes/1/desc/all" }, +]; + +const getTabIndex = (currentPath: string) => { + return TABS.findIndex((tab) => currentPath.includes(tab.path.split("/")[0])); +}; + const Profile: React.FC = () => { const { isConnected, address: connectedAddress } = useAccount(); - const { page, order, filter } = useParams(); const [searchParams] = useSearchParams(); - const location = useRootPath(); + const { pathname } = useLocation(); const navigate = useNavigate(); const searchParamAddress = searchParams.get("address")?.toLowerCase(); - const addressToQuery = searchParamAddress || connectedAddress?.toLowerCase(); - const casesPerPage = 3; - const pageNumber = parseInt(page ?? "1"); - const disputeSkip = casesPerPage * (pageNumber - 1); - const decodedFilter = decodeURIFilter(filter ?? "all"); - const { data: disputesData } = useMyCasesQuery( - addressToQuery, - disputeSkip, - decodedFilter, - order === "asc" ? OrderDirection.Asc : OrderDirection.Desc - ); - const { data: userData } = useUserQuery(addressToQuery, decodedFilter); - const totalCases = userData?.user?.disputes.length; - const totalResolvedCases = parseInt(userData?.user?.totalResolvedDisputes); - const totalPages = useMemo( - () => (!isUndefined(totalCases) ? Math.ceil(totalCases / casesPerPage) : 1), - [totalCases, casesPerPage] - ); + + useEffect(() => { + if (isConnected && !searchParamAddress && connectedAddress) { + navigate(`${pathname}?address=${connectedAddress.toLowerCase()}`, { replace: true }); + } + }, [isConnected, searchParamAddress, connectedAddress, pathname, navigate]); + + const handleTabChange = (tabIndex: number) => { + const selectedTab = TABS[tabIndex]; + const basePath = `/profile/${selectedTab.path}`; + const queryParam = searchParamAddress ? `?address=${searchParamAddress}` : ""; + navigate(`${basePath}${queryParam}`); + }; return ( - {isConnected || searchParamAddress ? ( + {searchParamAddress ? ( <> - - - - navigate(`${location}/${newPage}/${order}/${filter}?${searchParams.toString()}`) - } - {...{ casesPerPage }} + + handleTabChange(tabIndex)} /> + + } /> + } /> + } /> + + } + /> + - ) : ( + ) : !isConnected ? ( To see your profile, connect first
- )} + ) : null}
diff --git a/web/src/utils/findCourtNameById.ts b/web/src/utils/findCourtNameById.ts new file mode 100644 index 000000000..b5cbadfdd --- /dev/null +++ b/web/src/utils/findCourtNameById.ts @@ -0,0 +1,14 @@ +import { CourtTreeQuery } from "src/graphql/graphql"; + +export const findCourtNameById = (courtTreeData: CourtTreeQuery, courtId: string) => { + const traverse = (court: CourtTreeQuery["court"]) => { + if (court.id === courtId) return court.name; + for (const child of court.children) { + const found = traverse(child); + if (found) return found; + } + return null; + }; + + return traverse(courtTreeData.court) ?? undefined; +}; diff --git a/web/src/utils/userLevelCalculation.ts b/web/src/utils/userLevelCalculation.ts index 52d504256..60dc56b02 100644 --- a/web/src/utils/userLevelCalculation.ts +++ b/web/src/utils/userLevelCalculation.ts @@ -1,4 +1,4 @@ -interface ILevelCriteria { +export interface ILevelCriteria { level: number; title: string; minDisputes: number;