= ({ 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;