diff --git a/web/src/app.tsx b/web/src/app.tsx index a1f35108d..97c05cfd8 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -14,6 +14,8 @@ import Cases from "./pages/Cases"; import Dashboard from "./pages/Dashboard"; import Courts from "./pages/Courts"; import DisputeTemplateView from "./pages/DisputeTemplateView"; +import DisputeResolver from "./pages/Resolver"; +import { NewDisputeProvider } from "./context/NewDisputeContext"; const App: React.FC = () => { return ( @@ -22,16 +24,19 @@ const App: React.FC = () => { - - }> - } /> - } /> - } /> - } /> - } /> - Justice not found here ¯\_( ͡° ͜ʖ ͡°)_/¯} /> - - + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + Justice not found here ¯\_( ͡° ͜ʖ ͡°)_/¯} /> + + + diff --git a/web/src/assets/svgs/icons/dispute.svg b/web/src/assets/svgs/icons/dispute.svg new file mode 100644 index 000000000..e3622bf1d --- /dev/null +++ b/web/src/assets/svgs/icons/dispute.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/src/assets/svgs/icons/ellipse.svg b/web/src/assets/svgs/icons/ellipse.svg new file mode 100644 index 000000000..05629b571 --- /dev/null +++ b/web/src/assets/svgs/icons/ellipse.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/assets/svgs/icons/minus.svg b/web/src/assets/svgs/icons/minus.svg new file mode 100644 index 000000000..76b4d2a91 --- /dev/null +++ b/web/src/assets/svgs/icons/minus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/src/assets/svgs/icons/plus.svg b/web/src/assets/svgs/icons/plus.svg new file mode 100644 index 000000000..7a1ce9bc3 --- /dev/null +++ b/web/src/assets/svgs/icons/plus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/src/components/ConnectWallet/AccountDisplay.tsx b/web/src/components/ConnectWallet/AccountDisplay.tsx index 1d677637e..80b5e43d6 100644 --- a/web/src/components/ConnectWallet/AccountDisplay.tsx +++ b/web/src/components/ConnectWallet/AccountDisplay.tsx @@ -4,6 +4,7 @@ import { landscapeStyle } from "styles/landscapeStyle"; import { useAccount, useNetwork, useEnsAvatar, useEnsName } from "wagmi"; import Identicon from "react-identicons"; import { shortenAddress } from "utils/shortenAddress"; +import { isAddress } from "viem"; const Container = styled.div` display: flex; @@ -134,7 +135,7 @@ export const AddressOrName: React.FC = ({ address: propAddress } chainId: 1, }); - return ; + return ; }; export const ChainDisplay: React.FC = () => { diff --git a/web/src/components/DisputePreview/Alias.tsx b/web/src/components/DisputePreview/Alias.tsx new file mode 100644 index 000000000..53355aa4f --- /dev/null +++ b/web/src/components/DisputePreview/Alias.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import styled from "styled-components"; +import { AddressOrName, IdenticonOrAvatar } from "../ConnectWallet/AccountDisplay"; +import { Alias } from "context/NewDisputeContext"; +import { isUndefined } from "utils/index"; +import { useEnsAddress } from "wagmi"; +import { isAddress } from "viem"; +import Skeleton from "react-loading-skeleton"; + +const AliasContainer = styled.div` + min-height: 32px; + display: flex; + gap: 8px; + align-items: center; +`; + +const TextContainer = styled.div` + display: flex; + > label { + color: ${({ theme }) => theme.primaryText}; + font-size: 14px; + } +`; + +interface IAlias { + alias: Alias; +} + +const AliasDisplay: React.FC = ({ alias }) => { + const { data: addressFromENS, isLoading } = useEnsAddress({ + enabled: !isAddress(alias.address), // if alias.address is not an Address, we treat it as ENS and try to fetch address from there + name: alias.address, + chainId: 1, + }); + + // try fetching ens name, else go with address + const address = addressFromENS ?? alias.address; + + return ( + + {isLoading ? : } + + {isLoading ? : }  + {!isUndefined(alias.name) && alias.name !== "" ? : null} + + + ); +}; + +export default AliasDisplay; diff --git a/web/src/pages/Cases/CaseDetails/Overview/DisputeContext.tsx b/web/src/components/DisputePreview/DisputeContext.tsx similarity index 63% rename from web/src/pages/Cases/CaseDetails/Overview/DisputeContext.tsx rename to web/src/components/DisputePreview/DisputeContext.tsx index f1367755c..65d0c1217 100644 --- a/web/src/pages/Cases/CaseDetails/Overview/DisputeContext.tsx +++ b/web/src/components/DisputePreview/DisputeContext.tsx @@ -3,6 +3,9 @@ import ReactMarkdown from "components/ReactMarkdown"; import styled from "styled-components"; import { StyledSkeleton } from "components/StyledSkeleton"; import { isUndefined } from "utils/index"; +import { Answer as IAnswer, IDisputeTemplate } from "context/NewDisputeContext"; +import AliasDisplay from "./Alias"; +import { responsiveSize } from "styles/responsiveSize"; const StyledH1 = styled.h1` margin: 0; @@ -11,6 +14,10 @@ const StyledH1 = styled.h1` const QuestionAndDescription = styled.div` display: flex; flex-direction: column; + div:first-child p:first-of-type { + font-size: 16px; + font-weight: 600; + } `; const StyledReactMarkDown = styled(ReactMarkdown)` @@ -31,32 +38,24 @@ const AnswersContainer = styled.div` const Answer = styled.div` margin: 0px; display: flex; - gap: 8px; + flex-wrap: wrap; + gap: ${responsiveSize(2, 8)}; `; -interface IAnswer { - id?: string; - title: string; - description?: string; - reserved?: boolean; -} - -interface IDisputeTemplate { - answers: IAnswer[]; - arbitrableAddress: string; - arbitrableChainID: string; - arbitratorAddress: string; - arbitratorChainID: string; - category?: string; - description: string; - frontendUrl?: string; - lang?: string; - policyURI?: string; - question: string; - specification?: string; - title: string; -} +const AliasesContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${responsiveSize(8, 20)}; +`; +const Divider = styled.hr` + width: 100%; + display: flex; + border: none; + height: 1px; + background-color: ${({ theme }) => theme.stroke}; + margin: 0; +`; interface IDisputeContext { disputeTemplate: IDisputeTemplate; } @@ -86,13 +85,27 @@ export const DisputeContext: React.FC = ({ disputeTemplate }) = {isUndefined(disputeTemplate) ? null :

Voting Options

} {disputeTemplate?.answers?.map((answer: IAnswer, i: number) => ( - + Option {i + 1}: - + ))} + + {isUndefined(disputeTemplate?.aliases) ? null : ( + <> + + + {disputeTemplate.aliases.map((alias) => ( + + ))} + + + )} ); }; diff --git a/web/src/pages/Cases/CaseDetails/Overview/Policies.tsx b/web/src/components/DisputePreview/Policies.tsx similarity index 100% rename from web/src/pages/Cases/CaseDetails/Overview/Policies.tsx rename to web/src/components/DisputePreview/Policies.tsx diff --git a/web/src/pages/Home/HeroImage.tsx b/web/src/components/HeroImage.tsx similarity index 100% rename from web/src/pages/Home/HeroImage.tsx rename to web/src/components/HeroImage.tsx diff --git a/web/src/components/LabeledInput.tsx b/web/src/components/LabeledInput.tsx new file mode 100644 index 000000000..b7fb5958c --- /dev/null +++ b/web/src/components/LabeledInput.tsx @@ -0,0 +1,36 @@ +import { Field, FieldProps } from "@kleros/ui-components-library"; +import React from "react"; +import styled from "styled-components"; +import { isUndefined } from "utils/index"; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; +`; +const StyledField = styled(Field)` + width: 100%; + > small { + margin-top: 16px; + margin-bottom: 16px; + } +`; + +const StyledLabel = styled.label` + width: 100%; + margin-bottom: 12px; +`; + +interface ILabeledInput extends FieldProps { + label?: string; +} +const LabeledInput: React.FC = (props) => { + return ( + + {!isUndefined(props.label) ? {props.label} : null} + + + ); +}; + +export default LabeledInput; diff --git a/web/src/components/PlusMinusField.tsx b/web/src/components/PlusMinusField.tsx new file mode 100644 index 000000000..7ef2a679c --- /dev/null +++ b/web/src/components/PlusMinusField.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import styled, { css } from "styled-components"; +import Ellipse from "assets/svgs/icons/ellipse.svg"; +import Plus from "assets/svgs/icons/plus.svg"; +import Minus from "assets/svgs/icons/minus.svg"; + +const Container = styled.div` + display: flex; + gap: 8px; + margin: 32px 0px 48px; +`; + +const IconContainer = styled.button` + position: relative; + padding: 0; + border-radius: 50%; + border: none; + background-color: transparent; + cursor: pointer; +`; + +const StyledEllipseIcon = styled(Ellipse)<{ isDisabled?: boolean }>` + circle { + ${({ isDisabled }) => + isDisabled && + css` + fill-opacity: 0.12; + `}; + } +`; + +const Icon = styled.svg` + fill: ${({ theme }) => theme.white}; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +`; + +interface IPlusMinusField { + currentValue: number; + updateValue: (currentValue: number) => void; + minValue?: number; + className?: string; +} +const PlusMinusField: React.FC = ({ currentValue, updateValue, minValue = 0, className }) => { + const incrementValue = () => updateValue(++currentValue); + const decrementValue = () => currentValue > minValue && updateValue(--currentValue); + return ( + + + + + + + + + + + ); +}; + +export default PlusMinusField; diff --git a/web/src/components/Popup/Description/DisputeCreated.tsx b/web/src/components/Popup/Description/DisputeCreated.tsx new file mode 100644 index 000000000..22d4a10d7 --- /dev/null +++ b/web/src/components/Popup/Description/DisputeCreated.tsx @@ -0,0 +1,65 @@ +import React, { useMemo } from "react"; +import Skeleton from "react-loading-skeleton"; +import styled from "styled-components"; +import { responsiveSize } from "styles/responsiveSize"; +import { isUndefined } from "utils/index"; +import { formatDate, getCurrentTime } from "utils/date"; +import { useCourtDetails } from "hooks/queries/useCourtDetails"; + +const Container = styled.div` + display: flex; + flex-direction: column; + margin-bottom: 24px; +`; + +const StyledTitle = styled.div` + margin-left: ${responsiveSize(8, 44, 300)}; + margin-right: ${responsiveSize(8, 44, 300)}; + color: ${({ theme }) => theme.secondaryText}; + text-align: center; +`; + +const StyledDateContainer = styled.span` + color: ${({ theme }) => theme.primaryText}; +`; + +const StyledSubtitle = styled(StyledTitle)` + margin-top: 24px; + color: ${({ theme }) => theme.primaryText}; +`; +interface IDisputeCreated { + courtId: string; +} + +const DisputeCreated: React.FC = ({ courtId }) => { + const { data: courtDetails } = useCourtDetails(courtId); + + const date = useMemo( + () => + !isUndefined(courtDetails?.court?.timesPerPeriod) + ? calculateMinResolveTime(courtDetails?.court.timesPerPeriod) + : undefined, + [courtDetails] + ); + + return ( + + + 🎉 Your case was successfully submitted to Kleros. A pool of jurors will be drawn to evaluate the case and vote + at most{" "} + {isUndefined(date) ? ( + + ) : ( + {formatDate(date)} + )} + . 🎉 + + Now, it’s time to submit evidence to support the case. + + ); +}; + +const calculateMinResolveTime = (timesPerPeriod: string[]) => + timesPerPeriod.reduce((acc, val) => acc + parseInt(val), 0) + getCurrentTime(); + +export default DisputeCreated; diff --git a/web/src/components/Popup/ExtraInfo/DisputeCreatedExtraInfo.tsx b/web/src/components/Popup/ExtraInfo/DisputeCreatedExtraInfo.tsx new file mode 100644 index 000000000..c9f154be4 --- /dev/null +++ b/web/src/components/Popup/ExtraInfo/DisputeCreatedExtraInfo.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import styled from "styled-components"; +import { responsiveSize } from "styles/responsiveSize"; + +const Container = styled.div` + display: flex; + color: ${({ theme }) => theme.secondaryText}; + text-align: center; + margin-top: ${responsiveSize(8, 24, 300)}; + margin-right: ${responsiveSize(8, 44, 300)}; + margin-left: ${responsiveSize(8, 44, 300)}; +`; + +const DisputeCreatedExtraInfo: React.FC = () => { + return ( + + { + "In order to better track the case progress, we recommend you to subscribe to notifications: Settings > Notifications" + } + + ); +}; +export default DisputeCreatedExtraInfo; diff --git a/web/src/components/Popup/index.tsx b/web/src/components/Popup/index.tsx index e1b285230..ea19f7007 100644 --- a/web/src/components/Popup/index.tsx +++ b/web/src/components/Popup/index.tsx @@ -10,6 +10,9 @@ import Appeal from "./Description/Appeal"; import VoteWithCommitExtraInfo from "./ExtraInfo/VoteWithCommitExtraInfo"; import StakeWithdrawExtraInfo from "./ExtraInfo/StakeWithdrawExtraInfo"; import { responsiveSize } from "styles/responsiveSize"; +import DisputeCreated from "./Description/DisputeCreated"; +import DisputeCreatedExtraInfo from "./ExtraInfo/DisputeCreatedExtraInfo"; +import { useNavigate } from "react-router-dom"; const Header = styled.h1` display: flex; @@ -92,6 +95,7 @@ export enum PopupType { APPEAL = "APPEAL", VOTE_WITHOUT_COMMIT = "VOTE_WITHOUT_COMMIT", VOTE_WITH_COMMIT = "VOTE_WITH_COMMIT", + DISPUTE_CREATED = "DISPUTE_CREATED", } interface IStakeWithdraw { @@ -117,6 +121,11 @@ interface IAppeal { amount: string; option: string; } +interface IDisputeCreated { + popupType: PopupType.DISPUTE_CREATED; + disputeId: number; + courtId: string; +} interface IPopup { title: string; icon: React.FC>; @@ -126,7 +135,7 @@ interface IPopup { isCommit?: boolean; } -type PopupProps = IStakeWithdraw | IVoteWithoutCommit | IVoteWithCommit | IAppeal; +type PopupProps = IStakeWithdraw | IVoteWithoutCommit | IVoteWithCommit | IAppeal | IDisputeCreated; const Popup: React.FC = ({ title, @@ -138,6 +147,7 @@ const Popup: React.FC = ({ ...props }) => { const containerRef = useRef(null); + const navigate = useNavigate(); const resetValue = () => { if (setAmount) { @@ -178,6 +188,11 @@ const Popup: React.FC = ({ PopupComponent = ; break; } + case PopupType.DISPUTE_CREATED: { + const { courtId } = props as IDisputeCreated; + PopupComponent = ; + break; + } default: break; } @@ -193,12 +208,17 @@ const Popup: React.FC = ({ {popupType === PopupType.STAKE_WITHDRAW && } {popupType === PopupType.VOTE_WITH_COMMIT && } + {popupType === PopupType.DISPUTE_CREATED && } { setIsOpen(false); resetValue(); + if (popupType === PopupType.DISPUTE_CREATED) { + const { disputeId } = props as IDisputeCreated; + navigate(`/cases/${disputeId}`); + } }} /> diff --git a/web/src/context/NewDisputeContext.tsx b/web/src/context/NewDisputeContext.tsx new file mode 100644 index 000000000..9d69bbeb9 --- /dev/null +++ b/web/src/context/NewDisputeContext.tsx @@ -0,0 +1,120 @@ +import React, { createContext, useState, useContext, useMemo } from "react"; +import { isUndefined } from "utils/index"; +import { Address } from "viem"; +import { useLocalStorage } from "hooks/useLocalStorage"; + +export type Answer = { + id?: string; + title: string; + description?: string; + reserved?: boolean; +}; + +export type Alias = { + id?: string; + name: string; + address: string | Address; +}; + +export interface IDisputeTemplate { + answers: Answer[]; + aliases?: Alias[]; + arbitrableAddress?: string; + arbitrableChainID?: string; + arbitratorAddress?: string; + arbitratorChainID?: string; + category?: string; + description: string; + frontendUrl?: string; + lang?: string; + policyURI?: string; + question: string; + specification?: string; + title: string; +} + +interface IDisputeData extends IDisputeTemplate { + courtId?: string; + numberOfJurors: number; + arbitrationCost?: string; +} + +interface INewDisputeContext { + disputeData: IDisputeData; + setDisputeData: (disputeData: IDisputeData) => void; + disputeTemplate: IDisputeTemplate; + resetDisputeData: () => void; + isSubmittingCase: boolean; + setIsSubmittingCase: (isSubmittingCase: boolean) => void; + isPolicyUploading: boolean; + setIsPolicyUploading: (isPolicyUploading: boolean) => void; +} + +const initialDisputeData: IDisputeData = { + numberOfJurors: 3, + title: "", + description: "", + question: "", + answers: [ + { title: "", id: "1" }, + { title: "", id: "2" }, + ], + aliases: [ + { name: "", address: "", id: "1" }, + { name: "", address: "", id: "2" }, + ], +}; +const initialDisputeTemplate = initialDisputeData as IDisputeTemplate; + +const NewDisputeContext = createContext({ + disputeData: initialDisputeData, + setDisputeData: () => {}, + disputeTemplate: initialDisputeTemplate, + resetDisputeData: () => {}, + isSubmittingCase: false, + setIsSubmittingCase: () => {}, + isPolicyUploading: false, + setIsPolicyUploading: () => {}, +}); + +export const useNewDisputeContext = () => useContext(NewDisputeContext); + +export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [disputeData, setDisputeData] = useLocalStorage("disputeData", initialDisputeData); + const [isSubmittingCase, setIsSubmittingCase] = useState(false); + const [isPolicyUploading, setIsPolicyUploading] = useState(false); + + const disputeTemplate = useMemo(() => constructDisputeTemplate(disputeData), [disputeData]); + + const resetDisputeData = () => { + setDisputeData(initialDisputeData); + }; + + const contextValues = useMemo( + () => ({ + disputeData, + setDisputeData, + disputeTemplate, + resetDisputeData, + isSubmittingCase, + setIsSubmittingCase, + isPolicyUploading, + setIsPolicyUploading, + }), + [disputeData, disputeTemplate, resetDisputeData, isSubmittingCase, isPolicyUploading] + ); + + return {children}; +}; + +const constructDisputeTemplate = (disputeData: IDisputeData) => { + const baseTemplate = { ...disputeData } as IDisputeTemplate; + + if (!isUndefined(baseTemplate.aliases)) { + baseTemplate.aliases = baseTemplate.aliases.filter((item) => item.name !== "" && item.address !== ""); + if (baseTemplate.aliases.length === 0) delete baseTemplate.aliases; + } + if (!isUndefined(baseTemplate.policyURI) && baseTemplate.policyURI === "") delete baseTemplate.policyURI; + + return baseTemplate; +}; diff --git a/web/src/hooks/queries/useCourtDetails.ts b/web/src/hooks/queries/useCourtDetails.ts index 14c708e34..0cbf5546c 100644 --- a/web/src/hooks/queries/useCourtDetails.ts +++ b/web/src/hooks/queries/useCourtDetails.ts @@ -17,6 +17,7 @@ const courtDetailsQuery = graphql(` stake paidETH paidPNK + timesPerPeriod } } `); diff --git a/web/src/layout/Header/navbar/DappList.tsx b/web/src/layout/Header/navbar/DappList.tsx index eead0d5dd..bc584d5cd 100644 --- a/web/src/layout/Header/navbar/DappList.tsx +++ b/web/src/layout/Header/navbar/DappList.tsx @@ -106,7 +106,8 @@ const ITEMS = [ { text: "Resolver", Icon: Resolver, - url: "https://resolve.kleros.io", + url: "#/resolver", + isNewTab: false, }, { text: "Linguo", diff --git a/web/src/layout/Header/navbar/Product.tsx b/web/src/layout/Header/navbar/Product.tsx index a4ad9e43e..dc25e3ed4 100644 --- a/web/src/layout/Header/navbar/Product.tsx +++ b/web/src/layout/Header/navbar/Product.tsx @@ -38,11 +38,12 @@ interface IProduct { text: string; url: string; Icon: React.FC> | string; + isNewTab?: boolean; } -const Product: React.FC = ({ text, url, Icon }) => { +const Product: React.FC = ({ text, url, Icon, isNewTab = true }) => { return ( - + {typeof Icon === "string" ? : } {text} diff --git a/web/src/pages/Cases/CaseDetails/Overview/index.tsx b/web/src/pages/Cases/CaseDetails/Overview/index.tsx index 84f22374b..bfa74cbf8 100644 --- a/web/src/pages/Cases/CaseDetails/Overview/index.tsx +++ b/web/src/pages/Cases/CaseDetails/Overview/index.tsx @@ -11,8 +11,8 @@ import DisputeInfo from "components/DisputeCard/DisputeInfo"; import Verdict from "components/Verdict/index"; import { useVotingHistory } from "hooks/queries/useVotingHistory"; import { getLocalRounds } from "utils/getLocalRounds"; -import { DisputeContext } from "./DisputeContext"; -import { Policies } from "./Policies"; +import { DisputeContext } from "components/DisputePreview/DisputeContext"; +import { Policies } from "components/DisputePreview/Policies"; import { responsiveSize } from "styles/responsiveSize"; const Container = styled.div` diff --git a/web/src/pages/Cases/CaseDetails/Voting/index.tsx b/web/src/pages/Cases/CaseDetails/Voting/index.tsx index 087ad4199..813c27f48 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/index.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/index.tsx @@ -17,6 +17,7 @@ import { useDisputeKitClassicIsVoteActive } from "hooks/contracts/generated"; import VoteIcon from "assets/svgs/icons/voted.svg"; import InfoCircle from "tsx:svgs/icons/info-circle.svg"; import { responsiveSize } from "styles/responsiveSize"; +import { formatDate } from "utils/date"; const Container = styled.div` padding: ${responsiveSize(16, 32)}; @@ -35,12 +36,6 @@ const InfoContainer = styled.div` } `; -function formatDate(unixTimestamp: number): string { - const date = new Date(unixTimestamp * 1000); - const options: Intl.DateTimeFormatOptions = { month: "long", day: "2-digit", year: "numeric" }; - return date.toLocaleDateString("en-US", options); -} - interface IVoting { arbitrable?: `0x${string}`; currentPeriodIndex?: number; diff --git a/web/src/pages/Home/CourtOverview/Header.tsx b/web/src/pages/Home/CourtOverview/Header.tsx new file mode 100644 index 000000000..dddc02b68 --- /dev/null +++ b/web/src/pages/Home/CourtOverview/Header.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import styled from "styled-components"; +import { Button } from "@kleros/ui-components-library"; +import Bookmark from "svgs/icons/bookmark.svg"; +import { useNavigate } from "react-router-dom"; +import { responsiveSize } from "styles/responsiveSize"; + +const StyledHeader = styled.div` + display: flex; + justify-content: space-between; +`; + +const StyledH1 = styled.h1` + font-size: ${responsiveSize(21, 24)}; +`; + +const Header: React.FC = () => { + const navigate = useNavigate(); + return ( + + Court Overview +