diff --git a/package.json b/package.json index 824bce4..e62a190 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@tanstack/react-query": "^5.76.1", "alchemy-sdk": "^3.6.1", "react": "^18.2.55", + "react-countdown": "^2.3.6", "react-dom": "^18.2.55", "react-router": "^7.6.0", "styled-components": "^6.1.18", diff --git a/src/assets/close.svg b/src/assets/close.svg new file mode 100644 index 0000000..78cbdbb --- /dev/null +++ b/src/assets/close.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/warning-circle-outline.svg b/src/assets/warning-circle-outline.svg new file mode 100644 index 0000000..8aad069 --- /dev/null +++ b/src/assets/warning-circle-outline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/Transactions/TransactionCard/TransactionCard.tsx b/src/components/Transactions/TransactionCard/TransactionCard.tsx index aa74d60..abd03ae 100644 --- a/src/components/Transactions/TransactionCard/TransactionCard.tsx +++ b/src/components/Transactions/TransactionCard/TransactionCard.tsx @@ -63,6 +63,18 @@ const AmountTag = styled(Tag)` pointer-events: none; text-transform: capitalize; font-weight: bold; + max-width: 50%; + + p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + @media (max-width: ${({ theme }) => theme.breakpoints.xs}) { + width: 100%; + max-width: 100%; + } `; interface Props { @@ -80,6 +92,7 @@ export default function TransactionCard({ transaction }: Props) { active status={transaction.formattedStatus} text={transaction.formattedStatus} + forceMaxWidth /> theme.breakpoints.xs}) { + gap: 1rem; + } `; interface Props { @@ -23,7 +33,8 @@ interface Props { } export default function Actions({ transaction, isBuyer }: Props) { - const [isExecuteError, setIsExecuteError] = useState(false); + //For actions that do not have their own modal to show an error + const [isActionError, setIsActionError] = useState(false); const currentTime = Date.now() / 1000; const isInBufferPeriod = @@ -59,9 +70,34 @@ export default function Actions({ transaction, isBuyer }: Props) { transaction.status === TransactionStatus.NoDispute && hasTimedOut; + //Show raise dispute button if there is no dispute already and the transaction has not timed out + const showRaiseDisputeButton = + transaction.status === TransactionStatus.NoDispute && !hasTimedOut; + + //Show deposit arbitration fee button (RaiseDispute component) to the user only if he is the party that needs to deposit the arbitration fee + const showDepositArbitrationFeeButton = + (transaction.status === TransactionStatus.WaitingSender && isBuyer) || + (transaction.status === TransactionStatus.WaitingReceiver && !isBuyer); + + const depositFeeDeadline = + transaction.lastInteraction + transaction.arbitrationInfo.feeTimeout; + + //Show withdraw button if the user is the party that is waiting for the other to deposit the arbitration fee + const showWithdrawButton = + (transaction.status === TransactionStatus.WaitingSender && !isBuyer) || + (transaction.status === TransactionStatus.WaitingReceiver && isBuyer); + + const ongoingDispute = + transaction.status === TransactionStatus.DisputeCreated && + transaction.disputeInfo.disputeStatus !== DisputeStatus.Solved; + + const waitingRulingExecution = + transaction.status !== TransactionStatus.Resolved && + transaction.disputeInfo.disputeStatus === DisputeStatus.Solved; + return ( - {isExecuteError && } + {isActionError && } {showPayButton && ( @@ -90,7 +126,43 @@ export default function Actions({ transaction, isBuyer }: Props) { transactionId={transaction.id} contractAddress={transaction.arbitrableAddress} isNative={transaction.metaEvidence.token?.ticker === "ETH"} - setIsExecuteError={setIsExecuteError} + setIsExecuteError={setIsActionError} + /> + )} + + {(showRaiseDisputeButton || showDepositArbitrationFeeButton) && ( + + )} + + {showWithdrawButton && ( + + )} + + {(ongoingDispute || waitingRulingExecution) && ( + )} diff --git a/src/components/Transactions/TransactionDetails/Actions/Common/FeeDepositCountdown/FeeDepositCountdown.tsx b/src/components/Transactions/TransactionDetails/Actions/Common/FeeDepositCountdown/FeeDepositCountdown.tsx new file mode 100644 index 0000000..446cca6 --- /dev/null +++ b/src/components/Transactions/TransactionDetails/Actions/Common/FeeDepositCountdown/FeeDepositCountdown.tsx @@ -0,0 +1,24 @@ +import Countdown from "react-countdown"; +import { StyledP } from "../StyledElements/StyledElements"; + +interface Props { + depositFeeDeadline: number; +} + +export default function FeeDepositCoundown({ depositFeeDeadline }: Props) { + return ( + + Time left:{" "} + { + return ( + + {days}d {hours}h {minutes}m {seconds}s + + ); + }} + /> + + ); +} diff --git a/src/components/Transactions/TransactionDetails/Actions/Common/StyledElements/StyledElements.ts b/src/components/Transactions/TransactionDetails/Actions/Common/StyledElements/StyledElements.ts index c8d8818..b4eef66 100644 --- a/src/components/Transactions/TransactionDetails/Actions/Common/StyledElements/StyledElements.ts +++ b/src/components/Transactions/TransactionDetails/Actions/Common/StyledElements/StyledElements.ts @@ -14,4 +14,26 @@ export const StyledNumberField = styled(NumberField)` export const StyledP = styled.p` font-weight: bold; + text-align: justify; +`; + +export const StyledH1 = styled.h1` + font-size: 1.5rem; + font-weight: bold; +`; + +export const CustomActionButtonContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 16px; +`; + +export const RulingContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + text-align: justify; `; diff --git a/src/components/Transactions/TransactionDetails/Actions/Execute/Execute.tsx b/src/components/Transactions/TransactionDetails/Actions/Execute/Execute.tsx index 44166f7..ef42ed7 100644 --- a/src/components/Transactions/TransactionDetails/Actions/Execute/Execute.tsx +++ b/src/components/Transactions/TransactionDetails/Actions/Execute/Execute.tsx @@ -34,21 +34,19 @@ export default function Execute({ return { address: contractAddress as `0x${string}`, args: [transactionId], + account: address, + query: { enabled: false }, //Only simulate when we want } as const; - }, [transactionId, contractAddress]); + }, [contractAddress, transactionId, address]); const { refetch: refetchNativeSimulateData } = useSimulateMultipleArbitrableTransactionExecuteTransaction({ ...transactionConfig, - account: address, - query: { enabled: false }, //Only simulate when we want }); const { refetch: refetchTokenSimulateData } = useSimulateMultipleArbitrableTokenTransactionExecuteTransaction({ ...transactionConfig, - account: address, - query: { enabled: false }, //Only simulate when we want }); const { writeContractAsync: executeNativeTransaction } = diff --git a/src/components/Transactions/TransactionDetails/Actions/OngoingDisputeInfo/OngoingDisputeInfo.tsx b/src/components/Transactions/TransactionDetails/Actions/OngoingDisputeInfo/OngoingDisputeInfo.tsx new file mode 100644 index 0000000..f41e4d4 --- /dev/null +++ b/src/components/Transactions/TransactionDetails/Actions/OngoingDisputeInfo/OngoingDisputeInfo.tsx @@ -0,0 +1,102 @@ +import { useAccount } from "wagmi"; +import { StyledP } from "../Common/StyledElements/StyledElements"; +import styled from "styled-components"; +import { DisputeRuling, DisputeStatus, type DisputeInfo } from "model/Dispute"; +import AppealableDisputeInfo from "./Rulings/AppealableDisputeInfo/AppealableDisputeInfo"; +import WinnerDisputeInfo from "./Rulings/WinnerDisputeInfo/WinnerDisputeInfo"; + +interface Props { + transactionId: bigint; + contractAddress: string; + isNative: boolean; + disputeId: bigint; + disputeInfo: DisputeInfo; + isBuyer: boolean; + isAwaitingRulingExecution: boolean; + setIsAppealError: (isError: boolean) => void; +} + +const defaultLinkProps = { + target: "_blank", + rel: "noopener noreferrer", +}; + +const CustomP = styled(StyledP)` + text-align: center; +`; + +const StyledA = styled.a` + color: ${({ theme }) => theme.colors.secondaryText}; + text-decoration: underline; +`; + +export default function OngoingDisputeInfo({ + transactionId, + contractAddress, + isNative, + disputeId, + disputeInfo, + isBuyer, + isAwaitingRulingExecution, + setIsAppealError, +}: Props) { + const { chainId } = useAccount(); + + if (disputeInfo.disputeStatus === DisputeStatus.Waiting) { + return ( + + Dispute ongoing. +
+ You can follow the process and upload evidence using the{" "} + + Dispute Resolver. + +
+ You can also view the case in the{" "} + + Court. + +
+ ); + } + + const showTiedInformation = + disputeInfo.currentRuling === DisputeRuling["Jurors refused to arbitrate"]; + + const showLoserInformation = + (isBuyer && + disputeInfo.currentRuling === + DisputeRuling["Jurors ruled in favor of the receiver"]) || + (!isBuyer && + disputeInfo.currentRuling === + DisputeRuling["Jurors ruled in favor of the sender"]); + + //In effect, both are the same. In both scenarios, appeal is possible. + if (showTiedInformation || showLoserInformation) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/Transactions/TransactionDetails/Actions/OngoingDisputeInfo/Rulings/AppealableDisputeInfo/AppealableDisputeInfo.tsx b/src/components/Transactions/TransactionDetails/Actions/OngoingDisputeInfo/Rulings/AppealableDisputeInfo/AppealableDisputeInfo.tsx new file mode 100644 index 0000000..5ff2843 --- /dev/null +++ b/src/components/Transactions/TransactionDetails/Actions/OngoingDisputeInfo/Rulings/AppealableDisputeInfo/AppealableDisputeInfo.tsx @@ -0,0 +1,155 @@ +import { + RulingContainer, + StyledH1, +} from "../../../Common/StyledElements/StyledElements"; +import { Button } from "@kleros/ui-components-library"; +import AppealInfo from "../Common/AppealInfo"; +import { + useSimulateMultipleArbitrableTokenTransactionAppeal, + useSimulateMultipleArbitrableTransactionAppeal, + useWriteMultipleArbitrableTokenTransactionAppeal, + useWriteMultipleArbitrableTransactionAppeal, +} from "config/contracts/generated"; +import { useMemo, useState } from "react"; +import { useAccount, useClient } from "wagmi"; +import { useQueryClient } from "@tanstack/react-query"; +import { waitForTransactionReceipt } from "viem/actions"; +import { QUERY_KEYS } from "config/queryKeys"; +import { isUserRejectedRequestError } from "utils/common"; + +interface Props { + transactionId: bigint; + contractAddress: string; + isNative: boolean; + appealCost: bigint; + appealPeriod: { + start: number; + end: number; + }; + isLoser: boolean; + isAwaitingRulingExecution: boolean; + setIsAppealError: (isError: boolean) => void; +} + +export default function AppealableDisputeInfo({ + transactionId, + contractAddress, + isNative, + appealCost, + appealPeriod, + isLoser, + isAwaitingRulingExecution, + setIsAppealError, +}: Props) { + const queryClient = useQueryClient(); + const client = useClient(); + const { address } = useAccount(); + const [isAppealing, setIsAppealing] = useState(false); + + const transactionConfig = useMemo(() => { + return { + address: contractAddress as `0x${string}`, + args: [transactionId], + value: appealCost, + account: address, //By default this is the account used, but set it so if the user switches accounts after the component is rendered, the simulation will use the correct account + query: { enabled: false }, //Only simulate when we want + } as const; + }, [contractAddress, transactionId, appealCost, address]); + + const { refetch: refetchNativeSimulateData } = + useSimulateMultipleArbitrableTransactionAppeal({ + ...transactionConfig, + }); + + const { refetch: refetchTokenSimulateData } = + useSimulateMultipleArbitrableTokenTransactionAppeal({ + ...transactionConfig, + }); + + const { writeContractAsync: appealNativeTransaction } = + useWriteMultipleArbitrableTransactionAppeal(); + const { writeContractAsync: appealTokenTransaction } = + useWriteMultipleArbitrableTokenTransactionAppeal(); + + const handleAppeal = async () => { + setIsAppealError(false); + + if (!client) { + setIsAppealError(true); + return; + } + + setIsAppealing(true); + let hash; + + try { + if (isNative) { + const { data: nativeSimulationData } = + await refetchNativeSimulateData(); + + if (nativeSimulationData?.request) { + hash = await appealNativeTransaction(nativeSimulationData.request); + } else { + setIsAppealing(false); + setIsAppealError(true); + return; + } + } else { + const { data: tokenSimulationData } = await refetchTokenSimulateData(); + + if (tokenSimulationData?.request) { + hash = await appealTokenTransaction(tokenSimulationData.request); + } else { + setIsAppealing(false); + setIsAppealError(true); + return; + } + } + + //Wait for the transaction confirmation before performing the details refresh + await waitForTransactionReceipt(client, { hash: hash }); + + queryClient.invalidateQueries({ + queryKey: [ + QUERY_KEYS.transactionDetails, + transactionId.toString(), + contractAddress, + ], + }); + setIsAppealing(false); + } catch (error) { + console.error(error); + + if (!isUserRejectedRequestError(error)) { + setIsAppealError(true); + } + + setIsAppealing(false); + } + }; + + return ( + + + {isLoser ? "You lost the dispute." : "Jurors refused to arbitrate."} + + {isAwaitingRulingExecution ? ( +

+ The dispute has been resolved and appeal is no longer possible. The + ruling is awaiting execution. +

+ ) : ( + <> + +