diff --git a/package.json b/package.json index 9bb845c7b..8a52d3627 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,7 @@ "web-devtools", "eslint-config", "prettier-config", - "tsconfig", - "kleros-app" + "tsconfig" ], "packageManager": "yarn@4.5.1", "volta": { diff --git a/web/netlify.toml b/web/netlify.toml index 0ed0b0e78..8cf55aefd 100644 --- a/web/netlify.toml +++ b/web/netlify.toml @@ -6,8 +6,6 @@ NETLIFY_YARN_WORKSPACES = "true" YARN_ENABLE_GLOBAL_CACHE = "true" # YARN_CACHE_FOLDER = "$HOME/.yarn_cache" # YARN_VERSION = "3.2.0" -[build] -command = "yarn workspace @kleros/kleros-v2-contracts install && yarn workspace @kleros/kleros-app install && yarn workspace @kleros/kleros-v2-web install && yarn workspace @kleros/kleros-v2-contracts build && yarn workspace @kleros/kleros-app build && yarn workspace @kleros/kleros-v2-web build-netlify" [functions] directory = "web/netlify/functions/" diff --git a/web/package.json b/web/package.json index c12dbc666..c18d18b1b 100644 --- a/web/package.json +++ b/web/package.json @@ -78,15 +78,15 @@ }, "dependencies": { "@cyntler/react-doc-viewer": "^1.17.0", - "@kleros/kleros-app": "workspace:^", + "@kleros/kleros-app": "^2.0.1", "@kleros/kleros-sdk": "workspace:^", "@kleros/kleros-v2-contracts": "workspace:^", - "@kleros/ui-components-library": "^2.15.0", + "@kleros/ui-components-library": "^2.16.0", "@lifi/wallet-management": "^3.4.5", "@lifi/widget": "^3.12.2", "@sentry/react": "^7.120.0", "@sentry/tracing": "^7.120.0", - "@tanstack/react-query": "^5.61.0", + "@tanstack/react-query": "^5.56.2", "@types/react-modal": "^3.16.3", "@wagmi/connectors": "^5.5.0", "@wagmi/core": "^2.15.0", @@ -117,7 +117,7 @@ "react-toastify": "^9.1.3", "react-use": "^17.5.1", "styled-components": "^5.3.3", - "viem": "^2.21.48", - "wagmi": "^2.13.0" + "viem": "^2.21.54", + "wagmi": "^2.13.5" } } diff --git a/web/src/assets/svgs/icons/close-circle.svg b/web/src/assets/svgs/icons/close-circle.svg index f3d4a2477..1f4c4efd6 100644 --- a/web/src/assets/svgs/icons/close-circle.svg +++ b/web/src/assets/svgs/icons/close-circle.svg @@ -1,3 +1,3 @@ - + diff --git a/web/src/components/ExternalLink.tsx b/web/src/components/ExternalLink.tsx index f85920a1a..8470386ec 100644 --- a/web/src/components/ExternalLink.tsx +++ b/web/src/components/ExternalLink.tsx @@ -1,6 +1,7 @@ -import { Link } from "react-router-dom"; import styled from "styled-components"; +import { Link } from "react-router-dom"; + export const ExternalLink = styled(Link)` :hover { text-decoration: underline; diff --git a/web/src/components/Spinner.tsx b/web/src/components/Spinner.tsx new file mode 100644 index 000000000..5565e336b --- /dev/null +++ b/web/src/components/Spinner.tsx @@ -0,0 +1,27 @@ +import styled, { keyframes } from "styled-components"; + +import SpinnerIcon from "svgs/icons/spinner.svg"; + +const rotating = keyframes` + 0%{ + transform: rotate(0deg); + } + 50%{ + transform: rotate(180deg); + } + 100%{ + transform: rotate(360deg); + } +`; + +const Spinner = styled(SpinnerIcon)` + path { + fill: ${({ theme }) => theme.primaryBlue}; + } + width: 16px; + height: 16px; + margin-right: 4px; + animation: ${rotating} 2s ease-in-out infinite normal; +`; + +export default Spinner; diff --git a/web/src/components/TxnHash.tsx b/web/src/components/TxnHash.tsx new file mode 100644 index 000000000..bb25ce7c1 --- /dev/null +++ b/web/src/components/TxnHash.tsx @@ -0,0 +1,40 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; + +import NewTabIcon from "svgs/icons/new-tab.svg"; + +import { DEFAULT_CHAIN, getChain } from "consts/chains"; + +import { ExternalLink } from "./ExternalLink"; + +const TxnLabel = styled.label<{ variant: string }>` + display: flex; + gap: 4px; + color: ${({ theme, variant }) => (variant === "pending" ? theme.primaryBlue : theme[variant])}; + cursor: pointer; + path { + fill: ${({ theme, variant }) => (variant === "pending" ? theme.primaryBlue : theme[variant])}; + } +`; + +interface ITxnHash { + hash: `0x${string}`; + variant: "success" | "error" | "pending"; +} +const TxnHash: React.FC = ({ hash, variant }) => { + const transactionExplorerLink = useMemo(() => { + return `${getChain(DEFAULT_CHAIN)?.blockExplorers?.default.url}/tx/${hash}`; + }, [hash]); + + return ( + + + {" "} + {hash.substring(0, 6) + "..." + hash.substring(hash.length - 4)} + + + + ); +}; + +export default TxnHash; diff --git a/web/src/hooks/queries/useCourtDetails.ts b/web/src/hooks/queries/useCourtDetails.ts index 6d355ac26..296d4aa09 100644 --- a/web/src/hooks/queries/useCourtDetails.ts +++ b/web/src/hooks/queries/useCourtDetails.ts @@ -23,6 +23,7 @@ const courtDetailsQuery = graphql(` paidPNK timesPerPeriod feeForJuror + name } } `); diff --git a/web/src/hooks/usePNKData.tsx b/web/src/hooks/usePNKData.tsx new file mode 100644 index 000000000..5bee1265c --- /dev/null +++ b/web/src/hooks/usePNKData.tsx @@ -0,0 +1,46 @@ +import { useAccount } from "wagmi"; + +import { DEFAULT_CHAIN } from "consts/chains"; + +import { REFETCH_INTERVAL } from "src/consts"; +import { isUndefined } from "src/utils"; + +import { + klerosCoreAddress, + useReadPnkAllowance, + useReadPnkBalanceOf, + useReadSortitionModuleGetJurorBalance, +} from "./contracts/generated"; + +interface UsePnkDataParams { + courtId?: string; +} + +/** + * @description hook to provide user's pnk data. (pnk balance, pnk allowance, jurorBalance for provided courtId) + * @param param0 optional court Id to fetch juror balance for. Defaults to 0 + */ +export const usePnkData = ({ courtId = "0" }: UsePnkDataParams) => { + const { address } = useAccount(); + const queryConfig = { + enabled: !isUndefined(address), + refetchInterval: REFETCH_INTERVAL, + }; + + const { data: balance } = useReadPnkBalanceOf({ + query: queryConfig, + args: [address!], + }); + + const { data: jurorBalance } = useReadSortitionModuleGetJurorBalance({ + query: queryConfig, + args: [address ?? "0x", BigInt(courtId)], + }); + + const { data: allowance, refetch: refetchAllowance } = useReadPnkAllowance({ + query: queryConfig, + args: [address ?? "0x", klerosCoreAddress[DEFAULT_CHAIN]], + }); + + return { balance, jurorBalance, allowance, refetchAllowance }; +}; diff --git a/web/src/pages/Courts/CourtDetails/StakePanel/InputDisplay.tsx b/web/src/pages/Courts/CourtDetails/StakePanel/InputDisplay.tsx index 8ff616816..6a28e9685 100644 --- a/web/src/pages/Courts/CourtDetails/StakePanel/InputDisplay.tsx +++ b/web/src/pages/Courts/CourtDetails/StakePanel/InputDisplay.tsx @@ -1,25 +1,26 @@ import React, { useState, useMemo, useEffect } from "react"; import styled, { css } from "styled-components"; -import { landscapeStyle } from "styles/landscapeStyle"; import { useParams } from "react-router-dom"; import { useDebounce } from "react-use"; -import { useAccount } from "wagmi"; - -import { REFETCH_INTERVAL } from "consts/index"; -import { useReadSortitionModuleGetJurorBalance, useReadPnkBalanceOf } from "hooks/contracts/generated"; import { useParsedAmount } from "hooks/useParsedAmount"; - +import { usePnkData } from "hooks/usePNKData"; import { commify, uncommify } from "utils/commify"; import { formatPNK, roundNumberDown } from "utils/format"; import { isUndefined } from "utils/index"; +import { landscapeStyle } from "styles/landscapeStyle"; + import { NumberInputField } from "components/NumberInputField"; + import StakeWithdrawButton, { ActionType } from "./StakeWithdrawButton"; const StyledField = styled(NumberInputField)` height: fit-content; + input { + border-radius: 3px 0px 0px 3px; + } `; const LabelArea = styled.div` @@ -62,48 +63,27 @@ const EnsureChainContainer = styled.div` button { height: 45px; border: 1px solid ${({ theme }) => theme.stroke}; + border-radius: 0px 3px 3px 0px; } `; interface IInputDisplay { action: ActionType; - isSending: boolean; - setIsSending: (arg0: boolean) => void; - setIsPopupOpen: (arg0: boolean) => void; amount: string; setAmount: (arg0: string) => void; } -const InputDisplay: React.FC = ({ - action, - isSending, - setIsSending, - setIsPopupOpen, - amount, - setAmount, -}) => { +const InputDisplay: React.FC = ({ action, amount, setAmount }) => { const [debouncedAmount, setDebouncedAmount] = useState(""); const [errorMsg, setErrorMsg] = useState(); useDebounce(() => setDebouncedAmount(amount), 500, [amount]); const parsedAmount = useParsedAmount(uncommify(debouncedAmount) as `${number}`); const { id } = useParams(); - const { address } = useAccount(); - const { data: balance } = useReadPnkBalanceOf({ - query: { - enabled: !isUndefined(address), - refetchInterval: REFETCH_INTERVAL, - }, - args: [address ?? "0x"], - }); + const { balance, jurorBalance } = usePnkData({ courtId: id }); + const parsedBalance = formatPNK(balance ?? 0n, 0, true); - const { data: jurorBalance } = useReadSortitionModuleGetJurorBalance({ - query: { - enabled: !isUndefined(address), - refetchInterval: REFETCH_INTERVAL, - }, - args: [address ?? "0x", BigInt(id ?? "0")], - }); + const parsedStake = formatPNK(jurorBalance?.[2] ?? 0n, 0, true); const isStaking = useMemo(() => action === ActionType.stake, [action]); @@ -147,12 +127,10 @@ const InputDisplay: React.FC = ({ diff --git a/web/src/pages/Courts/CourtDetails/StakePanel/Simulator/QuantityToSimulate.tsx b/web/src/pages/Courts/CourtDetails/StakePanel/Simulator/QuantityToSimulate.tsx index 4b43352f8..d18b96ddf 100644 --- a/web/src/pages/Courts/CourtDetails/StakePanel/Simulator/QuantityToSimulate.tsx +++ b/web/src/pages/Courts/CourtDetails/StakePanel/Simulator/QuantityToSimulate.tsx @@ -1,5 +1,6 @@ import React from "react"; import styled from "styled-components"; + import Skeleton from "react-loading-skeleton"; import { commify } from "utils/commify"; @@ -13,6 +14,7 @@ const Container = styled.div` align-items: center; flex-wrap: wrap; gap: 0 8px; + justify-content: center; `; const TextWithTooltipContainer = styled.div` @@ -48,6 +50,7 @@ interface IQuantityToSimulate { jurorCurrentSpecificStake: number | undefined; isStaking: boolean; amountToStake: number; + className?: string; } const QuantityToSimulate: React.FC = ({ @@ -55,6 +58,7 @@ const QuantityToSimulate: React.FC = ({ jurorCurrentEffectiveStake, jurorCurrentSpecificStake, amountToStake, + className, }) => { const effectiveStakeDisplay = !isUndefined(jurorCurrentEffectiveStake) ? ( `${commify(jurorCurrentEffectiveStake)} PNK` @@ -85,7 +89,7 @@ const QuantityToSimulate: React.FC = ({ ); return ( - + {effectiveStakeDisplay} void; setAmount: (arg0: string) => void; - setIsPopupOpen: (arg0: boolean) => void; setErrorMsg: (msg: string) => void; } -const StakeWithdrawButton: React.FC = ({ - parsedAmount, - action, - isSending, - setIsSending, - setIsPopupOpen, - setErrorMsg, -}) => { +const StakeWithdrawButton: React.FC = ({ amount, parsedAmount, action, setErrorMsg, setAmount }) => { const { id } = useParams(); - const { address } = useAccount(); + const theme = useTheme(); + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [popupStepsState, setPopupStepsState] = useState<[_TimelineItem1, ..._TimelineItem1[]]>(); + const { data: courtDetails } = useCourtDetails(id); - const { data: balance } = useReadPnkBalanceOf({ - query: { - enabled: !isUndefined(address), - refetchInterval: REFETCH_INTERVAL, - }, - args: [address!], - }); - const { data: jurorBalance } = useReadSortitionModuleGetJurorBalance({ - query: { - enabled: !isUndefined(address), - refetchInterval: REFETCH_INTERVAL, - }, - args: [address ?? "0x", BigInt(id ?? 0)], - }); - const { data: allowance } = useReadPnkAllowance({ - query: { - enabled: !isUndefined(address), - refetchInterval: REFETCH_INTERVAL, - }, - args: [address ?? "0x", klerosCoreAddress[DEFAULT_CHAIN]], - }); + const { balance, jurorBalance, allowance, refetchAllowance } = usePnkData({ courtId: id }); + const publicClient = usePublicClient(); const isStaking = action === ActionType.stake; @@ -97,86 +75,191 @@ const StakeWithdrawButton: React.FC = ({ return 0n; }, [jurorBalance, parsedAmount, isAllowance, isStaking]); - const { data: increaseAllowanceConfig } = useSimulatePnkIncreaseAllowance({ + const { + data: increaseAllowanceConfig, + isLoading: isSimulatingAllowance, + error: allowanceError, + } = useSimulatePnkIncreaseAllowance({ query: { - enabled: isAllowance && !isUndefined(targetStake) && !isUndefined(allowance), + enabled: isAllowance && !isUndefined(targetStake) && !isUndefined(allowance) && !isUndefined(balance), }, args: [klerosCoreAddress[DEFAULT_CHAIN], BigInt(targetStake ?? 0) - BigInt(allowance ?? 0)], }); const { writeContractAsync: increaseAllowance } = useWritePnkIncreaseAllowance(); - const handleAllowance = useCallback(() => { - if (increaseAllowanceConfig && publicClient) { - setIsSending(true); - wrapWithToast(async () => await increaseAllowance(increaseAllowanceConfig.request), publicClient).finally(() => { - setIsSending(false); - }); - } - }, [setIsSending, increaseAllowance, increaseAllowanceConfig, publicClient]); - - const { data: setStakeConfig, error: setStakeError } = useSimulateKlerosCoreSetStake({ + const { + data: setStakeConfig, + error: setStakeError, + isLoading: isSimulatingSetStake, + refetch: refetchSetStake, + } = useSimulateKlerosCoreSetStake({ query: { enabled: - !isUndefined(targetStake) && !isUndefined(id) && !isAllowance && parsedAmount !== 0n && targetStake >= 0n, + !isUndefined(targetStake) && + !isUndefined(id) && + parsedAmount !== 0n && + targetStake >= 0n && + !isAllowance && + (isStaking ? true : jurorBalance && parsedAmount <= jurorBalance[2]), }, args: [BigInt(id ?? 0), targetStake], }); const { writeContractAsync: setStake } = useWriteKlerosCoreSetStake(); - const handleStake = useCallback(() => { - if (setStakeConfig && publicClient) { - setIsSending(true); - wrapWithToast(async () => await setStake(setStakeConfig.request), publicClient) - .then((res) => setIsPopupOpen(res.status)) - .finally(() => { - setIsSending(false); - }); - } - }, [setIsSending, setStake, setStakeConfig, publicClient, setIsPopupOpen]); + const handleStake = useCallback( + (config?: typeof setStakeConfig, approvalHash?: `0x${string}`) => { + const isWithdraw = action === ActionType.withdraw; + const requestData = config?.request ?? setStakeConfig?.request; + const commonArgs: [string, DefaultTheme, `0x${string}` | undefined] = [amount, theme, approvalHash]; - const buttonProps = { - [ActionType.allowance]: { - text: "Allow PNK", - checkDisabled: () => !balance || targetStake > balance, - onClick: handleAllowance, - }, - [ActionType.stake]: { - text: "Stake", - checkDisabled: () => !isUndefined(setStakeError), - onClick: handleStake, - }, - [ActionType.withdraw]: { - text: "Withdraw", - checkDisabled: () => !jurorBalance || parsedAmount > jurorBalance[2], - onClick: handleStake, + if (requestData && publicClient) { + setPopupStepsState( + getStakeSteps(isWithdraw ? StakeSteps.WithdrawInitiate : StakeSteps.StakeInitiate, ...commonArgs) + ); + + setStake(requestData) + .then(async (hash) => { + setPopupStepsState( + getStakeSteps(isWithdraw ? StakeSteps.WithdrawPending : StakeSteps.StakePending, ...commonArgs, hash) + ); + await publicClient.waitForTransactionReceipt({ hash, confirmations: 2 }).then((res: TransactionReceipt) => { + const status = res.status === "success"; + if (status) { + setPopupStepsState( + getStakeSteps( + isWithdraw ? StakeSteps.WithdrawConfirmed : StakeSteps.StakeConfirmed, + ...commonArgs, + hash + ) + ); + setIsSuccess(true); + } else + setPopupStepsState( + getStakeSteps(isWithdraw ? StakeSteps.WithdrawFailed : StakeSteps.StakeFailed, ...commonArgs, hash) + ); + }); + }) + .catch((err) => { + setPopupStepsState( + getStakeSteps( + isWithdraw ? StakeSteps.WithdrawFailed : StakeSteps.StakeFailed, + ...commonArgs, + undefined, + err + ) + ); + }); + } }, - }; + [setStake, setStakeConfig, publicClient, amount, theme, action] + ); + + const handleClick = useCallback(() => { + setIsPopupOpen(true); + if (isAllowance && increaseAllowanceConfig && publicClient) { + const commonArgs: [string, DefaultTheme] = [amount, theme]; + setPopupStepsState(getStakeSteps(StakeSteps.ApproveInitiate, ...commonArgs)); + + increaseAllowance(increaseAllowanceConfig.request) + .then(async (hash) => { + setPopupStepsState(getStakeSteps(StakeSteps.ApprovePending, ...commonArgs, hash)); + + await publicClient + .waitForTransactionReceipt({ hash, confirmations: 2 }) + .then(async (res: TransactionReceipt) => { + const status = res.status === "success"; + if (status) { + await refetchAllowance(); + const refetchData = await refetchWithRetry(refetchSetStake); + // check for a relatively new error with react/tanstack-query: + // https://github.com/TanStack/query/issues/8209 + if (!refetchData?.data) + setPopupStepsState( + getStakeSteps( + StakeSteps.ApproveFailed, + ...commonArgs, + hash, + undefined, + new Error("Something went wrong. Please restart the process.") + ) + ); + else { + handleStake(refetchData.data, hash); + } + } else setPopupStepsState(getStakeSteps(StakeSteps.ApproveFailed, ...commonArgs, hash)); + }); + }) + .catch((err) => { + setPopupStepsState(getStakeSteps(StakeSteps.ApproveFailed, ...commonArgs, undefined, undefined, err)); + }); + } else { + handleStake(); + } + }, [ + increaseAllowance, + increaseAllowanceConfig, + handleStake, + isAllowance, + theme, + publicClient, + amount, + refetchAllowance, + refetchSetStake, + ]); useEffect(() => { - if (setStakeError) { - setErrorMsg(parseWagmiError(setStakeError)); + if (isPopupOpen) return; + if (setStakeError || allowanceError) { + setErrorMsg(parseWagmiError(setStakeError || allowanceError)); + } else if (targetStake !== 0n && courtDetails && targetStake < BigInt(courtDetails.court?.minStake)) { + setErrorMsg(`Min Stake in court is: ${formatETH(courtDetails?.court?.minStake)}`); + } + }, [setStakeError, setErrorMsg, targetStake, courtDetails, allowanceError, isPopupOpen]); + + const isDisabled = useMemo(() => { + if ( + parsedAmount == 0n || + isUndefined(targetStake) || + isUndefined(courtDetails) || + (targetStake !== 0n && targetStake < BigInt(courtDetails.court?.minStake)) + ) + return true; + if (isAllowance) { + return isUndefined(increaseAllowanceConfig) || isSimulatingAllowance || !isUndefined(allowanceError); } - }, [setStakeError, setErrorMsg]); - const { text, checkDisabled, onClick } = buttonProps[isAllowance ? ActionType.allowance : action]; + return isUndefined(setStakeConfig) || isSimulatingSetStake || !isUndefined(setStakeError); + }, [ + parsedAmount, + targetStake, + courtDetails, + increaseAllowanceConfig, + isSimulatingAllowance, + setStakeConfig, + isSimulatingSetStake, + setStakeError, + allowanceError, + isAllowance, + ]); + + const closePopup = () => { + setIsPopupOpen(false); + setIsSuccess(false); + setAmount(""); + setPopupStepsState(undefined); + }; + return (