diff --git a/.env b/.env index c53893e9cb..7e6f9ce92c 100644 --- a/.env +++ b/.env @@ -57,3 +57,4 @@ VITE_APP_WALLET_CONNECT_PROJECT_ID="" # FEATURE FLAGS (Must equal "ON") VITE_APP_FLAG_DEVELOPMENT_MODE="" +VITE_APP_FLAG_TERMED_ROLES="" diff --git a/package-lock.json b/package-lock.json index 8d878479bc..5686474fc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "decent-interface", - "version": "0.3.10", + "version": "0.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "decent-interface", - "version": "0.3.10", + "version": "0.3.11", "hasInstallScript": true, "dependencies": { "@amplitude/analytics-browser": "^2.11.1", @@ -3697,6 +3697,7 @@ "cpu": [ "ppc64" ], + "license": "MIT", "optional": true, "os": [ "aix" @@ -3712,6 +3713,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -3727,6 +3729,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -3742,6 +3745,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -3757,6 +3761,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -3772,6 +3777,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -3787,6 +3793,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -3802,6 +3809,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -3817,6 +3825,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3832,6 +3841,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3847,6 +3857,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3862,6 +3873,7 @@ "cpu": [ "loong64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3877,6 +3889,7 @@ "cpu": [ "mips64el" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3892,6 +3905,7 @@ "cpu": [ "ppc64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3907,6 +3921,7 @@ "cpu": [ "riscv64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3922,6 +3937,7 @@ "cpu": [ "s390x" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3937,6 +3953,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -3952,6 +3969,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -3967,6 +3985,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -3982,6 +4001,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "sunos" @@ -3997,6 +4017,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -4012,6 +4033,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -4027,6 +4049,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -16703,6 +16726,7 @@ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -46284,6 +46308,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz", "integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==", + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.39", diff --git a/package.json b/package.json index e7458b2ab8..18c300ff7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "decent-interface", - "version": "0.3.10", + "version": "0.3.11", "private": true, "dependencies": { "@amplitude/analytics-browser": "^2.11.1", diff --git a/src/assets/abi/HatsElectionsEligibilityAbi.ts b/src/assets/abi/HatsElectionsEligibilityAbi.ts new file mode 100644 index 0000000000..f47dda9849 --- /dev/null +++ b/src/assets/abi/HatsElectionsEligibilityAbi.ts @@ -0,0 +1,235 @@ +export const HatsElectionsEligibilityAbi = [ + { + inputs: [{ internalType: 'string', name: '_version', type: 'string' }], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [{ internalType: 'uint128', name: 'termEnd', type: 'uint128' }], + name: 'ElectionClosed', + type: 'error', + }, + { inputs: [], name: 'InvalidTermEnd', type: 'error' }, + { inputs: [], name: 'NextTermNotReady', type: 'error' }, + { inputs: [], name: 'NotAdmin', type: 'error' }, + { inputs: [], name: 'NotBallotBox', type: 'error' }, + { inputs: [], name: 'NotElected', type: 'error' }, + { inputs: [], name: 'TermEnded', type: 'error' }, + { inputs: [], name: 'TermNotEnded', type: 'error' }, + { inputs: [], name: 'TooManyWinners', type: 'error' }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint128', + name: 'termEnd', + type: 'uint128', + }, + { + indexed: false, + internalType: 'address[]', + name: 'winners', + type: 'address[]', + }, + ], + name: 'ElectionCompleted', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint128', + name: 'nextTermEnd', + type: 'uint128', + }, + ], + name: 'ElectionOpened', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint8', + name: 'version', + type: 'uint8', + }, + ], + name: 'Initialized', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint128', + name: 'termEnd', + type: 'uint128', + }, + ], + name: 'NewTermStarted', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint128', + name: 'termEnd', + type: 'uint128', + }, + { + indexed: false, + internalType: 'address[]', + name: 'accounts', + type: 'address[]', + }, + ], + name: 'Recalled', + type: 'event', + }, + { + inputs: [], + name: 'ADMIN_HAT', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [], + name: 'BALLOT_BOX_HAT', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [], + name: 'HATS', + outputs: [{ internalType: 'contract IHats', name: '', type: 'address' }], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [], + name: 'IMPLEMENTATION', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [], + name: 'currentTermEnd', + outputs: [{ internalType: 'uint128', name: '', type: 'uint128' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint128', name: '_termEnd', type: 'uint128' }, + { internalType: 'address[]', name: '_winners', type: 'address[]' }, + ], + name: 'elect', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint128', name: 'termEnd', type: 'uint128' }, + { internalType: 'address', name: 'candidates', type: 'address' }, + ], + name: 'electionResults', + outputs: [{ internalType: 'bool', name: 'elected', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint128', name: 'termEnd', type: 'uint128' }], + name: 'electionStatus', + outputs: [{ internalType: 'bool', name: 'isElectionOpen', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '_wearer', type: 'address' }, + { internalType: 'uint256', name: '', type: 'uint256' }, + ], + name: 'getWearerStatus', + outputs: [ + { internalType: 'bool', name: 'eligible', type: 'bool' }, + { internalType: 'bool', name: 'standing', type: 'bool' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'hatId', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [], + name: 'nextTermEnd', + outputs: [{ internalType: 'uint128', name: '', type: 'uint128' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint128', name: '_termEnd', type: 'uint128' }, + { + internalType: 'address[]', + name: '_recallees', + type: 'address[]', + }, + ], + name: 'recall', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint128', name: '_newTermEnd', type: 'uint128' }], + name: 'setNextTerm', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: '_initData', type: 'bytes' }], + name: 'setUp', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'startNextTerm', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'version', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'version_', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/assets/theme/components/tabs/index.ts b/src/assets/theme/components/tabs/index.ts index c5352673ef..f3070c085a 100644 --- a/src/assets/theme/components/tabs/index.ts +++ b/src/assets/theme/components/tabs/index.ts @@ -19,6 +19,7 @@ const twoToneVariant = definePartsStyle({ padding: '0.5rem 1rem', width: { base: 'full', md: 'fit-content' }, borderRadius: '0.25rem', + whiteSpace: 'nowrap', color: 'neutral-6', _selected: { background: 'neutral-2', diff --git a/src/assets/theme/custom/icons/DecentHourGlass.tsx b/src/assets/theme/custom/icons/DecentHourGlass.tsx new file mode 100644 index 0000000000..29a90667ef --- /dev/null +++ b/src/assets/theme/custom/icons/DecentHourGlass.tsx @@ -0,0 +1,58 @@ +import { ComponentWithAs, createIcon, IconProps } from '@chakra-ui/react'; + +export const DecentHourGlass: ComponentWithAs<'svg', IconProps> = createIcon({ + displayName: 'DecentHourGlass', + viewBox: '0 0 46 68', + path: ( + + + + + + + + + + + + + ), +}); diff --git a/src/assets/theme/index.ts b/src/assets/theme/index.ts index c6cbe06ec3..d0252faa57 100644 --- a/src/assets/theme/index.ts +++ b/src/assets/theme/index.ts @@ -20,6 +20,10 @@ export const theme = mergeThemeOverride({ initialColorMode: 'dark', useSystemColorMode: false, }, + shadows: { + layeredShadowBorder: + '0px 0px 0px 1px #100414, inset 0px 0px 0px 1px rgba(248, 244, 252, 0.04), inset 0px 1px 0px rgba(248, 244, 252, 0.04)', + }, styles, breakpoints, colors, diff --git a/src/components/Proposals/ProposalActions/CastVote.tsx b/src/components/Proposals/ProposalActions/CastVote.tsx index c16278c444..0df5ae89d1 100644 --- a/src/components/Proposals/ProposalActions/CastVote.tsx +++ b/src/components/Proposals/ProposalActions/CastVote.tsx @@ -64,6 +64,7 @@ export function CastVote({ proposal }: { proposal: FractalProposal }) { azoriusProposal.state !== FractalProposalState.ACTIVE || proposalStartBlockNotFinalized || canVoteLoading || + hasVoted || hasVotedLoading; if (snapshotProposal && extendedSnapshotProposal) { diff --git a/src/components/Proposals/ProposalVotes/context/VoteContext.tsx b/src/components/Proposals/ProposalVotes/context/VoteContext.tsx index 6c72dff4fd..de999aab5c 100644 --- a/src/components/Proposals/ProposalVotes/context/VoteContext.tsx +++ b/src/components/Proposals/ProposalVotes/context/VoteContext.tsx @@ -1,5 +1,13 @@ import { abis } from '@fractal-framework/fractal-contracts'; -import { useContext, useCallback, useEffect, useState, createContext, ReactNode } from 'react'; +import { + useContext, + useCallback, + useEffect, + useState, + createContext, + ReactNode, + useRef, +} from 'react'; import { getContract } from 'viem'; import { usePublicClient } from 'wagmi'; import useSnapshotProposal from '../../../../hooks/DAO/loaders/snapshot/useSnapshotProposal'; @@ -140,10 +148,16 @@ export function VoteContextProvider({ safe?.owners, ], ); + + const initialLoadRef = useRef(false); useEffect(() => { + // Prevent running this effect multiple times + if (initialLoadRef.current) return; + initialLoadRef.current = true; + getCanVote(); getHasVoted(); - }, [getCanVote, getHasVoted, proposalVotesLength]); + }, [getCanVote, getHasVoted]); useEffect(() => { const azoriusProposal = proposal as AzoriusProposal; diff --git a/src/components/pages/Roles/DatePickerTrigger.tsx b/src/components/pages/Roles/DatePickerTrigger.tsx index 0e5298771c..0727c50b80 100644 --- a/src/components/pages/Roles/DatePickerTrigger.tsx +++ b/src/components/pages/Roles/DatePickerTrigger.tsx @@ -30,7 +30,9 @@ export function DatePickerTrigger({ selectedDate, disabled }: DatePickerTriggerP boxSize="24px" color="neutral-5" /> - {selectedDateStr ?? t('select')} + + {selectedDateStr ?? t('select')} + ); } diff --git a/src/components/pages/Roles/EditBadge.tsx b/src/components/pages/Roles/EditBadge.tsx index 2c56907981..bc439a7839 100644 --- a/src/components/pages/Roles/EditBadge.tsx +++ b/src/components/pages/Roles/EditBadge.tsx @@ -13,7 +13,7 @@ export default function EditBadge({ editStatus }: EditBadgeProps) { const displayText = t(BadgeStatus[editStatus]); return ( - - - + {isTermed && ( + + )} ); diff --git a/src/components/pages/Roles/RoleDetailsTabs.tsx b/src/components/pages/Roles/RoleDetailsTabs.tsx new file mode 100644 index 0000000000..7a3c0c44f1 --- /dev/null +++ b/src/components/pages/Roles/RoleDetailsTabs.tsx @@ -0,0 +1,143 @@ +import { Tabs, TabList, Tab, TabPanels, TabPanel, Divider, Text } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Address, Hex } from 'viem'; +import { isFeatureEnabled } from '../../../constants/common'; +import { + paymentSorterByWithdrawAmount, + paymentSorterByStartDate, + paymentSorterByActiveStatus, +} from '../../../store/roles/rolesStoreUtils'; +import NoDataCard from '../../ui/containers/NoDataCard'; +import { RolePaymentDetails } from './RolePaymentDetails'; +import RoleTermDetails from './RoleTermDetails'; +import { SablierPayment } from './types'; + +type RoleTermDetailProp = { + termEndDate: Date; + termNumber: number; + nominee: string; +}; + +type CurrentTermProp = RoleTermDetailProp & { termStatus: 'active' | 'inactive' }; + +function RolesDetailsPayments({ + payments, + roleHatSmartAccountAddress, + roleHatWearerAddress, + roleHatId, + roleTerms, +}: { + payments: (Omit & { + contractAddress?: Address; + streamId?: string; + })[]; + roleHatId: Hex | undefined; + roleHatWearerAddress: Address | undefined; + roleHatSmartAccountAddress: Address | undefined; + roleTerms: RoleTermDetailProp[]; +}) { + const { t } = useTranslation(['roles']); + const sortedPayments = useMemo( + () => + payments + ? [...payments] + .sort(paymentSorterByWithdrawAmount) + .sort(paymentSorterByStartDate) + .sort(paymentSorterByActiveStatus) + : [], + [payments], + ); + + if (!sortedPayments.length) { + return ( + + ); + } + + return ( + <> + + + {t('payments')} + + {sortedPayments.map((payment, index) => ( + + ))} + + ); +} + +export default function RoleDetailsTabs({ + roleTerms, + hatId, + roleHatWearerAddress, + roleHatSmartAccountAddress, + sortedPayments, +}: { + hatId: Hex | undefined; + roleTerms: { + currentTerm: CurrentTermProp | undefined; + nextTerm: RoleTermDetailProp | undefined; + expiredTerms: RoleTermDetailProp[]; + allTerms: RoleTermDetailProp[]; + }; + roleHatWearerAddress: Address | undefined; + roleHatSmartAccountAddress: Address | undefined; + sortedPayments: (Omit & { + contractAddress?: Address; + streamId?: string; + })[]; +}) { + const { t } = useTranslation(['roles']); + return ( + + + {isFeatureEnabled('TERMED_ROLES') && {t('terms')}} + {t('payments')} + + + {isFeatureEnabled('TERMED_ROLES') && ( + + + + )} + + + + + + ); +} diff --git a/src/components/pages/Roles/RolePaymentDetails.tsx b/src/components/pages/Roles/RolePaymentDetails.tsx index 645b5f3cf4..d3ac7ad121 100644 --- a/src/components/pages/Roles/RolePaymentDetails.tsx +++ b/src/components/pages/Roles/RolePaymentDetails.tsx @@ -1,10 +1,10 @@ import { Box, Button, Flex, Grid, GridItem, Icon, Image, Show, Tag, Text } from '@chakra-ui/react'; -import { CalendarBlank, Download, Trash } from '@phosphor-icons/react'; +import { CalendarBlank, Download, Link, Trash } from '@phosphor-icons/react'; import { format } from 'date-fns'; import { TouchEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { Address, getAddress } from 'viem'; +import { Address, getAddress, Hex } from 'viem'; import { useAccount, usePublicClient } from 'wagmi'; import { DETAILS_BOX_SHADOW } from '../../../constants/common'; import { DAO_ROUTES } from '../../../constants/routes'; @@ -49,6 +49,39 @@ function PaymentDate({ label, date }: { label: string; date?: Date }) { ); } +function TermedAssigned({ termNumber }: { termNumber: number }) { + const { t } = useTranslation(['roles']); + return ( + + + {t('assigned')} + + + + + {t('termNumber', { number: termNumber })} + + + + ); +} + function GreenStreamingDot({ isStreaming }: { isStreaming: boolean }) { if (!isStreaming) { return null; @@ -66,10 +99,13 @@ function GreenStreamingDot({ isStreaming }: { isStreaming: boolean }) { interface RolePaymentDetailsProps { roleHatWearerAddress?: Address; - roleHatSmartAddress?: Address; + roleHatSmartAccountAddress?: Address; + roleHatId?: Hex; + roleTerms: { termEndDate: Date; termNumber: number; nominee: string }[]; payment: { streamId?: string; contractAddress?: Address; + recipient?: Address; asset: { logo: string; symbol: string; @@ -96,9 +132,11 @@ export function RolePaymentDetails({ onClick, showWithdraw, roleHatWearerAddress, - roleHatSmartAddress, + roleHatSmartAccountAddress, + roleHatId, showCancel, onCancel, + roleTerms, }: RolePaymentDetailsProps) { const { t } = useTranslation(['roles']); const { @@ -111,22 +149,33 @@ export function RolePaymentDetails({ const navigate = useNavigate(); const publicClient = usePublicClient(); const canWithdraw = useMemo(() => { - if (connectedAccount && connectedAccount === roleHatWearerAddress && !!showWithdraw) { + if (connectedAccount && connectedAccount === payment.recipient && !!showWithdraw) { return true; } return false; - }, [connectedAccount, showWithdraw, roleHatWearerAddress]); + }, [connectedAccount, payment.recipient, showWithdraw]); + + const assignedTerm = useMemo(() => { + return roleTerms.find(term => term.termEndDate.getTime() === payment.endDate.getTime()); + }, [payment.endDate, roleTerms]); const [modalType, props] = useMemo(() => { if ( !payment.streamId || !payment.contractAddress || !roleHatWearerAddress || - !roleHatSmartAddress || - !publicClient + !publicClient || + !roleHatId ) { return [ModalType.NONE] as const; } + let recipient = roleHatWearerAddress; + if (assignedTerm) { + if (!assignedTerm.nominee) { + throw new Error('Assigned term nominee is missing'); + } + recipient = getAddress(assignedTerm.nominee); + } return [ ModalType.WITHDRAW_PAYMENT, { @@ -135,16 +184,23 @@ export function RolePaymentDetails({ paymentAssetDecimals: payment.asset.decimals, paymentStreamId: payment.streamId, paymentContractAddress: payment.contractAddress, - onSuccess: () => - refreshWithdrawableAmount(roleHatSmartAddress, payment.streamId!, publicClient), + onSuccess: () => refreshWithdrawableAmount(roleHatId, payment.streamId!, publicClient), withdrawInformation: { withdrawableAmount: payment.withdrawableAmount, - roleHatWearerAddress, - roleHatSmartAddress, + recipient, + roleHatSmartAccountAddress, }, }, ] as const; - }, [payment, roleHatSmartAddress, roleHatWearerAddress, refreshWithdrawableAmount, publicClient]); + }, [ + payment, + roleHatSmartAccountAddress, + roleHatWearerAddress, + refreshWithdrawableAmount, + publicClient, + roleHatId, + assignedTerm, + ]); const withdraw = useDecentModal(modalType, props); @@ -370,10 +426,14 @@ export function RolePaymentDetails({ templateColumns="1fr 24px 1fr 24px 1fr" > - + {assignedTerm ? ( + + ) : ( + + )} + {children} + + ); +} + +function RoleTermHeaderTitle({ + termNumber, + termPosition, +}: { + termNumber: number; + termPosition?: 'currentTerm' | 'nextTerm'; +}) { + const { t } = useTranslation(['roles']); + + return ( + + {t('termNumber', { number: termNumber })} + + {!!termPosition && t(termPosition)} + + + ); +} + +function RoleTermHeaderStatus({ + termEndDate, + termStatus, +}: { + termEndDate: Date; + termStatus: RoleFormTermStatus; +}) { + const { t } = useTranslation(['roles']); + const dateDisplay = useDateTimeDisplay(termEndDate); + + const statusText = useMemo(() => { + const statusTextData = { + ended: { + text: t('ended'), + textColor: 'neutral-6', + iconColor: 'neutral-6', + }, + inQueue: { + text: t('inQueue'), + textColor: 'neutral-7', + iconColor: 'lilac-0', + }, + pending: { + text: t('pending'), + textColor: 'neutral-7', + iconColor: 'lilac-0', + }, + readyToStart: { + text: t('readyToStart'), + textColor: 'neutral-7', + iconColor: 'lilac-0', + }, + active: { + text: dateDisplay, + textColor: 'neutral-7', + iconColor: 'lilac-0', + }, + revoked: { + text: t('revoked'), + textColor: 'red-1', + iconColor: 'red-1', + }, + }; + + switch (termStatus) { + case RoleFormTermStatus.Expired: + return statusTextData.ended; + case RoleFormTermStatus.Queued: + return statusTextData.inQueue; + case RoleFormTermStatus.Pending: + return statusTextData.pending; + case RoleFormTermStatus.Current: + return statusTextData.active; + case RoleFormTermStatus.ReadyToStart: + return statusTextData.readyToStart; + default: + return { + text: undefined, + textColor: undefined, + iconColor: undefined, + }; + } + }, [dateDisplay, termStatus, t]); + return ( + + + + {statusText.text} + + + ); +} + +function RoleTermHeader({ + termNumber, + termEndDate, + termStatus, + termPosition, + displayLightContainer, +}: { + termNumber: number; + termEndDate: Date; + termStatus: RoleFormTermStatus; + termPosition?: 'currentTerm' | 'nextTerm'; + displayLightContainer?: boolean; +}) { + return ( + + + + + + + ); +} + +function RoleTermMemberAddress({ memberAddress }: { memberAddress: Address }) { + const { t } = useTranslation(['roles', 'common']); + const { displayName: accountDisplayName } = useGetAccountName(memberAddress); + const avatarURL = useAvatar(memberAddress); + const copyToClipboard = useCopyText(); + return ( + + + {t('member')} + + + + + + + ); +} + +function RoleTermEndDate({ termEndDate }: { termEndDate: Date }) { + const { t } = useTranslation(['roles']); + return ( + + + {t('ending')} + + + + + {format(termEndDate, DEFAULT_DATE_TIME_FORMAT_NO_TZ)} + + + + ); +} + +export default function RoleTerm({ + hatId, + termNominatedWearer, + termEndDate, + termStatus, + termNumber, + displayLightContainer, +}: { + hatId: Hex | undefined; + termNominatedWearer: Address; + termEndDate: Date; + termNumber: number; + termStatus: RoleFormTermStatus; + displayLightContainer?: boolean; +}) { + const [contractCall, contractCallPending] = useTransaction(); + const { hatsTree, getHat, updateCurrentTermStatus } = useRolesStore(); + const { data: walletClient } = useWalletClient(); + const { t } = useTranslation(['roles']); + const { + contracts: { hatsProtocol }, + } = useNetworkConfig(); + + const roleHat = useMemo(() => { + if (!hatId) return undefined; + return getHat(hatId); + }, [getHat, hatId]); + + const termPosition = useMemo(() => { + if (!roleHat) return undefined; + const currentTermEndDate = roleHat.roleTerms.currentTerm?.termEndDate; + const nextTermEndDate = roleHat.roleTerms.nextTerm?.termEndDate; + if (currentTermEndDate && termEndDate.getTime() === currentTermEndDate.getTime()) + return 'currentTerm'; + if (nextTermEndDate && termEndDate.getTime() === nextTermEndDate.getTime()) return 'nextTerm'; + }, [roleHat, termEndDate]); + + const wearerAddress = roleHat?.wearerAddress; + + const handleTriggerStartTerm = useCallback(async () => { + const adminHatWearer = hatsTree?.adminHat.wearer; + + if (!wearerAddress) { + throw new Error('Current hat must be worn by a member'); + } + if (adminHatWearer === undefined) { + throw new Error('Admin hat must be worn by Decent Autonomous Admin'); + } + if (!walletClient) { + throw new Error('Public client not found'); + } + if (!roleHat) { + throw new Error('roleHat not found'); + } + const eligibilityAddress = roleHat.eligibility; + if (!eligibilityAddress) { + throw new Error('Election eligibility contract not found'); + } + + const [currentTerm, previousTerm] = roleHat.roleTerms.allTerms.sort( + (a, b) => a.termNumber - b.termNumber, + ); + const decentAutonomousAdminContract = getContract({ + abi: abis.DecentAutonomousAdminV1, + address: adminHatWearer, + client: walletClient, + }); + + contractCall({ + contractFn: () => { + if (getAddress(previousTerm.nominee) === getAddress(currentTerm.nominee)) { + const electionsContract = getContract({ + abi: HatsElectionsEligibilityAbi, + address: eligibilityAddress, + client: walletClient, + }); + return electionsContract.write.startNextTerm(); + } else { + return decentAutonomousAdminContract.write.triggerStartNextTerm([ + { + currentWearer: wearerAddress, + hatsProtocol, + hatId: BigInt(roleHat.id), + nominatedWearer: termNominatedWearer, + }, + ]); + } + }, + pendingMessage: t('startTermPendingToastMessage'), + failedMessage: t('startTermFailureToastMessage'), + successMessage: t('startTermSuccessToastMessage'), + successCallback: () => { + updateCurrentTermStatus(roleHat.id, 'active'); + }, + }); + }, [ + contractCall, + hatsProtocol, + hatsTree?.adminHat.wearer, + roleHat, + t, + termNominatedWearer, + updateCurrentTermStatus, + walletClient, + wearerAddress, + ]); + + return ( + + + + + + + + {roleHat?.roleTerms.currentTerm?.termStatus === 'inactive' && + termPosition === 'currentTerm' && ( + + )} + + + ); +} diff --git a/src/components/pages/Roles/RoleTermDetails.tsx b/src/components/pages/Roles/RoleTermDetails.tsx new file mode 100644 index 0000000000..108d507bf3 --- /dev/null +++ b/src/components/pages/Roles/RoleTermDetails.tsx @@ -0,0 +1,180 @@ +import { + Box, + Accordion, + AccordionItem, + AccordionButton, + Flex, + Icon, + AccordionPanel, + Text, +} from '@chakra-ui/react'; +import { CaretDown, CaretRight } from '@phosphor-icons/react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getAddress, Hex } from 'viem'; +import NoDataCard from '../../ui/containers/NoDataCard'; +import RoleTerm from './RoleTerm'; +import { RoleFormTermStatus } from './types'; + +type RoleTermDetailProp = { + nominee: string; + termEndDate: Date; + termNumber: number; +}; + +type CurrentTermProp = RoleTermDetailProp & { termStatus: 'active' | 'inactive' }; + +function RoleTermRenderer({ + roleTerm, + termStatus, + displayLightContainer, + hatId, +}: { + hatId: Hex | undefined; + roleTerm?: RoleTermDetailProp; + termStatus: RoleFormTermStatus; + displayLightContainer?: boolean; +}) { + if (!roleTerm?.nominee || !roleTerm?.termEndDate) { + return null; + } + return ( + + ); +} + +function RoleTermExpiredTerms({ + hatId, + roleTerms, +}: { + hatId: Hex | undefined; + roleTerms?: RoleTermDetailProp[]; +}) { + const { t } = useTranslation('roles'); + if (!roleTerms?.length) { + return null; + } + return ( + + + + {({ isExpanded }) => ( + <> + + + + + {t('showPreviousTerms')} + + + + + {roleTerms.map((term, index) => { + return ( + + + + ); + })} + + + )} + + + + ); +} + +export default function RoleTermDetails({ + hatId, + currentTerm, + nextTerm, + expiredTerms, +}: { + hatId: Hex | undefined; + nextTerm: RoleTermDetailProp | undefined; + currentTerm: CurrentTermProp | undefined; + expiredTerms: RoleTermDetailProp[]; +}) { + const currentTermStatus = useMemo(() => { + if (!!currentTerm) { + if (currentTerm.termStatus === 'inactive') { + return RoleFormTermStatus.ReadyToStart; + } + return RoleFormTermStatus.Current; + } + return RoleFormTermStatus.Pending; + }, [currentTerm]); + + return ( + + {!currentTerm && ( + + )} + {/* Next Term */} + + {/* Current Term */} + + + + ); +} diff --git a/src/components/pages/Roles/RolesDetailsDrawer.tsx b/src/components/pages/Roles/RolesDetailsDrawer.tsx index 984c5699b6..94256568a6 100644 --- a/src/components/pages/Roles/RolesDetailsDrawer.tsx +++ b/src/components/pages/Roles/RolesDetailsDrawer.tsx @@ -26,7 +26,7 @@ import { import { BarLoader } from '../../ui/loaders/BarLoader'; import Avatar from '../../ui/page/Header/Avatar'; import Divider from '../../ui/utils/Divider'; -import { RolePaymentDetails } from './RolePaymentDetails'; +import RoleDetailsTabs from './RoleDetailsTabs'; import { RoleDetailsDrawerProps } from './types'; function RoleAndDescriptionLabel({ label, icon }: { label: string; icon: React.ElementType }) { @@ -177,29 +177,17 @@ export default function RolesDetailsDrawer({ - {roleHat.payments && ( - <> - - - {t('payments')} - - {sortedPayments.map((payment, index) => ( - - ))} - - )} + + diff --git a/src/components/pages/Roles/RolesDetailsDrawerMobile.tsx b/src/components/pages/Roles/RolesDetailsDrawerMobile.tsx index f2ff45e5dd..6d712f60b1 100644 --- a/src/components/pages/Roles/RolesDetailsDrawerMobile.tsx +++ b/src/components/pages/Roles/RolesDetailsDrawerMobile.tsx @@ -11,9 +11,8 @@ import { } from '../../../store/roles/rolesStoreUtils'; import { useRolesStore } from '../../../store/roles/useRolesStore'; import DraggableDrawer from '../../ui/containers/DraggableDrawer'; -import Divider from '../../ui/utils/Divider'; import { AvatarAndRoleName } from './RoleCard'; -import { RolePaymentDetails } from './RolePaymentDetails'; +import RoleDetailsTabs from './RoleDetailsTabs'; import { RoleDetailsDrawerProps } from './types'; export default function RolesDetailsDrawerMobile({ @@ -98,31 +97,13 @@ export default function RolesDetailsDrawerMobile({ px="1rem" mb="1.5rem" > - {roleHat.payments && ( - <> - - - {t('payments')} - - {sortedPayments.map((payment, index) => ( - - ))} - - )} + ); diff --git a/src/components/pages/Roles/RolesTable.tsx b/src/components/pages/Roles/RolesTable.tsx index 8d10ce0558..15013e6914 100644 --- a/src/components/pages/Roles/RolesTable.tsx +++ b/src/components/pages/Roles/RolesTable.tsx @@ -92,14 +92,22 @@ function RoleNameEditColumn({ ); } -function MemberColumn({ wearerAddress }: { wearerAddress?: Address }) { +function MemberColumn({ + wearerAddress, + currentRoleTermStatus, + isTermed, +}: { + isTermed?: boolean; + wearerAddress?: Address; + currentRoleTermStatus?: 'active' | 'inactive'; +}) { const { displayName: accountDisplayName } = useGetAccountName(wearerAddress); const avatarURL = useAvatar(accountDisplayName); const { t } = useTranslation('roles'); return ( - + {wearerAddress ? ( ) : ( )} {wearerAddress ? accountDisplayName : t('unassigned')} @@ -159,7 +167,14 @@ function PaymentsColumn({ paymentsCount }: { paymentsCount?: number }) { ); } -export function RolesRow({ name, wearerAddress, paymentsCount, handleRoleClick }: RoleProps) { +export function RolesRow({ + name, + wearerAddress, + paymentsCount, + handleRoleClick, + currentRoleTermStatus, + isTermed, +}: RoleProps) { return ( {name} - + ); @@ -255,6 +274,8 @@ export function RolesTable({ name={role.name} wearerAddress={role.wearerAddress} handleRoleClick={() => handleRoleClick(role.id)} + currentRoleTermStatus={role.roleTerms.currentTerm?.termStatus} + isTermed={role.isTermed} paymentsCount={ role.payments === undefined ? undefined diff --git a/src/components/pages/Roles/forms/RoleFormAssetSelector.tsx b/src/components/pages/Roles/forms/RoleFormAssetSelector.tsx index 8e9c1938dd..43ed6a9683 100644 --- a/src/components/pages/Roles/forms/RoleFormAssetSelector.tsx +++ b/src/components/pages/Roles/forms/RoleFormAssetSelector.tsx @@ -300,6 +300,7 @@ export function AssetSelector({ formIndex, disabled }: { formIndex: number; disa return ( void }) return values.hats .filter(hat => !!hat.editedRole) .map(roleHat => { - if (!roleHat.wearer || !roleHat.name || !roleHat.description || !roleHat.editedRole) { + if (!roleHat.name || !roleHat.description || !roleHat.editedRole) { throw new Error('Role missing data', { cause: roleHat, }); } - + const allRoleTerms = + roleHat.roleTerms?.map(term => { + if (!term.termEndDate || term.nominee === undefined || term.termNumber === undefined) { + throw new Error('Role term missing data', { + cause: term, + }); + } + return { + termEndDate: term.termEndDate, + nominee: getAddress(term.nominee), + termNumber: term.termNumber, + }; + }) || []; + const roleTerms = { + allTerms: allRoleTerms, + currentTerm: drawerViewingRole?.roleTerms.currentTerm, + nextTerm: drawerViewingRole?.roleTerms.nextTerm, + expiredTerms: allRoleTerms.filter(term => term.termEndDate <= new Date()), + }; + const termedNominee = drawerViewingRole?.roleTerms.currentTerm?.nominee; + const wearer = + roleHat.isTermed && !!termedNominee + ? termedNominee + : !!roleHat?.wearer + ? getAddress(roleHat.wearer) + : undefined; + if (!wearer) { + throw new Error('Role missing wearer', { + cause: roleHat, + }); + } return { ...roleHat, editedRole: roleHat.editedRole, prettyId: roleHat.id, name: roleHat.name, description: roleHat.description, - wearer: roleHat.wearer, + wearer, + roleTerms, + isTermed: roleHat.isTermed ?? false, payments: roleHat.payments ? roleHat.payments.map(payment => { if (!payment.startDate || !payment.endDate || !payment.amount || !payment.asset) { @@ -109,6 +141,7 @@ export default function RoleFormCreateProposal({ close }: { close: () => void }) } return { ...payment, + recipient: wearer, startDate: payment.startDate, endDate: payment.endDate, amount: payment.amount, @@ -121,7 +154,11 @@ export default function RoleFormCreateProposal({ close }: { close: () => void }) : [], }; }); - }, [values.hats]); + }, [ + drawerViewingRole?.roleTerms.currentTerm, + drawerViewingRole?.roleTerms.nextTerm, + values.hats, + ]); const { node: { daoAddress }, diff --git a/src/components/pages/Roles/forms/RoleFormInfo.tsx b/src/components/pages/Roles/forms/RoleFormInfo.tsx index 9289617de5..958baa57ef 100644 --- a/src/components/pages/Roles/forms/RoleFormInfo.tsx +++ b/src/components/pages/Roles/forms/RoleFormInfo.tsx @@ -1,31 +1,14 @@ import { Box, FormControl } from '@chakra-ui/react'; import { Field, FieldInputProps, FieldMetaProps, FormikProps, useFormikContext } from 'formik'; -import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DETAILS_BOX_SHADOW } from '../../../../constants/common'; -import useAddress from '../../../../hooks/utils/useAddress'; -import { useGetAccountName } from '../../../../hooks/utils/useGetAccountName'; -import { AddressInput } from '../../../ui/forms/EthAddressInput'; import { InputComponent, TextareaComponent } from '../../../ui/forms/InputComponent'; -import LabelWrapper from '../../../ui/forms/LabelWrapper'; import { RoleFormValues } from '../types'; export default function RoleFormInfo() { const { t } = useTranslation('roles'); - const [roleWearerString, setRoleWearerString] = useState(''); - const { address: resolvedWearerAddress, isValid: isValidWearerAddress } = - useAddress(roleWearerString); - - const { setFieldValue, values } = useFormikContext(); - - useEffect(() => { - if (isValidWearerAddress) { - setFieldValue('roleEditing.resolvedWearer', resolvedWearerAddress); - } - }, [isValidWearerAddress, resolvedWearerAddress, setFieldValue]); - - const { displayName } = useGetAccountName(values.roleEditing?.resolvedWearer, false); + const { setFieldValue } = useFormikContext(); return ( - - - {({ - field, - form: { setFieldTouched }, - meta, - }: { - field: FieldInputProps; - form: FormikProps; - meta: FieldMetaProps; - }) => ( - - { - setFieldTouched('roleEditing.wearer', true); - }} - onChange={e => { - const inputWearer = e.target.value; - setRoleWearerString(inputWearer); - setFieldValue('roleEditing.wearer', inputWearer); - }} - /> - - )} - - ); } diff --git a/src/components/pages/Roles/forms/RoleFormMember.tsx b/src/components/pages/Roles/forms/RoleFormMember.tsx new file mode 100644 index 0000000000..9f8850e42d --- /dev/null +++ b/src/components/pages/Roles/forms/RoleFormMember.tsx @@ -0,0 +1,362 @@ +import { + Box, + Button, + Flex, + FormControl, + Hide, + Icon, + Switch, + Text, + useDisclosure, +} from '@chakra-ui/react'; +import { + ClockCountdown, + HandCoins, + ListPlus, + ReceiptX, + Warning, + WarningDiamond, +} from '@phosphor-icons/react'; +import { FieldInputProps, FormikProps, FieldMetaProps, useFormikContext, Field } from 'formik'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DecentHourGlass } from '../../../../assets/theme/custom/icons/DecentHourGlass'; +import { DETAILS_BOX_SHADOW, isFeatureEnabled } from '../../../../constants/common'; +import useAddress from '../../../../hooks/utils/useAddress'; +import { useGetAccountName } from '../../../../hooks/utils/useGetAccountName'; +import DraggableDrawer from '../../../ui/containers/DraggableDrawer'; +import { AddressInput } from '../../../ui/forms/EthAddressInput'; +import LabelWrapper from '../../../ui/forms/LabelWrapper'; +import { ModalBase } from '../../../ui/modals/ModalBase'; +import { RoleFormValues } from '../types'; +import RoleFormTerms from './RoleFormTerms'; + +function RoleMemberWearerInput() { + const { t } = useTranslation('roles'); + + const [roleWearerString, setRoleWearerString] = useState(''); + const { address: resolvedWearerAddress, isValid: isValidWearerAddress } = + useAddress(roleWearerString); + + const { setFieldValue, values } = useFormikContext(); + useEffect(() => { + if (isValidWearerAddress) { + setFieldValue('roleEditing.resolvedWearer', resolvedWearerAddress); + } + }, [isValidWearerAddress, resolvedWearerAddress, setFieldValue]); + + const { displayName } = useGetAccountName(values.roleEditing?.resolvedWearer, false); + return ( + + + {({ + field, + form: { setFieldTouched }, + meta, + }: { + field: FieldInputProps; + form: FormikProps; + meta: FieldMetaProps; + }) => ( + + { + setFieldTouched('roleEditing.wearer', true); + }} + onChange={e => { + const inputWearer = e.target.value; + setRoleWearerString(inputWearer); + setFieldValue('roleEditing.wearer', inputWearer); + }} + /> + + )} + + + ); +} + +function RoleMemberConfirmationScreen({ + onConfirmClick, + onCancelClick, +}: { + onConfirmClick: () => void; + onCancelClick: () => void; +}) { + const { t } = useTranslation(['roles', 'common']); + return ( + + + + + {t('addTermLengthTitle')} + + + + + + {t('termedRoleConfirmation-1')} + + + + + + + {t('termedRoleConfirmation-2')} + + + + + + {t('termedRoleConfirmation-3')} + + + + + + {t('termedRoleConfirmation-4')} + + + + + + {t('termedRoleConfirmation-warning')} + + + + + + + + ); +} + +function RoleMemberConfirmationPortal({ + onConfirmClick, + onCancelClick, + isOpen, + onOpen, + onClose, +}: { + onConfirmClick: () => void; + onCancelClick: () => void; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +}) { + return ( + <> + + + + + + + + + + + + ); +} + +function RoleFormMemberTermToggle() { + const { t } = useTranslation('roles'); + const [seenConfirmation, setSeenConfirmation] = useState(false); + const { isOpen, onOpen, onClose } = useDisclosure({}); + const { setFieldValue } = useFormikContext(); + return ( + + + {({ field }: { field: FieldInputProps }) => ( + <> + + + + {t('addTermLengths')} + + + + {t('addTermLengthSubTitle')} + + + + { + if (!seenConfirmation) { + setFieldValue('roleEditing.isTermed', false); + onOpen(); + } else { + field.onChange(e); + } + }} + isChecked={field.value} + /> + + + { + setSeenConfirmation(true); + setFieldValue('roleEditing.isTermed', true); + onClose(); + }} + onCancelClick={onClose} + isOpen={isOpen} + onOpen={onOpen} + onClose={onClose} + /> + + )} + + + ); +} + +export function RoleFormMember() { + const { values } = useFormikContext(); + + if (!!values.roleEditing?.isTermed) { + if (!isFeatureEnabled('TERMED_ROLES')) { + // @prevent any sheningans on different environments + return null; + } + return ; + } + return ( + + + + + {isFeatureEnabled('TERMED_ROLES') && } + + ); +} diff --git a/src/components/pages/Roles/forms/RoleFormPaymentStreamTermed.tsx b/src/components/pages/Roles/forms/RoleFormPaymentStreamTermed.tsx new file mode 100644 index 0000000000..1e90943565 --- /dev/null +++ b/src/components/pages/Roles/forms/RoleFormPaymentStreamTermed.tsx @@ -0,0 +1,419 @@ +import { + Box, + Button, + Flex, + FormControl, + Hide, + Icon, + Menu, + MenuButton, + MenuItem, + MenuList, + Text, +} from '@chakra-ui/react'; +import { Calendar, CaretDown, CheckCircle } from '@phosphor-icons/react'; +import { addDays, format } from 'date-fns'; +import { Field, FieldProps, FormikErrors, useFormikContext } from 'formik'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CARD_SHADOW, DETAILS_BOX_SHADOW } from '../../../../constants/common'; +import { DEFAULT_DATE_TIME_FORMAT_NO_TZ } from '../../../../utils'; +import DraggableDrawer from '../../../ui/containers/DraggableDrawer'; +import { DatePicker } from '../../../ui/forms/DatePicker'; +import LabelWrapper from '../../../ui/forms/LabelWrapper'; +import Divider from '../../../ui/utils/Divider'; +import { RoleFormValues, RoleHatFormValue } from '../types'; +import { AssetSelector } from './RoleFormAssetSelector'; +import { SectionTitle } from './RoleFormSectionTitle'; + +function ShadowedBox({ + hasBorderRadius = true, + children, +}: { + hasBorderRadius?: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function StartDatePicker({ paymentIndex, disabled }: { paymentIndex: number; disabled: boolean }) { + const { t } = useTranslation(['roles']); + const { values } = useFormikContext(); + + const selectedEndDate = values?.roleEditing?.payments?.[paymentIndex]?.endDate; + const terms = useMemo( + () => values?.roleEditing?.roleTerms || [], + [values?.roleEditing?.roleTerms], + ); + const minDate = useMemo(() => { + const [currentTerm, nextTerm] = terms.filter( + term => !!term.termEndDate && term.termEndDate >= new Date(), + ); + // if selected term is next term, start date can be next day after beggining of the term + if (!!nextTerm) { + if (!currentTerm.termEndDate) { + throw new Error('Current Term end date is required'); + } + return addDays(currentTerm.termEndDate, 1); + } + + // if selected term is current term, start date can be now + return new Date(); + }, [terms]); + + return ( + + + + {({ field, form: { setFieldValue } }: FieldProps) => ( + + setFieldValue(field.name, date)} + selectedDate={field.value} + minDate={minDate} + maxDate={selectedEndDate ? addDays(selectedEndDate, -1) : undefined} + disabled={disabled} + /> + + )} + + + ); +} + +function CliffDatePicker({ paymentIndex, disabled }: { paymentIndex: number; disabled: boolean }) { + const { t } = useTranslation(['roles']); + const { values } = useFormikContext(); + + const selectedStartDate = values?.roleEditing?.payments?.[paymentIndex]?.startDate; + const selectedEndDate = values?.roleEditing?.payments?.[paymentIndex]?.endDate; + + return ( + + + + {({ field, form: { setFieldValue } }: FieldProps) => ( + + setFieldValue(field.name, date)} + selectedDate={field.value} + minDate={selectedStartDate ? addDays(selectedStartDate, 1) : undefined} + maxDate={selectedEndDate} + disabled={disabled} + /> + + )} + + + ); +} + +function TermSelection({ + selectedTermNumber, + selectedTermEndDate, + defaultDateColor = 'white-0', +}: { + selectedTermNumber: number; + selectedTermEndDate: Date; + defaultDateColor?: string; +}) { + const { t } = useTranslation(['roles']); + + return ( + + + {t('termNumber', { number: selectedTermNumber })} + + + + + {format(selectedTermEndDate, DEFAULT_DATE_TIME_FORMAT_NO_TZ)} + + + + ); +} + +function TermSelectorMenu({ paymentIndex }: { paymentIndex: number }) { + const { t } = useTranslation(['roles']); + const { values, setFieldValue, validateField } = useFormikContext(); + const [selectedTerm, setSelectedTerm] = useState<{ + termNumber: number; + termEndDate: Date; + } | null>(null); + const roleFormTerms = useMemo( + () => values.roleEditing?.roleTerms || [], + [values.roleEditing?.roleTerms], + ); + + const eligibleTerms = useMemo( + () => + roleFormTerms + .filter(term => term.termEndDate && term.termEndDate >= new Date()) + .map(term => { + if (!term.termEndDate) { + throw new Error('Term end date is required'); + } + return { + termNumber: term.termNumber, + termEndDate: term.termEndDate, + }; + }), + [roleFormTerms], + ); + + useEffect(() => { + const [currentTerm] = roleFormTerms.filter( + term => !!term.termEndDate && term.termEndDate >= new Date(), + ); + if (!selectedTerm && currentTerm) { + if (!currentTerm.termEndDate) { + throw new Error('Term end date is required'); + } + setSelectedTerm({ + termNumber: currentTerm.termNumber, + termEndDate: currentTerm.termEndDate, + }); + } + }, [selectedTerm, roleFormTerms]); + + useEffect(() => { + if (selectedTerm) { + setFieldValue(`roleEditing.payments[${paymentIndex}].endDate`, selectedTerm.termEndDate); + validateField(`roleEditing.payments`); + } + }, [selectedTerm, paymentIndex, setFieldValue, validateField]); + return ( + + + {({ isOpen, onClose }) => ( + <> + + {!!selectedTerm && ( + + + + + )} + + + {}} + headerContent={null} + initialHeight="50%" + closeOnOverlayClick={false} + > + {eligibleTerms.map((term, index) => ( + { + setSelectedTerm(term); + onClose(); + }} + > + + + {t('selectTerm')} + + + + + + + + + + + ))} + + + + + {eligibleTerms.map((term, index) => ( + { + setSelectedTerm(term); + onClose(); + }} + > + + + ))} + + + + )} + + + ); +} + +export default function RoleFormPaymentStreamTermed({ paymentIndex }: { paymentIndex: number }) { + const { t } = useTranslation(['roles']); + const { values, errors, setFieldValue } = useFormikContext(); + const roleEditingPaymentsErrors = (errors.roleEditing as FormikErrors) + ?.payments; + + const streamId = values.roleEditing?.payments?.[paymentIndex]?.streamId; + + return ( + <> + + + + + + + + + + + + + + + + {!streamId && ( + + )} + + + ); +} diff --git a/src/components/pages/Roles/forms/RoleFormPaymentStreams.tsx b/src/components/pages/Roles/forms/RoleFormPaymentStreams.tsx index 50db3952c7..a70e1b07e2 100644 --- a/src/components/pages/Roles/forms/RoleFormPaymentStreams.tsx +++ b/src/components/pages/Roles/forms/RoleFormPaymentStreams.tsx @@ -30,6 +30,30 @@ export function RoleFormPaymentStreams() { [payments], ); + const isTermsAvailable = useMemo(() => { + return values.roleEditing?.roleTerms?.some(term => { + if (!term.termEndDate) { + return false; + } + return term.termEndDate > new Date(); + }); + }, [values.roleEditing?.roleTerms]); + + const roleTerms = useMemo(() => { + const terms = + values.roleEditing?.roleTerms?.map(term => { + if (!term.termEndDate || !term.nominee) { + return undefined; + } + return { + termEndDate: term.termEndDate, + termNumber: term.termNumber, + nominee: term.nominee, + }; + }) || []; + return terms.filter(term => !!term); + }, [values.roleEditing?.roleTerms]); + return ( {({ push: pushPayment }: { push: (streamFormValue: SablierPaymentFormValues) => void }) => ( @@ -37,6 +61,7 @@ export function RoleFormPaymentStreams() { + + + ); +} + +function RoleTermRenderer({ + roleTerm, + termStatus, + hatId, + displayLightContainer, +}: { + roleTerm?: { + nominee?: string; + termEndDate?: Date; + termNumber: number; + }; + termStatus: RoleFormTermStatus; + hatId: Hex | undefined; + displayLightContainer?: boolean; +}) { + if (!roleTerm?.nominee || !roleTerm?.termEndDate) { + return null; + } + return ( + + ); +} + +function RoleTermExpiredTerms({ + hatId, + roleTerms, +}: { + hatId: Hex | undefined; + roleTerms?: { + nominee?: string; + termEndDate?: Date; + termNumber: number; + }[]; +}) { + const { t } = useTranslation('roles'); + if (!roleTerms?.length) { + return null; + } + return ( + + + + {({ isExpanded }) => ( + <> + + + + + {t('showPreviousTerms')} + + + + + {roleTerms.map((term, index) => { + return ( + + + + ); + })} + + + )} + + + + ); +} + +export default function RoleFormTerms() { + const { t } = useTranslation('roles'); + const { values, setFieldValue } = useFormikContext(); + const { getHat } = useRolesStore(); + const roleFormHatId = values.roleEditing?.id; + const roleHatTerms = useMemo(() => { + if (!roleFormHatId) { + return undefined; + } + const hat = getHat(roleFormHatId); + return hat?.roleTerms; + }, [getHat, roleFormHatId]); + + const roleFormTerms = useMemo( + () => values.roleEditing?.roleTerms || [], + [values.roleEditing?.roleTerms], + ); + + // @note only 2 terms should be unexpired at a time + const terms = useMemo( + () => + roleFormTerms.filter(term => !!term.termEndDate && term.termEndDate.getTime() > Date.now()), + [roleFormTerms], + ); + + // @dev shows the term form when there are no terms + useEffect(() => { + if (values.newRoleTerm === undefined && roleFormTerms.length === 0) { + setFieldValue('newRoleTerm', { + nominee: '', + termEndDate: undefined, + }); + } + }, [values.newRoleTerm, setFieldValue, roleFormTerms.length]); + + const isAddButtonDisabled = useMemo(() => { + const isFirstTermBeingCreated = !!values.newRoleTerm || !roleFormTerms.length; + const isTermCreationPending = (roleHatTerms?.allTerms ?? []).length < roleFormTerms.length; + const canCreateNewTerm = terms.length == 2; + return isFirstTermBeingCreated || canCreateNewTerm || isTermCreationPending; + }, [values.newRoleTerm, roleFormTerms.length, roleHatTerms?.allTerms, terms.length]); + + return ( + <> + + + {values.newRoleTerm !== undefined && ( + setFieldValue('newRoleTerm', undefined)} + /> + )} + + + + + + ); +} diff --git a/src/components/pages/Roles/forms/useRoleFormEditedRole.ts b/src/components/pages/Roles/forms/useRoleFormEditedRole.ts index 38e69899fd..e1bef7a74f 100644 --- a/src/components/pages/Roles/forms/useRoleFormEditedRole.ts +++ b/src/components/pages/Roles/forms/useRoleFormEditedRole.ts @@ -1,9 +1,13 @@ import { useFormikContext } from 'formik'; import { useMemo } from 'react'; import { DecentTree } from '../../../../store/roles/rolesStoreUtils'; -import { EditedRole, EditBadgeStatus, RoleFormValues } from '../types'; +import { EditedRole, EditBadgeStatus, RoleFormValues, EditedRoleFieldNames } from '../types'; -const addRemoveField = (fieldNames: string[], fieldName: string, hasChanges: boolean) => { +const addRemoveField = ( + fieldNames: EditedRoleFieldNames[], + fieldName: EditedRoleFieldNames, + hasChanges: boolean, +): EditedRoleFieldNames[] => { if (fieldNames.includes(fieldName) && !hasChanges) { return fieldNames.filter(field => field !== fieldName); } else if (!fieldNames.includes(fieldName) && !hasChanges) { @@ -38,6 +42,19 @@ export function useRoleFormEditedRole({ hatsTree }: { hatsTree: DecentTree | und }); }, [values.roleEditing]); + const isRoleTypeUpdated = useMemo(() => { + const isTermToggled = !!values.roleEditing?.isTermed; + const isExistingRoleNotTerm = !!existingRoleHat && !existingRoleHat.isTermed; + return isExistingRoleNotTerm && isTermToggled; + }, [existingRoleHat, values.roleEditing]); + + const isRoleTermUpdated = useMemo(() => { + return ( + !!existingRoleHat && + values.roleEditing?.roleTerms?.length !== existingRoleHat.roleTerms.allTerms.length + ); + }, [existingRoleHat, values.roleEditing]); + const editedRoleData = useMemo(() => { if (!existingRoleHat) { return { @@ -45,11 +62,13 @@ export function useRoleFormEditedRole({ hatsTree }: { hatsTree: DecentTree | und status: EditBadgeStatus.New, }; } - let fieldNames: string[] = []; + let fieldNames: EditedRoleFieldNames[] = []; fieldNames = addRemoveField(fieldNames, 'roleName', isRoleNameUpdated); fieldNames = addRemoveField(fieldNames, 'roleDescription', isRoleDescriptionUpdated); fieldNames = addRemoveField(fieldNames, 'member', isMemberUpdated); fieldNames = addRemoveField(fieldNames, 'payments', isPaymentsUpdated); + fieldNames = addRemoveField(fieldNames, 'roleType', isRoleTypeUpdated); + fieldNames = addRemoveField(fieldNames, 'newTerm', isRoleTermUpdated); return { fieldNames, @@ -61,13 +80,27 @@ export function useRoleFormEditedRole({ hatsTree }: { hatsTree: DecentTree | und isRoleDescriptionUpdated, isMemberUpdated, isPaymentsUpdated, + isRoleTypeUpdated, + isRoleTermUpdated, ]); const isRoleUpdated = useMemo(() => { return ( - !!isRoleNameUpdated || !!isRoleDescriptionUpdated || !!isMemberUpdated || !!isPaymentsUpdated + !!isRoleNameUpdated || + !!isRoleDescriptionUpdated || + !!isMemberUpdated || + !!isPaymentsUpdated || + !!isRoleTypeUpdated || + !!isRoleTermUpdated ); - }, [isRoleNameUpdated, isRoleDescriptionUpdated, isMemberUpdated, isPaymentsUpdated]); + }, [ + isRoleNameUpdated, + isRoleDescriptionUpdated, + isMemberUpdated, + isPaymentsUpdated, + isRoleTypeUpdated, + isRoleTermUpdated, + ]); return { existingRoleHat, diff --git a/src/components/pages/Roles/types.tsx b/src/components/pages/Roles/types.tsx index 91243842eb..d45b5117bd 100644 --- a/src/components/pages/Roles/types.tsx +++ b/src/components/pages/Roles/types.tsx @@ -6,6 +6,7 @@ import { SendAssetsData } from '../../ui/modals/SendAssetsModal'; export interface SablierPayment { streamId: string; contractAddress: Address; + recipient: Address; asset: { address: Address; name: string; @@ -30,17 +31,22 @@ export interface SablierPaymentFormValues extends Partial { } export interface RoleProps { - editStatus?: EditBadgeStatus; handleRoleClick: () => void; name: string; wearerAddress?: Address; paymentsCount?: number; + isTermed: boolean; + currentRoleTermStatus?: 'active' | 'inactive'; } export interface RoleEditProps - extends Omit { + extends Omit< + RoleProps, + 'hatId' | 'handleRoleClick' | 'paymentsCount' | 'name' | 'currentRoleTermStatus' | 'isTermed' + > { name?: string; handleRoleClick: () => void; + editStatus?: EditBadgeStatus; payments?: SablierPaymentFormValues[]; } @@ -62,25 +68,43 @@ export enum EditBadgeStatus { Updated, New, Removed, + NewTermedRole, + Inactive, } export const BadgeStatus: Record = { [EditBadgeStatus.Updated]: 'updated', [EditBadgeStatus.New]: 'new', [EditBadgeStatus.Removed]: 'removed', + [EditBadgeStatus.NewTermedRole]: 'newTermedRole', + [EditBadgeStatus.Inactive]: 'Inactive', }; export const BadgeStatusColor: Record = { [EditBadgeStatus.Updated]: 'lilac-0', [EditBadgeStatus.New]: 'celery--2', [EditBadgeStatus.Removed]: 'red-1', + [EditBadgeStatus.NewTermedRole]: 'celery--2', + [EditBadgeStatus.Inactive]: 'neutral-6', }; +export interface TermedParams { + termEndDateTs: bigint; + nominatedWearers: Address[]; +} + +export enum RoleFormTermStatus { + ReadyToStart, + Current, + Queued, + Expired, + Pending, +} export interface HatStruct { maxSupply: 1; // No more than this number of wearers. Hardcode to 1 details: string; // IPFS url/hash to JSON { version: '1.0', data: { name, description, ...arbitraryData } } imageURI: string; isMutable: boolean; // true wearer: Address; - termEndDateTs: 0n; + termEndDateTs: bigint; // 0 for non-termed roles } export interface HatStructWithPayments extends HatStruct { @@ -96,13 +120,20 @@ export interface HatStructWithPayments extends HatStruct { }[]; } +export type EditedRoleFieldNames = + | 'roleName' + | 'roleDescription' + | 'member' + | 'payments' + | 'roleType' + | 'newTerm'; export interface EditedRole { - fieldNames: string[]; + fieldNames: EditedRoleFieldNames[]; status: EditBadgeStatus; } export interface RoleHatFormValue - extends Partial> { + extends Partial> { id: Hex; wearer?: string; // Not a user-input field. @@ -112,6 +143,12 @@ export interface RoleHatFormValue // form specific state editedRole?: EditedRole; roleEditingPaymentIndex?: number; + isTermed?: boolean; + roleTerms?: { + nominee?: string; + termEndDate?: Date; + termNumber: number; + }[]; } export interface RoleHatFormValueEdited extends RoleHatFormValue { @@ -124,6 +161,11 @@ export type RoleFormValues = { roleEditing?: RoleHatFormValue; customNonce?: number; actions: SendAssetsData[]; + newRoleTerm?: { + nominee: string; + termEndDate: Date; + termNumber: number; + }; }; export type PreparedNewStreamData = { diff --git a/src/components/ui/containers/NoDataCard.tsx b/src/components/ui/containers/NoDataCard.tsx index 91b27e7ed4..720ccc8c69 100644 --- a/src/components/ui/containers/NoDataCard.tsx +++ b/src/components/ui/containers/NoDataCard.tsx @@ -1,7 +1,6 @@ -import { Text } from '@chakra-ui/react'; +import { Box, Text } from '@chakra-ui/react'; import { useTranslation } from 'react-i18next'; import { useCanUserCreateProposal } from '../../../hooks/utils/useCanUserSubmitProposal'; -import { Card } from '../cards/Card'; export default function NoDataCard({ translationNameSpace, @@ -15,7 +14,12 @@ export default function NoDataCard({ const { t } = useTranslation(translationNameSpace); const { canUserCreateProposal } = useCanUserCreateProposal(); return ( - + - + ); } diff --git a/src/components/ui/forms/DatePicker.tsx b/src/components/ui/forms/DatePicker.tsx new file mode 100644 index 0000000000..72c8a8519c --- /dev/null +++ b/src/components/ui/forms/DatePicker.tsx @@ -0,0 +1,220 @@ +import { + Box, + Button, + Divider, + Flex, + Icon, + Menu, + MenuButton, + MenuItem, + MenuList, + Show, + useBreakpointValue, + useDisclosure, +} from '@chakra-ui/react'; +import { CalendarBlank, CaretLeft, CaretRight } from '@phosphor-icons/react'; +import { format } from 'date-fns'; +import { ReactNode } from 'react'; +import { Calendar } from 'react-calendar'; +import { useTranslation } from 'react-i18next'; +import { SEXY_BOX_SHADOW_T_T } from '../../../constants/common'; +import { DEFAULT_DATE_FORMAT } from '../../../utils'; +import { DatePickerTrigger } from '../../pages/Roles/DatePickerTrigger'; +import DraggableDrawer from '../containers/DraggableDrawer'; + +type DateOrNull = Date | null; +type OnDateChangeValue = DateOrNull | [DateOrNull, DateOrNull]; + +function DateDisplayBox({ date }: { date: Date | undefined }) { + const { t } = useTranslation('common'); + return ( + + + {(date && format(date, DEFAULT_DATE_FORMAT)) ?? t('select')} + + ); +} + +function SelectedDateDisplay({ selectedDate }: { selectedDate: Date | undefined }) { + return ( + + + + ); +} + +const isToday = (date: Date) => { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); +}; + +function DatePickerContainer({ + children, + disabled, + selectedDate, + isOpen, + onOpen, + onClose, +}: { + children: ReactNode[]; + disabled: boolean; + selectedDate: Date | undefined; + isOpen: boolean; + onClose: () => void; + onOpen: () => void; +}) { + const boxShadow = useBreakpointValue({ base: 'none', md: SEXY_BOX_SHADOW_T_T }); + const maxBoxW = useBreakpointValue({ base: '100%', md: '26.875rem' }); + return ( + <> + + + + + + {children} + + + + + + + + + + + + {children} + + + + + + + ); +} + +function TodayBox({ isTodaySelected }: { isTodaySelected: () => boolean }) { + // @dev @todo - This is a workaround to fix an issue with the dot not being centered on the current day. Gotta be a better way to fix this. + const todayDotLeftMargin = useBreakpointValue({ base: '4.5vw', md: '1.15rem' }); + return ( + + ); +} + +export function DatePicker({ + selectedDate, + onChange, + minDate, + maxDate, + disabled, +}: { + onChange: (date: Date) => void; + selectedDate: Date | undefined; + minDate?: Date; + maxDate?: Date; + disabled: boolean; +}) { + const isTodaySelected = () => { + return !!selectedDate ? isToday(selectedDate) : false; + }; + const { isOpen, onClose, onOpen } = useDisclosure(); + return ( + + + + {!disabled && ( + date.toString().slice(0, 2)} + prevLabel={} + nextLabel={} + next2Label={null} + prev2Label={null} + tileContent={({ date }) => + isToday(date) ? : null + } + onChange={(e: OnDateChangeValue) => { + if (e instanceof Date) { + onChange?.(new Date(e.setHours(0, 0, 0, 0))); + onClose(); + } + }} + /> + )} + + ); +} diff --git a/src/components/ui/modals/ModalProvider.tsx b/src/components/ui/modals/ModalProvider.tsx index bc6b4c8e9a..b8723bf07d 100644 --- a/src/components/ui/modals/ModalProvider.tsx +++ b/src/components/ui/modals/ModalProvider.tsx @@ -73,8 +73,8 @@ export type ModalPropsTypes = { paymentStreamId?: string; paymentContractAddress: Address; withdrawInformation: { - roleHatSmartAddress: Address; - roleHatWearerAddress: Address; + roleHatSmartAccountAddress: Address | undefined; + recipient: Address; withdrawableAmount: bigint; }; onSuccess: () => Promise; diff --git a/src/components/ui/modals/PaymentWithdrawModal.tsx b/src/components/ui/modals/PaymentWithdrawModal.tsx index 00c317f495..f3b99145b5 100644 --- a/src/components/ui/modals/PaymentWithdrawModal.tsx +++ b/src/components/ui/modals/PaymentWithdrawModal.tsx @@ -2,15 +2,15 @@ import { Box, Button, Flex, Image, Text, useBreakpointValue } from '@chakra-ui/r import { Download } from '@phosphor-icons/react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; import { Address, encodeFunctionData, getContract } from 'viem'; -import { usePublicClient, useWalletClient } from 'wagmi'; +import { useWalletClient } from 'wagmi'; import HatsAccount1ofNAbi from '../../../assets/abi/HatsAccount1ofN'; import { SablierV2LockupLinearAbi } from '../../../assets/abi/SablierV2LockupLinear'; import { SEXY_BOX_SHADOW_T_T } from '../../../constants/common'; import { convertStreamIdToBigInt } from '../../../hooks/streams/useCreateSablierStream'; import useAvatar from '../../../hooks/utils/useAvatar'; import { useGetAccountName } from '../../../hooks/utils/useGetAccountName'; +import { useTransaction } from '../../../hooks/utils/useTransaction'; import { useNetworkConfig } from '../../../providers/NetworkConfig/NetworkConfigProvider'; import { formatCoin } from '../../../utils'; import Avatar, { AvatarSize } from '../page/Header/Avatar'; @@ -31,19 +31,17 @@ export default function PaymentWithdrawModal({ paymentAssetDecimals: number; paymentContractAddress?: Address; withdrawInformation: { - roleHatSmartAddress: Address; - roleHatWearerAddress: Address; + roleHatSmartAccountAddress: Address | undefined; + recipient: Address; withdrawableAmount: bigint; }; onSuccess: () => Promise; onClose: () => void; }) { - const publicClient = usePublicClient(); const { data: walletClient } = useWalletClient(); + const [contractCall, pendingTransaction] = useTransaction(); const { t } = useTranslation(['roles', 'menu', 'common', 'modals']); - const { displayName: accountDisplayName } = useGetAccountName( - withdrawInformation.roleHatWearerAddress, - ); + const { displayName: accountDisplayName } = useGetAccountName(withdrawInformation.recipient); const avatarURL = useAvatar(accountDisplayName); const iconSize = useBreakpointValue({ base: 'sm', md: 'icon' }) || 'sm'; const { chain } = useNetworkConfig(); @@ -53,53 +51,61 @@ export default function PaymentWithdrawModal({ paymentContractAddress && paymentStreamId && walletClient && - publicClient && - withdrawInformation.roleHatSmartAddress && - withdrawInformation.roleHatWearerAddress + withdrawInformation.recipient ) { - let withdrawToast: string | number | undefined = undefined; - try { - const hatsAccountContract = getContract({ - abi: HatsAccount1ofNAbi, - address: withdrawInformation.roleHatSmartAddress, - client: walletClient, - }); - const bigIntStreamId = convertStreamIdToBigInt(paymentStreamId); - let hatsAccountCalldata = encodeFunctionData({ - abi: SablierV2LockupLinearAbi, - functionName: 'withdrawMax', - args: [bigIntStreamId, withdrawInformation.roleHatWearerAddress], - }); - withdrawToast = toast.loading(t('withdrawPendingMessage')); - const txHash = await hatsAccountContract.write.execute([ - paymentContractAddress, - 0n, - hatsAccountCalldata, - 0, - ]); - const transaction = await publicClient.waitForTransactionReceipt({ hash: txHash }); - if (transaction.status === 'success') { + const sablierV2LockupLinearContract = getContract({ + abi: SablierV2LockupLinearAbi, + address: paymentContractAddress, + client: walletClient, + }); + const bigIntStreamId = convertStreamIdToBigInt(paymentStreamId); + + contractCall({ + contractFn: () => { + if (!withdrawInformation.roleHatSmartAccountAddress) { + return sablierV2LockupLinearContract.write.withdrawMax([ + bigIntStreamId, + withdrawInformation.recipient, + ]); + } + const hatsAccountCalldata = encodeFunctionData({ + abi: SablierV2LockupLinearAbi, + functionName: 'withdrawMax', + args: [bigIntStreamId, withdrawInformation.recipient], + }); + const hatsAccountContract = getContract({ + abi: HatsAccount1ofNAbi, + address: withdrawInformation.roleHatSmartAccountAddress, + client: walletClient, + }); + return hatsAccountContract.write.execute([ + paymentContractAddress, + 0n, + hatsAccountCalldata, + 0, + ]); + }, + pendingMessage: t('withdrawPendingMessage'), + failedMessage: t('withdrawRevertedMessage'), + successMessage: t('withdrawSuccessMessage'), + failedCallback: () => {}, + successCallback: async () => { await onSuccess(); - toast.success(t('withdrawSuccessMessage'), { id: withdrawToast }); onClose(); - } else { - toast.error(t('withdrawRevertedMessage'), { id: withdrawToast }); - } - } catch (e) { - console.error('Error withdrawing from stream', e); - toast.error(t('withdrawErrorMessage'), { id: withdrawToast }); - } + }, + completedCallback: () => {}, + }); } }, [ paymentContractAddress, paymentStreamId, - publicClient, walletClient, + withdrawInformation.roleHatSmartAccountAddress, + withdrawInformation.recipient, + contractCall, + t, onSuccess, onClose, - withdrawInformation.roleHatSmartAddress, - withdrawInformation.roleHatWearerAddress, - t, ]); return ( @@ -194,12 +200,12 @@ export default function PaymentWithdrawModal({ > - {withdrawInformation.roleHatWearerAddress} + {withdrawInformation.recipient}