diff --git a/web/src/components/DisputeFeatures/Features/ClassicVote.tsx b/web/src/components/DisputeFeatures/Features/ClassicVote.tsx new file mode 100644 index 000000000..44f3bf237 --- /dev/null +++ b/web/src/components/DisputeFeatures/Features/ClassicVote.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { Features } from "consts/disputeFeature"; +import { useNewDisputeContext } from "context/NewDisputeContext"; + +import { useCourtDetails } from "queries/useCourtDetails"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +import { RadioInput, StyledRadio } from "."; + +const ClassicVote: React.FC = (props) => { + const { disputeData } = useNewDisputeContext(); + const { data: courtData } = useCourtDetails(disputeData.courtId); + const isCommitEnabled = Boolean(courtData?.court?.hiddenVotes); + return ( + + + + ); +}; + +export default ClassicVote; diff --git a/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx b/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx new file mode 100644 index 000000000..3d48228c1 --- /dev/null +++ b/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx @@ -0,0 +1,122 @@ +import React, { Fragment, useEffect, useMemo } from "react"; +import styled from "styled-components"; + +import { Field } from "@kleros/ui-components-library"; + +import { Features } from "consts/disputeFeature"; +import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; +import { useERC1155Validation } from "hooks/useTokenAddressValidation"; + +import { isUndefined } from "src/utils"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +import { RadioInput, StyledRadio } from "."; + +const FieldContainer = styled.div` + width: 100%; + padding-left: 32px; +`; + +const StyledField = styled(Field)` + width: 100%; + margin-top: 8px; + margin-bottom: 32px; + > small { + margin-top: 16px; + } +`; + +const GatedErc1155: React.FC = (props) => { + const { disputeData, setDisputeData } = useNewDisputeContext(); + + const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; + const validationEnabled = !isUndefined(tokenGateAddress) && tokenGateAddress.trim() !== ""; + + const { + isValidating, + isValid, + error: validationError, + } = useERC1155Validation({ + address: tokenGateAddress, + enabled: validationEnabled && props.checked, + }); + + const [validationMessage, variant] = useMemo(() => { + if (isValidating) return [`Validating ERC-1155 token...`, "info"]; + else if (validationError) return [validationError, "error"]; + else if (isValid === true) return [`Valid ERC-1155 token`, "success"]; + else return [undefined, "info"]; + }, [isValidating, validationError, isValid]); + + // Update validation state in dispute context + useEffect(() => { + // this can clash with erc20 check + if (!props.checked) return; + // Only update if isValid has actually changed + if (disputeData.disputeKitData) { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + + if (currentData.isTokenGateValid !== isValid) { + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, isTokenGateValid: isValid }, + }); + } + } + }, [isValid, setDisputeData, props.checked]); + + const handleTokenAddressChange = (event: React.ChangeEvent) => { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + + setDisputeData({ + ...disputeData, + disputeKitData: { + ...currentData, + tokenGate: event.target.value, + isTokenGateValid: null, // Reset validation state when address changes + }, + }); + }; + + const handleTokenIdChange = (event: React.ChangeEvent) => { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + // DEV: we only update the tokenGate value here, and the disputeKidID, + // and type are still handled in Resolver/Court/FeatureSelection.tsx + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, tokenId: event.target.value }, + }); + }; + + return ( + + + + + {props.checked ? ( + + + + + ) : null} + + ); +}; + +export default GatedErc1155; diff --git a/web/src/components/DisputeFeatures/Features/GatedErc20.tsx b/web/src/components/DisputeFeatures/Features/GatedErc20.tsx new file mode 100644 index 000000000..1ac053b42 --- /dev/null +++ b/web/src/components/DisputeFeatures/Features/GatedErc20.tsx @@ -0,0 +1,106 @@ +import React, { Fragment, useEffect, useMemo } from "react"; +import styled from "styled-components"; + +import { Field } from "@kleros/ui-components-library"; + +import { Features } from "consts/disputeFeature"; +import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; +import { useERC20ERC721Validation } from "hooks/useTokenAddressValidation"; + +import { isUndefined } from "src/utils"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +import { RadioInput, StyledRadio } from "."; + +const FieldContainer = styled.div` + width: 100%; + padding-left: 32px; +`; + +const StyledField = styled(Field)` + width: 100%; + margin-top: 8px; + margin-bottom: 32px; + > small { + margin-top: 16px; + } +`; + +const GatedErc20: React.FC = (props) => { + const { disputeData, setDisputeData } = useNewDisputeContext(); + + const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; + const validationEnabled = !isUndefined(tokenGateAddress) && tokenGateAddress.trim() !== ""; + + const { + isValidating, + isValid, + error: validationError, + } = useERC20ERC721Validation({ + address: tokenGateAddress, + enabled: validationEnabled && props.checked, + }); + + const [validationMessage, variant] = useMemo(() => { + if (isValidating) return [`Validating ERC-20 or ERC-721 token...`, "info"]; + else if (validationError) return [validationError, "error"]; + else if (isValid === true) return [`Valid ERC-20 or ERC-721 token`, "success"]; + else return [undefined, "info"]; + }, [isValidating, validationError, isValid]); + + // Update validation state in dispute context + useEffect(() => { + // this can clash with erc1155 check + if (!props.checked) return; + if (disputeData.disputeKitData) { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + + if (currentData.isTokenGateValid !== isValid) { + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, isTokenGateValid: isValid }, + }); + } + } + }, [isValid, setDisputeData, props.checked]); + + const handleTokenAddressChange = (event: React.ChangeEvent) => { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + // DEV: we only update the tokenGate value here, and the disputeKidID, + // and type are still handled in Resolver/Court/FeatureSelection.tsx + setDisputeData({ + ...disputeData, + disputeKitData: { + ...currentData, + tokenGate: event.target.value, + isTokenGateValid: null, // Reset validation state when address changes + }, + }); + }; + + return ( + + + + + {props.checked ? ( + + + + ) : null} + + ); +}; + +export default GatedErc20; diff --git a/web/src/components/DisputeFeatures/Features/index.tsx b/web/src/components/DisputeFeatures/Features/index.tsx new file mode 100644 index 000000000..c98cc77e0 --- /dev/null +++ b/web/src/components/DisputeFeatures/Features/index.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import styled from "styled-components"; + +import { Radio } from "@kleros/ui-components-library"; + +import { Features } from "consts/disputeFeature"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +import ClassicVote from "./ClassicVote"; +import GatedErc1155 from "./GatedErc1155"; +import GatedErc20 from "./GatedErc20"; + +export type RadioInput = { + name: string; + value: Features; + checked: boolean; + disabled: boolean; + onClick: () => void; +}; + +export type FeatureUI = React.FC; + +export const StyledRadio = styled(Radio)` + font-size: 14px; + color: ${({ theme, disabled }) => (disabled ? theme.secondaryText : theme.primaryText)}; + opacity: ${({ disabled }) => (disabled ? "0.7" : 1)}; +`; + +export const FeatureUIs: Record = { + [Features.ShieldedVote]: (props: RadioInput) => ( + + + + ), + + [Features.ClassicVote]: (props: RadioInput) => , + + [Features.ClassicEligibility]: (props: RadioInput) => ( + + ), + + [Features.GatedErc20]: (props: RadioInput) => , + + [Features.GatedErc1155]: (props: RadioInput) => , +}; diff --git a/web/src/components/DisputeFeatures/GroupsUI.tsx b/web/src/components/DisputeFeatures/GroupsUI.tsx new file mode 100644 index 000000000..63b1b2e1b --- /dev/null +++ b/web/src/components/DisputeFeatures/GroupsUI.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import styled from "styled-components"; + +import { Group } from "consts/disputeFeature"; + +import LightButton from "../LightButton"; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + align-items: start; + padding-bottom: 16px; +`; + +const HeaderContainer = styled.div` + width: 100%; + padding-top: 16px; +`; + +const Header = styled.h2` + display: flex; + font-size: 16px; + font-weight: 600; + margin: 0; + align-items: center; + gap: 8px; +`; + +const SubTitle = styled.p` + font-size: 14px; + color: ${({ theme }) => theme.secondaryText}; + padding: 0; + margin: 0; +`; + +const StyledLightButton = styled(LightButton)` + padding: 0 !important; + .button-text { + color: ${({ theme }) => theme.primaryBlue}; + font-size: 14px; + } + :hover { + background-color: transparent !important; + .button-text { + color: ${({ theme }) => theme.secondaryBlue}; + } + } +`; + +export type GroupUI = (props: { children: JSX.Element; clearAll: () => void }) => JSX.Element; +export const GroupsUI: Record = { + [Group.Voting]: ({ children, clearAll }) => ( + + +
+ Shielded Voting +
+ This feature hides the jurors votes until the end of the voting period. +
+ {children} +
+ ), + [Group.Eligibility]: ({ children, clearAll }) => ( + + +
+ Jurors Eligibility +
+ This feature determines who can be selected as a juror. +
+ {children} +
+ ), +}; diff --git a/web/src/consts/disputeFeature.ts b/web/src/consts/disputeFeature.ts new file mode 100644 index 000000000..f852047ac --- /dev/null +++ b/web/src/consts/disputeFeature.ts @@ -0,0 +1,226 @@ +export enum Group { + Voting = "Voting", + Eligibility = "Eligibility", +} + +/** A single feature, grouped into categories. has to be atomic. + * For gated, we split them into atomic erc20 and erc1155 */ +export enum Features { + ShieldedVote = "shieldedVote", + ClassicVote = "classicVote", + ClassicEligibility = "classicEligibility", + GatedErc20 = "gatedErc20", + GatedErc1155 = "gatedErc1155", +} + +/** Group of features (like radio buttons per category) */ +export type FeatureGroups = Record; + +/** Definition of a dispute kit */ +export interface DisputeKit { + id: number; + /** + * The feature sets this kit supports. + * Each array represents a valid configuration, and has to be 1:1, + * if either subset matches the selected feature array this dispute kit is selected + */ + featureSets: Features[][]; + + type: "general" | "gated"; +} + +export type DisputeKits = DisputeKit[]; + +// groups +// withing a group only one feature can be selected, we deselect the other one when a new one is selected +// we don't use these directly in here for utils because these need to be filtered based on court selection. +// NOTE: a feature cannot appear in more than one Group +// DEV: the order of features in array , determine the order the radios appear on UI +export const featureGroups: FeatureGroups = { + [Group.Voting]: [Features.ClassicVote, Features.ShieldedVote], + [Group.Eligibility]: [Features.ClassicEligibility, Features.GatedErc20, Features.GatedErc1155], +}; + +// dispute kits +// each array is a unique match, for multiple combinations, add more arrays. +export const disputeKits: DisputeKits = [ + { + id: 1, + featureSets: [[Features.ClassicVote, Features.ClassicEligibility]], + type: "general", + }, // strict + { id: 2, featureSets: [[Features.ShieldedVote, Features.ClassicEligibility]], type: "general" }, // strict + { + id: 3, + // strictly keep the common feature in front and in order. + featureSets: [ + [Features.ClassicVote, Features.GatedErc20], + [Features.ClassicVote, Features.GatedErc1155], + ], + type: "gated", + }, + { + id: 4, + featureSets: [ + [Features.ShieldedVote, Features.GatedErc20], + [Features.ShieldedVote, Features.GatedErc1155], + ], + type: "gated", + }, +]; + +/** Canonical string for a feature set (order-independent) */ +function normalize(features: Features[]): string { + return [...features].sort().join("|"); +} + +/** Check if `a` is exactly the same as `b` (order-insensitive) */ +function arraysEqual(a: Features[], b: Features[]): boolean { + return normalize(a) === normalize(b); +} + +/** + * Toggle a feature, ensuring radio behavior per group + * @returns the updated selected features array + */ +export function toggleFeature(selected: Features[], feature: Features, groups: FeatureGroups): Features[] { + const group = Object.entries(groups).find(([_, feats]) => feats.includes(feature)); + if (!group) return selected; // not found in any group + const [_, features] = group; // <= this is the group we are making selection in currently + + // Remove any feature from this group + const withoutGroup = selected.filter((f) => !features.includes(f)); + + // If it was already selected => deselect + if (selected.includes(feature)) { + return withoutGroup; + } + + // Otherwise => select this one + return [...withoutGroup, feature]; +} + +/** + * Find dispute kits that match the given selection + */ +export function findMatchingKits(selected: Features[], kits: DisputeKits): DisputeKit[] { + return kits.filter((kit) => + kit.featureSets.some( + (set) => arraysEqual(set, selected) // strict exact match + ) + ); +} + +/** + * Ensures that the current selection of features is always in a valid state. + * We use this just to make sure we don't accidently allow user to select an invalid state in handleToggle + * + * "Valid" means: + * - Either matches at least one dispute kit fully, OR + * - Could still be completed into a valid kit (prefix of a valid set). + * + * If the selection is invalid: + * 1. Try removing one conflicting group to recover validity. + * 2. If nothing works, fallback to keeping only the last clicked feature. + * + * @returns A corrected selection that is guaranteed to be valid. + */ +export function ensureValidSmart(selected: Features[], groups: FeatureGroups, kits: DisputeKits): Features[] { + // --- Helper: checks if a candidate is valid or could still become valid --- + function isValidOrPrefix(candidate: Features[]): boolean { + return ( + findMatchingKits(candidate, kits).length > 0 || + kits.some((kit) => + kit.featureSets.some( + (set) => candidate.every((f) => set.includes(f)) // prefix check + ) + ) + ); + } + + // --- Case 1: Current selection is already valid --- + if (isValidOrPrefix(selected)) { + return selected; + } + + // --- Case 2: Try fixing by removing one group at a time --- + for (const [_, features] of Object.entries(groups)) { + const withoutGroup = selected.filter((f) => !features.includes(f)); + if (isValidOrPrefix(withoutGroup)) { + return withoutGroup; + } + } + + // --- Case 3: Fallback to only the last picked feature --- + return selected.length > 0 ? [selected[selected.length - 1]] : []; +} + +// Checks if the candidate if selected, can still lead to a match +function canStillLeadToMatch(candidate: Features[], kits: DisputeKits): boolean { + return kits.some((kit) => + kit.featureSets.some((set) => + // candidate must be subset of this set + candidate.every((f) => set.includes(f)) + ) + ); +} + +/** + * Compute which features should be disabled, + * given the current selection. + */ +export function getDisabledOptions(selected: Features[], groups: FeatureGroups, kits: DisputeKits): Set { + const disabled = new Set(); + + // If nothing is selected => allow all + if (selected.length === 0) { + return disabled; + } + + for (const [_, features] of Object.entries(groups)) { + for (const feature of features) { + const candidate = toggleFeature(selected, feature, groups); + + // Instead of only checking full matches: + const valid = findMatchingKits(candidate, kits).length > 0 || canStillLeadToMatch(candidate, kits); + + if (!valid) { + disabled.add(feature); + } + } + } + + return disabled; +} + +/** + * Features that are visible for a given court, + * based only on the kits that court supports. + */ +export function getVisibleFeaturesForCourt( + supportedKits: number[], + allKits: DisputeKits, + groups: FeatureGroups +): FeatureGroups { + // Get supported kits for this court + const filteredKits = allKits.filter((kit) => supportedKits.includes(kit.id)); + + // Gather all features that appear in these kits + const visible = new Set(); + for (const kit of filteredKits) { + for (const set of kit.featureSets) { + set.forEach((f) => visible.add(f)); + } + } + + // Filter groups => only keep features that are visible + const filteredGroups: FeatureGroups = {}; + for (const [groupName, features] of Object.entries(groups)) { + const visibleFeatures = features.filter((f) => visible.has(f)); + if (visibleFeatures.length > 0) { + filteredGroups[groupName] = visibleFeatures; + } + } + + return filteredGroups; +} diff --git a/web/src/context/NewDisputeContext.tsx b/web/src/context/NewDisputeContext.tsx index 5fc109cef..25fdb1ccd 100644 --- a/web/src/context/NewDisputeContext.tsx +++ b/web/src/context/NewDisputeContext.tsx @@ -4,10 +4,13 @@ import { useLocation } from "react-router-dom"; import { Address } from "viem"; import { DEFAULT_CHAIN } from "consts/chains"; +import { Features } from "consts/disputeFeature"; import { klerosCoreAddress } from "hooks/contracts/generated"; import { useLocalStorage } from "hooks/useLocalStorage"; import { isEmpty, isUndefined } from "utils/index"; +import { DisputeKits } from "src/consts"; + export const MIN_DISPUTE_BATCH_SIZE = 2; export type Answer = { @@ -24,6 +27,12 @@ export type AliasArray = { isValid?: boolean; }; +export type DisputeKitOption = { + text: DisputeKits; + value: number; + gated: boolean; +}; + export type Alias = Record; export interface IDisputeTemplate { answers: Answer[]; @@ -83,6 +92,8 @@ interface INewDisputeContext { setIsBatchCreation: (isBatchCreation: boolean) => void; batchSize: number; setBatchSize: (batchSize?: number) => void; + selectedFeatures: Features[]; + setSelectedFeatures: React.Dispatch>; } const getInitialDisputeData = (): IDisputeData => ({ @@ -118,6 +129,7 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch const [isPolicyUploading, setIsPolicyUploading] = useState(false); const [isBatchCreation, setIsBatchCreation] = useState(false); const [batchSize, setBatchSize] = useLocalStorage("disputeBatchSize", MIN_DISPUTE_BATCH_SIZE); + const [selectedFeatures, setSelectedFeatures] = useState([]); const disputeTemplate = useMemo(() => constructDisputeTemplate(disputeData), [disputeData]); const location = useLocation(); @@ -151,6 +163,8 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch setIsBatchCreation, batchSize, setBatchSize, + selectedFeatures, + setSelectedFeatures, }), [ disputeData, @@ -163,6 +177,8 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch setIsBatchCreation, batchSize, setBatchSize, + selectedFeatures, + setSelectedFeatures, ] ); diff --git a/web/src/hooks/queries/useCourtDetails.ts b/web/src/hooks/queries/useCourtDetails.ts index 46795d763..5cfa7a2e9 100644 --- a/web/src/hooks/queries/useCourtDetails.ts +++ b/web/src/hooks/queries/useCourtDetails.ts @@ -26,6 +26,7 @@ const courtDetailsQuery = graphql(` timesPerPeriod feeForJuror name + hiddenVotes } } `); diff --git a/web/src/pages/Resolver/Parameters/Court.tsx b/web/src/pages/Resolver/Parameters/Court.tsx deleted file mode 100644 index df4767f9d..000000000 --- a/web/src/pages/Resolver/Parameters/Court.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import React, { useMemo, useEffect } from "react"; -import styled, { css } from "styled-components"; - -import { AlertMessage, Checkbox, DropdownCascader, DropdownSelect, Field } from "@kleros/ui-components-library"; - -import { DisputeKits } from "consts/index"; -import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; -import { rootCourtToItems, useCourtTree } from "hooks/queries/useCourtTree"; -import { useDisputeKitAddressesAll } from "hooks/useDisputeKitAddresses"; -import { useERC20ERC721Validation, useERC1155Validation } from "hooks/useTokenAddressValidation"; -import { isUndefined } from "utils/index"; - -import { useSupportedDisputeKits } from "queries/useSupportedDisputeKits"; - -import { landscapeStyle } from "styles/landscapeStyle"; -import { responsiveSize } from "styles/responsiveSize"; - -import { StyledSkeleton } from "components/StyledSkeleton"; -import Header from "pages/Resolver/Header"; - -import NavigationButtons from "../NavigationButtons"; - -const Container = styled.div` - display: flex; - flex-direction: column; - align-items: center; - - ${landscapeStyle( - () => css` - padding-bottom: 115px; - ` - )} -`; - -const StyledDropdownCascader = styled(DropdownCascader)` - width: 84vw; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} - > button { - width: 100%; - } -`; - -const AlertMessageContainer = styled.div` - width: 84vw; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} - margin-top: 24px; -`; - -const StyledDropdownSelect = styled(DropdownSelect)` - width: 84vw; - margin-top: 24px; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} -`; - -const StyledField = styled(Field)` - width: 84vw; - margin-top: 24px; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} - > small { - margin-top: 16px; - } -`; - -const StyledCheckbox = styled(Checkbox)` - width: 84vw; - margin-top: 24px; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} -`; - -const ValidationContainer = styled.div` - width: 84vw; - display: flex; - align-items: left; - gap: 8px; - margin-top: 8px; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} -`; - -const ValidationIcon = styled.div<{ $isValid?: boolean | null; $isValidating?: boolean }>` - width: 16px; - height: 16px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - - ${({ $isValidating, $isValid }) => { - if ($isValidating) { - return css` - border: 2px solid ${({ theme }) => theme.stroke}; - border-top-color: ${({ theme }) => theme.primaryBlue}; - animation: spin 1s linear infinite; - - @keyframes spin { - to { - transform: rotate(360deg); - } - } - `; - } - - if ($isValid === true) { - return css` - background-color: ${({ theme }) => theme.success}; - color: white; - &::after { - content: "✓"; - } - `; - } - - if ($isValid === false) { - return css` - background-color: ${({ theme }) => theme.error}; - color: white; - &::after { - content: "✗"; - } - `; - } - - return css` - display: none; - `; - }} -`; - -const ValidationMessage = styled.small<{ $isError?: boolean }>` - color: ${({ $isError, theme }) => ($isError ? theme.error : theme.success)}; - font-size: 14px; - font-style: italic; - font-weight: normal; -`; - -const StyledFieldWithValidation = styled(StyledField)<{ $isValid?: boolean | null }>` - > input { - border-color: ${({ $isValid, theme }) => { - if ($isValid === true) return theme.success; - if ($isValid === false) return theme.error; - return "inherit"; - }}; - } -`; - -const Court: React.FC = () => { - const { disputeData, setDisputeData } = useNewDisputeContext(); - const { data: courtTree } = useCourtTree(); - const { data: supportedDisputeKits } = useSupportedDisputeKits(disputeData.courtId); - const items = useMemo(() => !isUndefined(courtTree?.court) && [rootCourtToItems(courtTree.court)], [courtTree]); - const { availableDisputeKits } = useDisputeKitAddressesAll(); - - const disputeKitOptions = useMemo(() => { - return ( - supportedDisputeKits?.court?.supportedDisputeKits.map((dk) => { - const text = availableDisputeKits[dk.address.toLowerCase()] ?? ""; - return { - text, - value: Number(dk.id), - gated: text === DisputeKits.Gated || text === DisputeKits.GatedShutter, - }; - }) || [] - ); - }, [supportedDisputeKits, availableDisputeKits]); - - const isGatedDisputeKit = useMemo(() => { - const options = disputeKitOptions.find((dk) => String(dk.value) === String(disputeData.disputeKitId)); - return options?.gated ?? false; - }, [disputeKitOptions, disputeData.disputeKitId]); - - // Token validation for token gate address (conditional based on ERC1155 checkbox) - const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; - const isERC1155 = (disputeData.disputeKitData as IGatedDisputeData)?.isERC1155 ?? false; - const validationEnabled = isGatedDisputeKit && !!tokenGateAddress.trim(); - - const { - isValidating: isValidatingERC20, - isValid: isValidERC20, - error: validationErrorERC20, - } = useERC20ERC721Validation({ - address: tokenGateAddress, - enabled: validationEnabled && !isERC1155, - }); - - const { - isValidating: isValidatingERC1155, - isValid: isValidERC1155, - error: validationErrorERC1155, - } = useERC1155Validation({ - address: tokenGateAddress, - enabled: validationEnabled && isERC1155, - }); - - // Combine validation results based on token type - const isValidating = isERC1155 ? isValidatingERC1155 : isValidatingERC20; - const isValidToken = isERC1155 ? isValidERC1155 : isValidERC20; - const validationError = isERC1155 ? validationErrorERC1155 : validationErrorERC20; - - // Update validation state in dispute context - useEffect(() => { - if (isGatedDisputeKit && disputeData.disputeKitData) { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - if (currentData.isTokenGateValid !== isValidToken) { - setDisputeData({ - ...disputeData, - disputeKitData: { ...currentData, isTokenGateValid: isValidToken }, - }); - } - } - }, [isValidToken, isGatedDisputeKit, disputeData.disputeKitData, setDisputeData]); - - const handleCourtChange = (courtId: string) => { - if (disputeData.courtId !== courtId) { - setDisputeData({ ...disputeData, courtId, disputeKitId: undefined }); - } - }; - - const handleDisputeKitChange = (newValue: string | number) => { - const options = disputeKitOptions.find((dk) => String(dk.value) === String(newValue)); - const gatedDisputeKitData: IGatedDisputeData | undefined = - (options?.gated ?? false) - ? { - type: "gated", - tokenGate: "", - isERC1155: false, - tokenId: "0", - } - : undefined; - setDisputeData({ ...disputeData, disputeKitId: Number(newValue), disputeKitData: gatedDisputeKitData }); - }; - - const handleTokenAddressChange = (event: React.ChangeEvent) => { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - setDisputeData({ - ...disputeData, - disputeKitData: { - ...currentData, - tokenGate: event.target.value, - isTokenGateValid: null, // Reset validation state when address changes - }, - }); - }; - - const handleERC1155TokenChange = (event: React.ChangeEvent) => { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - setDisputeData({ - ...disputeData, - disputeKitData: { - ...currentData, - isERC1155: event.target.checked, - isTokenGateValid: null, // Reset validation state when token type changes - }, - }); - }; - - const handleTokenIdChange = (event: React.ChangeEvent) => { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - setDisputeData({ - ...disputeData, - disputeKitData: { ...currentData, tokenId: event.target.value }, - }); - }; - - return ( - -
- {items ? ( - typeof path === "string" && handleCourtChange(path.split("/").pop()!)} - placeholder="Select Court" - value={`/courts/${disputeData.courtId}`} - /> - ) : ( - - )} - {disputeData?.courtId && disputeKitOptions.length > 0 && ( - - )} - {isGatedDisputeKit && ( - <> - - {tokenGateAddress.trim() !== "" && ( - - - - {isValidating && `Validating ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token...`} - {validationError && validationError} - {isValidToken === true && `Valid ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token`} - - - )} - - {(disputeData.disputeKitData as IGatedDisputeData)?.isERC1155 && ( - - )} - - )} - - - - - - ); -}; - -export default Court; diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/FeatureSkeleton.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/FeatureSkeleton.tsx new file mode 100644 index 000000000..3e38e8716 --- /dev/null +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/FeatureSkeleton.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import styled from "styled-components"; + +import Skeleton from "react-loading-skeleton"; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 16px; +`; + +const HeaderContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const HeaderSkeleton = styled(Skeleton)` + width: 25%; + height: 22px; +`; + +const SubHeaderSkeleton = styled(Skeleton)` + width: 75%; + height: 19px; +`; + +const RadioSkeleton = styled(Skeleton)` + width: 20%; + height: 19px; +`; + +const FeatureSkeleton: React.FC = () => { + return ( + + + + + + + + ); +}; + +export default FeatureSkeleton; diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx new file mode 100644 index 000000000..8c0d342ae --- /dev/null +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx @@ -0,0 +1,180 @@ +import React, { Fragment, useEffect, useMemo } from "react"; +import styled from "styled-components"; + +import { Card } from "@kleros/ui-components-library"; + +import { + disputeKits, + ensureValidSmart, + featureGroups, + Features, + findMatchingKits, + getDisabledOptions, + getVisibleFeaturesForCourt, + Group, + toggleFeature, +} from "consts/disputeFeature"; +import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; + +import { useSupportedDisputeKits } from "queries/useSupportedDisputeKits"; + +import { isUndefined } from "src/utils"; + +import { FeatureUIs } from "components/DisputeFeatures/Features"; +import { GroupsUI } from "components/DisputeFeatures/GroupsUI"; + +import FeatureSkeleton from "./FeatureSkeleton"; + +const Container = styled(Card)` + width: 100%; + height: auto; + padding: 32px; + display: flex; + flex-direction: column; + margin-top: 16px; +`; + +const SubTitle = styled.p` + font-size: 18px; + color: ${({ theme }) => theme.secondaryBlue}; + padding: 0; + margin: 0; +`; + +const Separator = styled.hr` + width: 100%; +`; +const FeatureSelection: React.FC = () => { + const { + disputeData, + setDisputeData, + selectedFeatures: selected, + setSelectedFeatures: setSelected, + } = useNewDisputeContext(); + const { data: supportedDisputeKits } = useSupportedDisputeKits(disputeData.courtId); + + // DEV: initial feature selection logic, included hardcoded logic + useEffect(() => { + if (!isUndefined(disputeData?.disputeKitId)) { + const defaultKit = disputeKits.find((dk) => dk.id === disputeData.disputeKitId); + if (!defaultKit) return; + + // some kits like gated can have two feature sets, one for gatedERC20 and other for ERC1155 + if (defaultKit?.featureSets.length > 1) { + if ((disputeData?.disputeKitData as IGatedDisputeData)?.isERC1155) { + // defaultKit.featureSets[0][0] - is either Classic or Shutter + setSelected([defaultKit.featureSets[0][0], Features.GatedErc1155]); + } else { + setSelected([defaultKit.featureSets[0][0], Features.GatedErc20]); + } + } else if (defaultKit.featureSets.length === 1) { + setSelected(defaultKit.featureSets[0]); + } + } + }, []); + + const allowedDisputeKits = useMemo(() => { + if (!supportedDisputeKits?.court?.supportedDisputeKits) return []; + const allowedIds = supportedDisputeKits.court.supportedDisputeKits.map((dk) => Number(dk.id)); + return disputeKits.filter((kit) => allowedIds.includes(kit.id)); + }, [supportedDisputeKits]); + + // Court specific groups + const courtGroups = useMemo(() => { + const courtKits = supportedDisputeKits?.court?.supportedDisputeKits.map((dk) => Number(dk.id)); + if (isUndefined(courtKits) || allowedDisputeKits.length === 0) return {}; + return getVisibleFeaturesForCourt(courtKits, allowedDisputeKits, featureGroups); + }, [supportedDisputeKits, allowedDisputeKits]); + + const disabled = useMemo( + () => getDisabledOptions(selected, courtGroups, allowedDisputeKits), + [selected, courtGroups, allowedDisputeKits] + ); + + const matchingKits = useMemo(() => findMatchingKits(selected, allowedDisputeKits), [selected, allowedDisputeKits]); + + const handleToggle = (feature: Features) => { + setSelected((prev) => { + const toggled = toggleFeature(prev, feature, courtGroups); + // we don't necessarily need ensureValidSmart here, + // but in case a bug allows picking a disabled option, this will correct that + return ensureValidSmart(toggled, courtGroups, allowedDisputeKits); + }); + }; + + const handleGroupDisable = (group: Group) => { + const groupFeatures = courtGroups[group]; + // we have a feature selected from this group + for (const feature of groupFeatures) { + if (selected.includes(feature)) { + // turn off this feature + handleToggle(feature); + } + } + }; + + // if each group only has one feature, select them by default + // This should not clash with the initial selection logic, + // as it only runs when there's one disputeKit and featureSet to pick + useEffect(() => { + // if only one disputeKit is found, and that dk has only one featureSEt to pick, then select by default + if (allowedDisputeKits.length === 1 && allowedDisputeKits[0].featureSets.length === 1) { + setSelected(allowedDisputeKits[0].featureSets[0]); + } + }, [allowedDisputeKits, setSelected]); + + useEffect(() => { + // work of feature selection ends here by giving us the disputeKitId, + // any further checks we do separately, like for NextButton + // right now we don't have kits that can have same features, so we assume it will be 1 length array + if (matchingKits.length === 1) { + const selectedKit = matchingKits[0]; + + setDisputeData({ + ...disputeData, + disputeKitId: selectedKit.id, + disputeKitData: + selectedKit.type === "general" + ? undefined + : ({ ...disputeData.disputeKitData, type: selectedKit.type } as IGatedDisputeData), + }); + } else if (matchingKits.length === 0) { + setDisputeData({ ...disputeData, disputeKitId: undefined }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [matchingKits]); + + return ( + + Features in this Court + + {Object.entries(courtGroups).length > 0 ? ( + Object.entries(courtGroups).map(([groupName, features], index) => ( + <> + {GroupsUI[groupName]({ + clearAll: () => handleGroupDisable(groupName as Group), + children: ( + + {features.map((feature) => + FeatureUIs[feature]({ + name: groupName, + checked: selected.includes(feature), + disabled: disabled.has(feature), + onClick: () => handleToggle(feature), + value: feature, + }) + )} + + ), + })} + {index !== Object.entries(courtGroups).length - 1 ? : null} + + )) + ) : ( + + )} + + ); +}; + +export default FeatureSelection; diff --git a/web/src/pages/Resolver/Parameters/Court/index.tsx b/web/src/pages/Resolver/Parameters/Court/index.tsx new file mode 100644 index 000000000..758f167bc --- /dev/null +++ b/web/src/pages/Resolver/Parameters/Court/index.tsx @@ -0,0 +1,93 @@ +import React, { useMemo } from "react"; +import styled, { css } from "styled-components"; + +import { AlertMessage, DropdownCascader } from "@kleros/ui-components-library"; + +import { useNewDisputeContext } from "context/NewDisputeContext"; +import { rootCourtToItems, useCourtTree } from "hooks/queries/useCourtTree"; +import { isUndefined } from "utils/index"; + +import { landscapeStyle } from "styles/landscapeStyle"; +import { responsiveSize } from "styles/responsiveSize"; + +import { StyledSkeleton } from "components/StyledSkeleton"; +import Header from "pages/Resolver/Header"; + +import NavigationButtons from "../../NavigationButtons"; + +import FeatureSelection from "./FeatureSelection"; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + ${landscapeStyle( + () => css` + padding-bottom: 115px; + ` + )} +`; + +const StyledDropdownCascader = styled(DropdownCascader)` + width: 84vw; + ${landscapeStyle( + () => css` + width: ${responsiveSize(442, 700, 900)}; + ` + )} + > button { + width: 100%; + } +`; + +const AlertMessageContainer = styled.div` + width: 84vw; + ${landscapeStyle( + () => css` + width: ${responsiveSize(442, 700, 900)}; + ` + )} + margin-top: 24px; +`; + +const Court: React.FC = () => { + const { disputeData, setDisputeData, setSelectedFeatures } = useNewDisputeContext(); + const { data: courtTree } = useCourtTree(); + const items = useMemo(() => !isUndefined(courtTree?.court) && [rootCourtToItems(courtTree.court)], [courtTree]); + + const handleCourtChange = (courtId: string) => { + if (disputeData.courtId !== courtId) { + setDisputeData({ ...disputeData, courtId, disputeKitId: undefined, disputeKitData: undefined }); + setSelectedFeatures([]); + } + }; + + return ( + +
+ {items ? ( + typeof path === "string" && handleCourtChange(path.split("/").pop()!)} + placeholder="Select Court" + value={`/courts/${disputeData.courtId}`} + /> + ) : ( + + )} + + + + + {isUndefined(disputeData.courtId) ? null : } + + + ); +}; + +export default Court;