Skip to content

Commit 8a5b2f4

Browse files
committed
feat: shutter appeal support
1 parent 724a949 commit 8a5b2f4

File tree

4 files changed

+215
-3
lines changed

4 files changed

+215
-3
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import React, { useMemo, useState } from "react";
2+
import styled from "styled-components";
3+
4+
import { useParams } from "react-router-dom";
5+
import { useDebounce } from "react-use";
6+
import { useAccount, useBalance, usePublicClient } from "wagmi";
7+
import { Field, Button } from "@kleros/ui-components-library";
8+
9+
import { REFETCH_INTERVAL } from "consts/index";
10+
import { useSimulateDisputeKitShutterFundAppeal, useWriteDisputeKitShutterFundAppeal } from "hooks/contracts/generated";
11+
import { useSelectedOptionContext, useFundingContext, useCountdownContext } from "hooks/useClassicAppealContext";
12+
import { useParsedAmount } from "hooks/useParsedAmount";
13+
14+
import { isUndefined } from "utils/index";
15+
import { wrapWithToast } from "utils/wrapWithToast";
16+
17+
import { EnsureChain } from "components/EnsureChain";
18+
import { ErrorButtonMessage } from "components/ErrorButtonMessage";
19+
import ClosedCircleIcon from "components/StyledIcons/ClosedCircleIcon";
20+
21+
const Container = styled.div`
22+
display: flex;
23+
flex-direction: column;
24+
align-items: center;
25+
gap: 8px;
26+
`;
27+
28+
const StyledField = styled(Field)`
29+
width: 100%;
30+
& > input {
31+
text-align: center;
32+
}
33+
&:before {
34+
position: absolute;
35+
content: "ETH";
36+
right: 32px;
37+
top: 50%;
38+
transform: translateY(-50%);
39+
color: ${({ theme }) => theme.primaryText};
40+
}
41+
`;
42+
43+
const StyledButton = styled(Button)`
44+
margin: auto;
45+
margin-top: 4px;
46+
`;
47+
48+
const StyledLabel = styled.label`
49+
align-self: flex-start;
50+
`;
51+
52+
const useNeedFund = () => {
53+
const { loserSideCountdown } = useCountdownContext();
54+
const { fundedChoices, winningChoice } = useFundingContext();
55+
return (
56+
(loserSideCountdown ?? 0) > 0 ||
57+
(!isUndefined(fundedChoices) &&
58+
!isUndefined(winningChoice) &&
59+
fundedChoices.length > 0 &&
60+
!fundedChoices.includes(winningChoice))
61+
);
62+
};
63+
64+
const useFundAppeal = (parsedAmount: bigint, insufficientBalance: boolean) => {
65+
const { id } = useParams();
66+
const { selectedOption } = useSelectedOptionContext();
67+
const {
68+
data: fundAppealConfig,
69+
isLoading,
70+
isError,
71+
} = useSimulateDisputeKitShutterFundAppeal({
72+
query: { enabled: !isUndefined(id) && !isUndefined(selectedOption) && !insufficientBalance },
73+
args: [BigInt(id ?? 0), BigInt(selectedOption?.id ?? 0)],
74+
value: parsedAmount,
75+
});
76+
const { writeContractAsync: fundAppeal } = useWriteDisputeKitShutterFundAppeal();
77+
return { fundAppeal, fundAppealConfig, isLoading, isError };
78+
};
79+
80+
interface IFund {
81+
amount: `${number}`;
82+
setAmount: (val: string) => void;
83+
setIsOpen: (val: boolean) => void;
84+
}
85+
86+
const Fund: React.FC<IFund> = ({ amount, setAmount, setIsOpen }) => {
87+
const needFund = useNeedFund();
88+
const { address, isDisconnected } = useAccount();
89+
const { data: balance } = useBalance({
90+
query: { refetchInterval: REFETCH_INTERVAL },
91+
address,
92+
});
93+
const publicClient = usePublicClient();
94+
const [isSending, setIsSending] = useState(false);
95+
const [debouncedAmount, setDebouncedAmount] = useState<`${number}` | "">("");
96+
useDebounce(() => setDebouncedAmount(amount), 500, [amount]);
97+
const parsedAmount = useParsedAmount(debouncedAmount as `${number}`);
98+
const insufficientBalance = useMemo(() => balance && balance.value < parsedAmount, [balance, parsedAmount]);
99+
const { fundAppealConfig, fundAppeal, isLoading, isError } = useFundAppeal(parsedAmount, insufficientBalance);
100+
const isFundDisabled = useMemo(
101+
() =>
102+
isDisconnected ||
103+
isSending ||
104+
!balance ||
105+
insufficientBalance ||
106+
Number(parsedAmount) <= 0 ||
107+
isError ||
108+
isLoading,
109+
[isDisconnected, isSending, balance, insufficientBalance, parsedAmount, isError, isLoading]
110+
);
111+
112+
return needFund ? (
113+
<Container>
114+
<StyledLabel>How much ETH do you want to contribute?</StyledLabel>
115+
<StyledField
116+
type="number"
117+
value={amount}
118+
onChange={(e) => setAmount(e.target.value)}
119+
placeholder="Amount to fund"
120+
/>
121+
<EnsureChain>
122+
<div>
123+
<StyledButton
124+
disabled={isFundDisabled}
125+
isLoading={(isSending || isLoading) && !insufficientBalance}
126+
text={isDisconnected ? "Connect to Fund" : "Fund"}
127+
onClick={() => {
128+
if (fundAppeal && fundAppealConfig && publicClient) {
129+
setIsSending(true);
130+
wrapWithToast(async () => await fundAppeal(fundAppealConfig.request), publicClient)
131+
.then((res) => setIsOpen(res.status))
132+
.finally(() => setIsSending(false));
133+
}
134+
}}
135+
/>
136+
{insufficientBalance && (
137+
<ErrorButtonMessage>
138+
<ClosedCircleIcon /> Insufficient balance
139+
</ErrorButtonMessage>
140+
)}
141+
</div>
142+
</EnsureChain>
143+
</Container>
144+
) : null;
145+
};
146+
147+
export default Fund;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, { useState } from "react";
2+
import { useSelectedOptionContext } from "hooks/useClassicAppealContext";
3+
import Popup, { PopupType } from "components/Popup";
4+
import AppealIcon from "svgs/icons/appeal.svg";
5+
import HowItWorks from "components/HowItWorks";
6+
import Appeal from "components/Popup/MiniGuides/Appeal";
7+
import { AppealHeader, StyledTitle } from "..";
8+
import Options from "../Classic/Options";
9+
import Fund from "./Fund";
10+
11+
interface IShutter {
12+
isAppealMiniGuideOpen: boolean;
13+
toggleAppealMiniGuide: () => void;
14+
}
15+
16+
const Shutter: React.FC<IShutter> = ({ isAppealMiniGuideOpen, toggleAppealMiniGuide }) => {
17+
const [isPopupOpen, setIsPopupOpen] = useState(false);
18+
const [amount, setAmount] = useState("");
19+
const { selectedOption } = useSelectedOptionContext();
20+
21+
return (
22+
<>
23+
{isPopupOpen && (
24+
<Popup
25+
title="Thanks for Funding the Appeal"
26+
icon={AppealIcon}
27+
popupType={PopupType.APPEAL}
28+
setIsOpen={setIsPopupOpen}
29+
setAmount={setAmount}
30+
option={selectedOption?.title ?? ""}
31+
amount={amount}
32+
/>
33+
)}
34+
<AppealHeader>
35+
<StyledTitle>Appeal crowdfunding</StyledTitle>
36+
<HowItWorks
37+
isMiniGuideOpen={isAppealMiniGuideOpen}
38+
toggleMiniGuide={toggleAppealMiniGuide}
39+
MiniGuideComponent={Appeal}
40+
/>
41+
</AppealHeader>
42+
<label>The jury decision is appealed when two options are fully funded.</label>
43+
<Options setAmount={setAmount} />
44+
<Fund amount={amount as `${number}`} setAmount={setAmount} setIsOpen={setIsPopupOpen} />
45+
</>
46+
);
47+
};
48+
49+
export default Shutter;

web/src/pages/Cases/CaseDetails/Appeal/index.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ import React from "react";
22
import styled, { css } from "styled-components";
33

44
import { useToggle } from "react-use";
5+
import { useParams } from "react-router-dom";
56

67
import { Periods } from "consts/periods";
7-
import { ClassicAppealProvider } from "hooks/useClassicAppealContext";
8+
import { getDisputeKitName } from "consts/index";
9+
import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery";
810

911
import { landscapeStyle } from "styles/landscapeStyle";
1012
import { responsiveSize } from "styles/responsiveSize";
1113

1214
import AppealHistory from "./AppealHistory";
1315
import Classic from "./Classic";
16+
import Shutter from "./Shutter";
1417

1518
const Container = styled.div`
1619
padding: 16px;
@@ -44,11 +47,24 @@ export const StyledTitle = styled.h1`
4447

4548
const Appeal: React.FC<{ currentPeriodIndex: number }> = ({ currentPeriodIndex }) => {
4649
const [isAppealMiniGuideOpen, toggleAppealMiniGuide] = useToggle(false);
50+
const { id } = useParams();
51+
const { data: disputeData } = useDisputeDetailsQuery(id);
52+
const disputeKitId = disputeData?.dispute?.currentRound?.disputeKit?.id;
53+
const disputeKitName = disputeKitId ? getDisputeKitName(Number(disputeKitId))?.toLowerCase() : "";
54+
const isClassicDisputeKit = disputeKitName?.includes("classic") ?? false;
55+
const isShutterDisputeKit = disputeKitName?.includes("shutter") ?? false;
4756

4857
return (
4958
<Container>
5059
{Periods.appeal === currentPeriodIndex ? (
51-
<Classic isAppealMiniGuideOpen={isAppealMiniGuideOpen} toggleAppealMiniGuide={toggleAppealMiniGuide} />
60+
<>
61+
{isClassicDisputeKit && (
62+
<Classic isAppealMiniGuideOpen={isAppealMiniGuideOpen} toggleAppealMiniGuide={toggleAppealMiniGuide} />
63+
)}
64+
{isShutterDisputeKit && (
65+
<Shutter isAppealMiniGuideOpen={isAppealMiniGuideOpen} toggleAppealMiniGuide={toggleAppealMiniGuide} />
66+
)}
67+
</>
5268
) : (
5369
<AppealHistory isAppealMiniGuideOpen={isAppealMiniGuideOpen} toggleAppealMiniGuide={toggleAppealMiniGuide} />
5470
)}

web/src/pages/Resolver/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ const DisputeResolver: React.FC = () => {
107107
<Container>
108108
{!isConnected || !isVerified ? (
109109
<>
110-
<Heading>Justise as a Service</Heading>
110+
<Heading>Justice as a Service</Heading>
111111
<Paragraph>You send your disputes. Kleros sends back decisions.</Paragraph>
112112
</>
113113
) : null}

0 commit comments

Comments
 (0)