& {
+ 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' && (
+
+ }
+ onClick={handleTriggerStartTerm}
+ >
+ {t('startTerm')}
+
+ )}
+
+
+ );
+}
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 (
+
+
+
+ );
+}
+
+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() {
}
iconSpacing={0}
onClick={async () => {
@@ -75,6 +100,7 @@ export function RoleFormPaymentStreams() {
cancelModal();
setFieldValue('roleEditing.roleEditingPaymentIndex', undefined);
}}
+ roleTerms={roleTerms}
payment={{
streamId: payment.streamId,
amount: payment.amount,
diff --git a/src/components/pages/Roles/forms/RoleFormSectionTitle.tsx b/src/components/pages/Roles/forms/RoleFormSectionTitle.tsx
index b1416321e0..8ea34e368d 100644
--- a/src/components/pages/Roles/forms/RoleFormSectionTitle.tsx
+++ b/src/components/pages/Roles/forms/RoleFormSectionTitle.tsx
@@ -43,7 +43,12 @@ export function SectionTitle({
>
{title}
- {tooltipContent && }
+ {tooltipContent && (
+
+ )}
diff --git a/src/components/pages/Roles/forms/RoleFormTabs.tsx b/src/components/pages/Roles/forms/RoleFormTabs.tsx
index 3e1cd49300..2b68336a93 100644
--- a/src/components/pages/Roles/forms/RoleFormTabs.tsx
+++ b/src/components/pages/Roles/forms/RoleFormTabs.tsx
@@ -10,11 +10,13 @@ import { useNetworkConfig } from '../../../../providers/NetworkConfig/NetworkCon
import { useRolesStore } from '../../../../store/roles/useRolesStore';
import { EditBadgeStatus, RoleFormValues, RoleHatFormValue } from '../types';
import RoleFormInfo from './RoleFormInfo';
+import { RoleFormMember } from './RoleFormMember';
import RoleFormPaymentStream from './RoleFormPaymentStream';
+import RoleFormPaymentStreamTermed from './RoleFormPaymentStreamTermed';
import { RoleFormPaymentStreams } from './RoleFormPaymentStreams';
import { useRoleFormEditedRole } from './useRoleFormEditedRole';
-export default function RoleFormTabs({
+export function RoleFormTabs({
hatId,
pushRole,
blocker,
@@ -58,7 +60,13 @@ export default function RoleFormTabs({
if (!daoAddress) return null;
if (values.roleEditing?.roleEditingPaymentIndex !== undefined) {
- return ;
+ if (values.roleEditing?.isTermed) {
+ return (
+
+ );
+ } else {
+ return ;
+ }
}
return (
@@ -66,12 +74,16 @@ export default function RoleFormTabs({
{t('roleInfo')}
+ {t('member')}
{t('payments')}
+
+
+
@@ -93,7 +105,10 @@ export default function RoleFormTabs({
if (isRoleUpdated || editedRoleData.status === EditBadgeStatus.New) {
setFieldValue(`hats.${hatIndex}`, roleUpdated);
} else if (existingRoleHat !== undefined) {
- setFieldValue(`hats.${hatIndex}`, existingRoleHat);
+ setFieldValue(`hats.${hatIndex}`, {
+ ...existingRoleHat,
+ roleTerms: existingRoleHat.roleTerms.allTerms,
+ });
}
}
setFieldValue('roleEditing', undefined);
diff --git a/src/components/pages/Roles/forms/RoleFormTerms.tsx b/src/components/pages/Roles/forms/RoleFormTerms.tsx
new file mode 100644
index 0000000000..f38dd27f25
--- /dev/null
+++ b/src/components/pages/Roles/forms/RoleFormTerms.tsx
@@ -0,0 +1,386 @@
+import {
+ Accordion,
+ AccordionButton,
+ AccordionItem,
+ AccordionPanel,
+ Box,
+ Button,
+ Flex,
+ FormControl,
+ Icon,
+ IconButton,
+ Text,
+} from '@chakra-ui/react';
+import { CaretDown, CaretRight, Plus, X } from '@phosphor-icons/react';
+import { Field, FieldProps, useFormikContext } from 'formik';
+import { useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'sonner';
+import { getAddress, Hex } from 'viem';
+import { usePublicClient } from 'wagmi';
+import { DETAILS_BOX_SHADOW } from '../../../../constants/common';
+import { useRolesStore } from '../../../../store/roles/useRolesStore';
+import { DatePicker } from '../../../ui/forms/DatePicker';
+import { AddressInput } from '../../../ui/forms/EthAddressInput';
+import LabelWrapper from '../../../ui/forms/LabelWrapper';
+import RoleTerm from '../RoleTerm';
+import { RoleFormTermStatus, RoleFormValues } from '../types';
+
+function RoleTermEndDateInput({ previousTermEndDate }: { previousTermEndDate: Date | undefined }) {
+ const { t } = useTranslation('roles');
+ return (
+
+
+ {({ field, meta, form: { setFieldValue } }: FieldProps) => (
+
+ setFieldValue(field.name, date)}
+ disabled={false}
+ minDate={previousTermEndDate ?? new Date(new Date().setHours(0, 0, 0, 0))}
+ maxDate={undefined}
+ selectedDate={field.value}
+ />
+
+ )}
+
+
+ );
+}
+
+function RoleTermMemberInput() {
+ const { t } = useTranslation('roles');
+ return (
+
+
+ {({
+ field,
+ form: { setFieldValue, setFieldTouched },
+ meta,
+ }: FieldProps) => (
+
+ {
+ setFieldTouched(field.name, true);
+ }}
+ onChange={e => {
+ setFieldValue(field.name, e.target.value);
+ }}
+ />
+
+ )}
+
+
+ );
+}
+
+function RoleTermCreate({
+ onClose,
+ previousTermEndDate,
+ termIndex,
+}: {
+ termIndex: number;
+ previousTermEndDate: Date | undefined;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation('roles');
+ const { values, errors, setFieldValue } = useFormikContext();
+ const publicClient = usePublicClient();
+
+ const handleAddTerm = async () => {
+ if (!values.newRoleTerm?.nominee || !values.newRoleTerm?.termEndDate) {
+ throw new Error('Nominee and Term End Date are required');
+ }
+ if (!publicClient) {
+ throw new Error('Public client is not available');
+ }
+ let nomineeAddress = values.newRoleTerm.nominee;
+ if (!values.newRoleTerm.nominee.startsWith('0x') && !getAddress(values.newRoleTerm.nominee)) {
+ const ens = await publicClient.getEnsAddress({
+ name: values.newRoleTerm.nominee,
+ });
+ if (ens) {
+ nomineeAddress = ens;
+ }
+ }
+
+ setFieldValue('roleEditing.roleTerms', [
+ ...(values?.roleEditing?.roleTerms || []),
+ {
+ nominee: nomineeAddress,
+ termEndDate: values.newRoleTerm.termEndDate,
+ termNumber: termIndex + 1,
+ },
+ ]);
+ toast.info(t('termSavedSuccessfully'));
+ onClose();
+ };
+ return (
+
+
+ {t('termNumber', { number: termIndex + 1 })}
+
+ {
+ // remove term from the form
+ setFieldValue(
+ 'roleEditing.roleTerms',
+ values?.roleEditing?.roleTerms?.filter((_, index) => index !== termIndex),
+ );
+ onClose();
+ }}
+ />
+
+
+
+
+
+
+
+ );
+}
+
+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}
+
+
+
+
+
+
+ >
+ );
+}
+
+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}
|