diff --git a/amplify/backend/api/colonycdapp/schema/schema.graphql b/amplify/backend/api/colonycdapp/schema/schema.graphql index b785263c1df..abd29f02803 100644 --- a/amplify/backend/api/colonycdapp/schema/schema.graphql +++ b/amplify/backend/api/colonycdapp/schema/schema.graphql @@ -4324,4 +4324,4 @@ type UserStake @model { Only applicable for expenditure stakes, indicates if the creator's stake was forfeited when expenditure was cancelled """ isForfeited: Boolean -} +} \ No newline at end of file diff --git a/amplify/backend/backend-config.json b/amplify/backend/backend-config.json index 1cae8639c11..b03ccb9bcd8 100644 --- a/amplify/backend/backend-config.json +++ b/amplify/backend/backend-config.json @@ -281,263 +281,5 @@ "providerPlugin": "awscloudformation", "service": "Lambda" } - }, - "parameters": { - "AMPLIFY_function_colonycdappSSMAccess_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "colonycdappSSMAccess" - } - ] - }, - "AMPLIFY_function_colonycdappSSMAccess_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "colonycdappSSMAccess" - } - ] - }, - "AMPLIFY_function_createColonyEtherealMetadata_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "createColonyEtherealMetadata" - } - ] - }, - "AMPLIFY_function_createColonyEtherealMetadata_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "createColonyEtherealMetadata" - } - ] - }, - "AMPLIFY_function_createPrivateBetaInvite_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "createPrivateBetaInvite" - } - ] - }, - "AMPLIFY_function_createPrivateBetaInvite_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "createPrivateBetaInvite" - } - ] - }, - "AMPLIFY_function_createUniqueUser_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "createUniqueUser" - } - ] - }, - "AMPLIFY_function_createUniqueUser_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "createUniqueUser" - } - ] - }, - "AMPLIFY_function_fetchColonyBalances_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchColonyBalances" - } - ] - }, - "AMPLIFY_function_fetchColonyBalances_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchColonyBalances" - } - ] - }, - "AMPLIFY_function_fetchColonyNativeFundsClaim_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchColonyNativeFundsClaim" - } - ] - }, - "AMPLIFY_function_fetchColonyNativeFundsClaim_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchColonyNativeFundsClaim" - } - ] - }, - "AMPLIFY_function_fetchMotionState_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchMotionState" - } - ] - }, - "AMPLIFY_function_fetchMotionState_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchMotionState" - } - ] - }, - "AMPLIFY_function_fetchMotionTimeoutPeriods_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchMotionTimeoutPeriods" - } - ] - }, - "AMPLIFY_function_fetchMotionTimeoutPeriods_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchMotionTimeoutPeriods" - } - ] - }, - "AMPLIFY_function_fetchTokenFromChain_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchTokenFromChain" - } - ] - }, - "AMPLIFY_function_fetchTokenFromChain_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchTokenFromChain" - } - ] - }, - "AMPLIFY_function_fetchVoterRewards_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchVoterRewards" - } - ] - }, - "AMPLIFY_function_fetchVoterRewards_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "fetchVoterRewards" - } - ] - }, - "AMPLIFY_function_getSafeTransactionStatus_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "getSafeTransactionStatus" - } - ] - }, - "AMPLIFY_function_getSafeTransactionStatus_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "getSafeTransactionStatus" - } - ] - }, - "AMPLIFY_function_getUserReputation_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "getUserReputation" - } - ] - }, - "AMPLIFY_function_getUserReputation_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "getUserReputation" - } - ] - }, - "AMPLIFY_function_getUserTokenBalance_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "getUserTokenBalance" - } - ] - }, - "AMPLIFY_function_getUserTokenBalance_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "getUserTokenBalance" - } - ] - }, - "AMPLIFY_function_qaSSMtest_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "qaSSMtest" - } - ] - }, - "AMPLIFY_function_qaSSMtest_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "qaSSMtest" - } - ] - }, - "AMPLIFY_function_updateContributorsWithReputation_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "updateContributorsWithReputation" - } - ] - }, - "AMPLIFY_function_updateContributorsWithReputation_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "updateContributorsWithReputation" - } - ] - }, - "AMPLIFY_function_validateUserInvite_deploymentBucketName": { - "usedBy": [ - { - "category": "function", - "resourceName": "validateUserInvite" - } - ] - }, - "AMPLIFY_function_validateUserInvite_s3Key": { - "usedBy": [ - { - "category": "function", - "resourceName": "validateUserInvite" - } - ] - } } } \ No newline at end of file diff --git a/src/apollo/cache/index.ts b/src/apollo/cache/index.ts index 21725709115..76c01e8dc0a 100644 --- a/src/apollo/cache/index.ts +++ b/src/apollo/cache/index.ts @@ -151,6 +151,29 @@ const cache = new InMemoryCache({ }; }, }, + getStreamingPaymentsByColony: { + keyArgs: ['$domainId', '$recipientAddress'], + merge(existing = {}, incoming, { args }) { + // remove duplicates from incoming data + const uniqueIncomingItems = Array.from( + new Map( + // eslint-disable-next-line no-underscore-dangle + incoming.items.map((item) => [item.__ref, item]), + ).values(), + ); + + if (!args?.nextToken) { + return { + ...incoming, + items: uniqueIncomingItems, + }; + } + return { + ...incoming, + items: [...existing.items, ...uniqueIncomingItems], + }; + }, + }, }, }, ...cacheUpdates, diff --git a/src/components/common/ColonyActions/helpers/getActionTitleValues.ts b/src/components/common/ColonyActions/helpers/getActionTitleValues.ts index d53b7017027..3c7fb2a0cd5 100644 --- a/src/components/common/ColonyActions/helpers/getActionTitleValues.ts +++ b/src/components/common/ColonyActions/helpers/getActionTitleValues.ts @@ -7,6 +7,7 @@ import { ColonyActionType, type Colony, type Expenditure, + type StreamingPayment, } from '~types/graphql.ts'; import { getExtendedActionType, @@ -43,11 +44,38 @@ export enum ActionTitleMessageKeys { StagedAmount = 'stagedAmount', ArbitraryTransactionsLength = 'arbitraryTransactionsLength', ArbitraryMethod = 'arbitraryMethod', + Period = 'period', } /* Maps actionTypes to message values as found in en-actions.ts */ +/** + * @TODO: Refactor to use comparison instead of includes which is just a partial match on the string + */ const getMessageDescriptorKeys = (actionType: AnyActionType) => { switch (true) { + case actionType === ColonyActionType.Payment: + return [ + ActionTitleMessageKeys.Recipient, + ActionTitleMessageKeys.Amount, + ActionTitleMessageKeys.TokenSymbol, + ActionTitleMessageKeys.Initiator, + ]; + case actionType.includes(ColonyActionType.CreateStreamingPayment): + return [ + ActionTitleMessageKeys.Recipient, + ActionTitleMessageKeys.Amount, + ActionTitleMessageKeys.TokenSymbol, + ActionTitleMessageKeys.Initiator, + ActionTitleMessageKeys.Period, + ]; + case actionType.includes(ColonyActionType.Payment) && + !actionType.includes(ExtendedColonyActionType.SplitPayment): + return [ + ActionTitleMessageKeys.Recipient, + ActionTitleMessageKeys.Amount, + ActionTitleMessageKeys.TokenSymbol, + ActionTitleMessageKeys.Initiator, + ]; case actionType.includes(ColonyActionType.MoveFunds): return [ ActionTitleMessageKeys.Amount, @@ -181,12 +209,14 @@ const useGetActionTitleValues = ({ keyFallbackValues, expenditureData, networkInverseFee, + streamingPaymentData, }: { actionData: ColonyAction | null | undefined; colony: Pick | undefined; keyFallbackValues?: Partial>; expenditureData?: Expenditure; networkInverseFee?: string; + streamingPaymentData?: StreamingPayment; }) => { const { isMotion, pendingColonyMetadata } = actionData || {}; @@ -196,6 +226,7 @@ const useGetActionTitleValues = ({ keyFallbackValues, expenditureData, networkInverseFee, + streamingPaymentData, }); if (!actionData || !colony) { diff --git a/src/components/common/ColonyActions/helpers/mapItemToMessageFormat.tsx b/src/components/common/ColonyActions/helpers/mapItemToMessageFormat.tsx index e6278e5c772..64e54db67ca 100644 --- a/src/components/common/ColonyActions/helpers/mapItemToMessageFormat.tsx +++ b/src/components/common/ColonyActions/helpers/mapItemToMessageFormat.tsx @@ -21,6 +21,7 @@ import { type Expenditure, type ExpenditureStage, type ExpenditureSlot, + type StreamingPayment, } from '~types/graphql.ts'; import { getFormatValuesArbitraryTransactions } from '~utils/arbitraryTxs.ts'; import { notMaybe, notNull } from '~utils/arrays/index.ts'; @@ -30,6 +31,7 @@ import { getAmountLessFee } from '~utils/getAmountLessFee.ts'; import { formatText, intl } from '~utils/intl.ts'; import { formatReputationChange } from '~utils/reputation.ts'; import { getAddedSafeChainName } from '~utils/safes/index.ts'; +import { getAmountPerValue } from '~utils/streamingPayments.ts'; import { getSelectedToken, getTokenDecimalsWithFallback, @@ -214,11 +216,13 @@ export const useMapColonyActionToExpectedFormat = ({ keyFallbackValues = {}, expenditureData, networkInverseFee, + streamingPaymentData, }: { actionData: ColonyAction | null | undefined; colony: Pick | undefined; keyFallbackValues?: Partial>; expenditureData?: Expenditure; + streamingPaymentData?: StreamingPayment; networkInverseFee?: string; }) => { const getFormattedValueWithFallback = ( @@ -305,13 +309,21 @@ export const useMapColonyActionToExpectedFormat = ({ , ActionTitleMessageKeys.Amount, - notMaybe(actionData?.amount), + notMaybe( + streamingPaymentData ? streamingPaymentData.amount : actionData?.amount, + ), ), [ActionTitleMessageKeys.Direction]: formattedRolesTitle, [ActionTitleMessageKeys.FromDomain]: getFormattedValueWithFallback( @@ -334,23 +346,14 @@ export const useMapColonyActionToExpectedFormat = ({ ActionTitleMessageKeys.ToDomain, notMaybe(actionData.toDomain?.metadata?.name), ), - [ActionTitleMessageKeys.TokenSymbol]: getFormattedValueWithFallback( - expenditureData - ? getSelectedToken( - colony, - expenditureData?.slots?.[0]?.payouts?.[0]?.tokenAddress || '', - )?.symbol - : actionData.token?.symbol, - ActionTitleMessageKeys.TokenSymbol, - notMaybe( - expenditureData - ? getSelectedToken( - colony, - expenditureData?.slots?.[0]?.payouts?.[0]?.tokenAddress || '', - )?.symbol - : actionData.token?.symbol, - ), - ), + [ActionTitleMessageKeys.TokenSymbol]: + getSelectedToken( + colony, + expenditureData?.slots?.[0]?.payouts?.[0]?.tokenAddress ?? '', + )?.symbol ?? + getSelectedToken(colony, streamingPaymentData?.tokenAddress ?? '') + ?.symbol ?? + actionData.token?.symbol, [ActionTitleMessageKeys.ReputationChangeNumeral]: getFormattedValueWithFallback( actionData.amount && ( @@ -439,5 +442,17 @@ export const useMapColonyActionToExpectedFormat = ({ [ActionTitleMessageKeys.ArbitraryTransactionsLength]: arbitraryTransactionsLength, [ActionTitleMessageKeys.ArbitraryMethod]: arbitraryMethod, + [ActionTitleMessageKeys.RecipientsNumber]: new Set( + expenditureData?.slots.map((slot) => slot.recipientAddress), + ).size, + [ActionTitleMessageKeys.TokensNumber]: new Set( + expenditureData?.slots?.flatMap( + (slot) => slot.payouts?.map((payout) => payout.tokenAddress) ?? [], + ), + ).size, + // @todo: update this to use the actual period value + [ActionTitleMessageKeys.Period]: streamingPaymentData + ? getAmountPerValue(streamingPaymentData.interval).toLowerCase() + : undefined, }; }; diff --git a/src/components/common/ColonyActionsTable/FiltersContext/types.ts b/src/components/common/ColonyActionsTable/FiltersContext/types.ts index 138e79ce691..45ff5f55ecf 100644 --- a/src/components/common/ColonyActionsTable/FiltersContext/types.ts +++ b/src/components/common/ColonyActionsTable/FiltersContext/types.ts @@ -4,4 +4,5 @@ export enum FiltersValues { DecisionMethod = 'decisionMethod', Date = 'date', Custom = 'custom', + TotalStreamedFilters = 'TotalStreamedFilters', } diff --git a/src/components/common/ColonyActionsTable/partials/ActionBadge/ActionBadge.tsx b/src/components/common/ColonyActionsTable/partials/ActionBadge/ActionBadge.tsx index 2a9c3d43850..436a018be0c 100644 --- a/src/components/common/ColonyActionsTable/partials/ActionBadge/ActionBadge.tsx +++ b/src/components/common/ColonyActionsTable/partials/ActionBadge/ActionBadge.tsx @@ -1,9 +1,14 @@ -import clsx from 'clsx'; -import React, { type FC } from 'react'; +import React, { useMemo, type FC, useEffect } from 'react'; +import LoadingSkeleton from '~common/LoadingSkeleton/LoadingSkeleton.tsx'; +import useCurrentBlockTime from '~hooks/useCurrentBlockTime.ts'; +import { useExpenditureActionStatus } from '~hooks/useExpenditureActionStatus.ts'; import { MotionState } from '~utils/colonyMotions.ts'; +import { getStreamingPaymentStatus } from '~utils/streamingPayments.ts'; import { useGetExpenditureData } from '~v5/common/ActionSidebar/hooks/useGetExpenditureData.ts'; +import { useGetStreamingPaymentData } from '~v5/common/ActionSidebar/hooks/useGetStreamingPaymentData.ts'; import ExpenditureActionStatusBadge from '~v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/ExpenditureActionStatusBadge.tsx'; +import StreamingPaymentStatusPill from '~v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/StreamingPaymentStatusPill.tsx'; import MotionStateBadge from '~v5/common/Pills/MotionStateBadge/MotionStateBadge.tsx'; import { type ActionBadgeProps } from './types.ts'; @@ -17,22 +22,54 @@ const ActionBadge: FC = ({ const { expenditure, loadingExpenditure } = useGetExpenditureData(expenditureId); - const isLoading = loading || loadingExpenditure; - - return expenditure ? ( - - ) : ( - + const expenditureStatus = useExpenditureActionStatus(expenditure); + const { streamingPaymentData, loadingStreamingPayment } = + useGetStreamingPaymentData(expenditureId); + + const { currentBlockTime: blockTime, fetchCurrentBlockTime } = + useCurrentBlockTime(); + + const currentTime = useMemo( + () => Math.floor(blockTime ?? Date.now() / 1000), + [blockTime], + ); + + const streamingPaymentStatus = getStreamingPaymentStatus({ + streamingPayment: streamingPaymentData, + currentTimestamp: currentTime, + isMotion: !!motionState, + }); + + useEffect(() => { + fetchCurrentBlockTime(); + }, [fetchCurrentBlockTime]); + + const isLoading = + loading || !!loadingExpenditure || !!loadingStreamingPayment; + + return ( + + {streamingPaymentData ? ( + + ) : ( + <> + {expenditure ? ( + + ) : ( + + )} + + )} + ); }; diff --git a/src/components/common/ColonyActionsTable/partials/ActionDescription/ActionDescription.tsx b/src/components/common/ColonyActionsTable/partials/ActionDescription/ActionDescription.tsx index 8e445e3284c..577b1e4eb34 100644 --- a/src/components/common/ColonyActionsTable/partials/ActionDescription/ActionDescription.tsx +++ b/src/components/common/ColonyActionsTable/partials/ActionDescription/ActionDescription.tsx @@ -10,6 +10,7 @@ import useShouldDisplayMotionCountdownTime from '~hooks/useShouldDisplayMotionCo import useUserByAddress from '~hooks/useUserByAddress.ts'; import { formatText } from '~utils/intl.ts'; import { useGetExpenditureData } from '~v5/common/ActionSidebar/hooks/useGetExpenditureData.ts'; +import { useGetStreamingPaymentData } from '~v5/common/ActionSidebar/hooks/useGetStreamingPaymentData.ts'; import MotionCountDownTimer from '~v5/common/ActionSidebar/partials/Motions/partials/MotionCountDownTimer/index.ts'; import { UserAvatar } from '~v5/shared/UserAvatar/UserAvatar.tsx'; import UserInfoPopover from '~v5/shared/UserInfoPopover/UserInfoPopover.tsx'; @@ -32,7 +33,6 @@ const ActionDescription: FC = ({ isMotion, motionData, motionState, - expenditureId, recipientAddress, } = action; @@ -41,10 +41,15 @@ const ActionDescription: FC = ({ true, ); - const { expenditure, loadingExpenditure } = - useGetExpenditureData(expenditureId); + const { expenditure, loadingExpenditure } = useGetExpenditureData( + action.expenditureId, + ); + + const { streamingPaymentData, loadingStreamingPayment } = + useGetStreamingPaymentData(action?.streamingPaymentId); - const isLoading = loading || loadingExpenditure || loadingUser; + const isLoading = + loading || loadingExpenditure || loadingStreamingPayment || loadingUser; const walletAddress = user?.walletAddress || initiatorAddress || ADDRESS_ZERO; const refetchMotionState = () => { @@ -73,6 +78,7 @@ const ActionDescription: FC = ({ colony, expenditureData: expenditure ?? undefined, networkInverseFee, + streamingPaymentData: streamingPaymentData ?? undefined, }), ); diff --git a/src/components/common/ColonyActionsTable/partials/ActionsTableFilters/partials/filters/ActionTypeFilters/consts.ts b/src/components/common/ColonyActionsTable/partials/ActionsTableFilters/partials/filters/ActionTypeFilters/consts.ts index b0900b454d1..c5a612e8f01 100644 --- a/src/components/common/ColonyActionsTable/partials/ActionsTableFilters/partials/filters/ActionTypeFilters/consts.ts +++ b/src/components/common/ColonyActionsTable/partials/ActionsTableFilters/partials/filters/ActionTypeFilters/consts.ts @@ -14,19 +14,14 @@ export const ACTION_TYPES_FILTERS = [ label: formatText({ id: 'actions.stagedPayment' }), name: Action.StagedPayment, }, - // @BETA: Disabled for now - // { - // label:formatText({ id: 'actions.batchPayment' }), - // name: Action.BatchPayment, - // }, { label: formatText({ id: 'actions.splitPayment' }), name: Action.SplitPayment, }, - // { - // label:formatText({ id: 'actions.streamingPayment' }), - // name: Action.StreamingPayment, - // }, + { + label: formatText({ id: 'actions.streamingPayment' }), + name: Action.StreamingPayment, + }, { label: formatText({ id: 'actions.createDecision' }), name: Action.CreateDecision, diff --git a/src/components/common/Extensions/ExtensionItem/hooks.ts b/src/components/common/Extensions/ExtensionItem/hooks.ts index 9f74e55f70b..8c155b9cf96 100644 --- a/src/components/common/Extensions/ExtensionItem/hooks.ts +++ b/src/components/common/Extensions/ExtensionItem/hooks.ts @@ -11,7 +11,8 @@ export const useExtensionItem = (extensionId: string) => { const { colony: { name: colonyName }, } = useColonyContext(); - const { extensionData, loading } = useExtensionData(extensionId); + const { extensionData, loading: extensionDataLoading } = + useExtensionData(extensionId); const navigate = useNavigate(); const isExtensionInstalled = @@ -28,7 +29,7 @@ export const useExtensionItem = (extensionId: string) => { return { extensionUrl, isExtensionInstalled, - isExtensionDataLoading: loading, + isExtensionDataLoading: extensionDataLoading, status, handleNavigateToExtensionDetails, }; diff --git a/src/components/common/Extensions/SpecialInput/SpecialInput.tsx b/src/components/common/Extensions/SpecialInput/SpecialInput.tsx index 9ebfabd1ece..4fd2d2fa72f 100644 --- a/src/components/common/Extensions/SpecialInput/SpecialInput.tsx +++ b/src/components/common/Extensions/SpecialInput/SpecialInput.tsx @@ -1,75 +1,15 @@ -import clsx from 'clsx'; import React, { type FC } from 'react'; -import { useController } from 'react-hook-form'; - -import { formatText } from '~utils/intl.ts'; +import { useFormContext } from 'react-hook-form'; +import SpecialInputBase from './SpecialInputBase.tsx'; import { type SpecialInputProps } from './types.ts'; const displayName = 'common.Extensions.SpecialInput'; -const SpecialInput: FC = ({ - defaultValue, - name, - disabled, - id, - placeholder, - isError, - type, - step, - onChange: onChangeProp, -}) => { - const { - field: { onChange, disabled: fieldDisabled, ...restField }, - } = useController({ name }); +const SpecialInput: FC = ({ name, min, max, ...rest }) => { + const { register } = useFormContext(); - return ( -
- e.currentTarget.blur()} - onChange={(e) => { - onChange(e); - onChangeProp?.(e); - }} - /> - - {type === 'hours' ? formatText({ id: 'hours' }) : '%'} - -
- ); + return ; }; SpecialInput.displayName = displayName; diff --git a/src/components/common/Extensions/SpecialInput/SpecialInputBase.tsx b/src/components/common/Extensions/SpecialInput/SpecialInputBase.tsx new file mode 100644 index 00000000000..e8744c5c3f9 --- /dev/null +++ b/src/components/common/Extensions/SpecialInput/SpecialInputBase.tsx @@ -0,0 +1,76 @@ +import clsx from 'clsx'; +import React, { type FC } from 'react'; + +import { formatText } from '~utils/intl.ts'; + +import { type SpecialInputProps } from './types.ts'; + +const displayName = 'common.Extensions.SpecialInputBase'; + +const SpecialInputBase: FC = ({ + defaultValue, + name, + disabled, + id, + placeholder, + isError, + type, + step, + onChange, + value, + min, + ...rest +}) => { + return ( +
+ e.currentTarget.blur()} + onChange={onChange} + value={value} + /> + + {type === 'hours' && formatText({ id: 'hours' })} + {type === 'percent' && '%'} + {type === 'days' && formatText({ id: 'days' })} + +
+ ); +}; +SpecialInputBase.displayName = displayName; + +export default SpecialInputBase; diff --git a/src/components/common/Extensions/SpecialInput/types.ts b/src/components/common/Extensions/SpecialInput/types.ts index 1a3177a5042..59d181e8d13 100644 --- a/src/components/common/Extensions/SpecialInput/types.ts +++ b/src/components/common/Extensions/SpecialInput/types.ts @@ -17,7 +17,7 @@ export interface SpecialInputProps { onChange?: (e: SyntheticEvent) => void; } -export type ComponentType = 'percent' | 'hours'; +export type ComponentType = 'percent' | 'hours' | 'days'; export type FormHourInput = { hours?: number; diff --git a/src/components/common/Extensions/UserHub/UserHub.tsx b/src/components/common/Extensions/UserHub/UserHub.tsx index 3d2f549aeec..bcfa4658f75 100644 --- a/src/components/common/Extensions/UserHub/UserHub.tsx +++ b/src/components/common/Extensions/UserHub/UserHub.tsx @@ -13,7 +13,7 @@ import NotificationsEnabledWrapper from '~v5/common/NotificationsEnabledWrapper/ import TitleLabel from '~v5/shared/TitleLabel/index.ts'; import { tabList } from './consts.ts'; -import BalanceTab from './partials/BalanceTab/index.ts'; +import BalanceTab from './partials/BalanceTab/BalanceTab.tsx'; import CryptoToFiatTab from './partials/CryptoToFiatTab/CryptoToFiatTab.tsx'; import NotificationsTab from './partials/NotificationsTab/NotificationsTab.tsx'; import StakesTab from './partials/StakesTab/index.ts'; diff --git a/src/components/common/Extensions/UserHub/partials/BalanceTab/index.ts b/src/components/common/Extensions/UserHub/partials/BalanceTab/index.ts deleted file mode 100644 index 59d643f5aa6..00000000000 --- a/src/components/common/Extensions/UserHub/partials/BalanceTab/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './BalanceTab.tsx'; -export { default as TotalReputation } from './partials/TotalReputation.tsx'; -export { default as PendingReputation } from './partials/PendingReputation/index.ts'; diff --git a/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/AvailableToClaimCounter/AvailableToClaimCounter.tsx b/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/AvailableToClaimCounter/AvailableToClaimCounter.tsx new file mode 100644 index 00000000000..efd73dab057 --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/AvailableToClaimCounter/AvailableToClaimCounter.tsx @@ -0,0 +1,91 @@ +import clsx from 'clsx'; +import { AnimatePresence, motion } from 'framer-motion'; +import React, { type FC, useEffect, useState } from 'react'; + +import { currencySymbolMap } from '~constants/currency.ts'; +import { useCurrencyContext } from '~context/CurrencyContext/CurrencyContext.ts'; + +import { type AvailableToClaimCounterProps } from './types.ts'; + +const AvailableToClaimCounter: FC = ({ + amountAvailableToClaim, + getTotalFunds, + isAtLeastOnePaymentActive, + ratePerSecond, + currentTimestamp: currentTimeProp, +}) => { + const { currency } = useCurrencyContext(); + const [currentTime, setCurrentTime] = useState(-1); + + useEffect(() => { + const timer = setInterval(() => { + getTotalFunds(currentTime); + setCurrentTime((oldTime) => oldTime + 1); + }, 1000); + + return () => { + clearInterval(timer); + }; + }, [currentTime, getTotalFunds]); + + useEffect(() => { + setCurrentTime(currentTimeProp); + }, [currentTimeProp]); + + const decimalPlaces = ratePerSecond.toString().split('.')[1]?.length || 0; + const fixedDecimalPlaces = decimalPlaces < 5 ? decimalPlaces : 5; + + const formattedNumber = Number( + amountAvailableToClaim.toFixed(fixedDecimalPlaces), + ).toLocaleString(undefined, { + minimumFractionDigits: fixedDecimalPlaces, + maximumFractionDigits: fixedDecimalPlaces, + }); + + const digits = formattedNumber.split('').map((char, index) => ({ + char, + key: `${char}-${index}`, + isStatic: char === ',' || char === '.', // Handle commas as static + })); + + return isAtLeastOnePaymentActive ? ( +
+ {currencySymbolMap[currency]} +
+ {digits.map(({ char, key, isStatic }) => ( +
+ {isStatic ? ( +
+ {char} +
+ ) : ( + + + {char} + + + )} +
+ ))} +
+ {currency} +
+ ) : ( + + {currencySymbolMap[currency]} {formattedNumber} {currency} + + ); +}; + +export default AvailableToClaimCounter; diff --git a/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/AvailableToClaimCounter/types.ts b/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/AvailableToClaimCounter/types.ts new file mode 100644 index 00000000000..5780afaf052 --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/AvailableToClaimCounter/types.ts @@ -0,0 +1,7 @@ +export interface AvailableToClaimCounterProps { + amountAvailableToClaim: number; + getTotalFunds: (currentTimestamp: number) => Promise; + isAtLeastOnePaymentActive?: boolean; + ratePerSecond: number; + currentTimestamp: number; +} diff --git a/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/StreamsInfoRow/StreamsInfoRow.tsx b/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/StreamsInfoRow/StreamsInfoRow.tsx new file mode 100644 index 00000000000..71df2470b7f --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/StreamsInfoRow/StreamsInfoRow.tsx @@ -0,0 +1,145 @@ +// @todo: uncomment and update when claim all streams functionality will be implemented +// import { HandArrowDown, WarningCircle } from '@phosphor-icons/react'; +import React, { type FC } from 'react'; +import { defineMessages } from 'react-intl'; + +import UserHubInfoSection from '~common/Extensions/UserHub/partials/UserHubInfoSection/UserHubInfoSection.tsx'; +import LoadingSkeleton from '~common/LoadingSkeleton/LoadingSkeleton.tsx'; +import { currencySymbolMap } from '~constants/currency.ts'; +import useCurrentBlockTime from '~hooks/useCurrentBlockTime.ts'; +import Numeral from '~shared/Numeral/Numeral.tsx'; +import { useStreamingPaymentsTotalFunds } from '~shared/StreamingPayments/hooks.ts'; +import { formatText } from '~utils/intl.ts'; +// @todo: uncomment and update when claim all streams functionality will be implemented +// import Button from '~v5/shared/Button/Button.tsx'; +// import NotificationBanner from '~v5/shared/NotificationBanner/NotificationBanner.tsx'; + +import AvailableToClaimCounter from '../AvailableToClaimCounter/AvailableToClaimCounter.tsx'; + +const displayName = + 'common.Extensions.UserHub.partials.BalanceTab.partials.StreamsInfoRow'; + +const MSG = defineMessages({ + title: { + id: `${displayName}.title`, + defaultMessage: 'Streams', + }, + totalClaimed: { + id: `${displayName}.totalClaimed`, + defaultMessage: 'Total claimed', + }, + availableToClaim: { + id: `${displayName}.availableToClaim`, + defaultMessage: 'Available to claim', + }, + claimAllStreams: { + id: `${displayName}.claimAllStreams`, + defaultMessage: 'Claim all streams', + }, + viewStreams: { + id: `${displayName}.viewStreams`, + defaultMessage: 'View streams', + }, + insufficientFundsErrorMessage: { + id: `${displayName}.insufficientFundsErrorMessage`, + defaultMessage: 'Insufficient funds in teams to claim all funds.', + }, +}); + +const StreamsInfoRow: FC = () => { + const { + isAnyPaymentActive, + loading, + totalFunds, + ratePerSecond, + currency, + getTotalFunds, + streamingPayments, + } = useStreamingPaymentsTotalFunds({}); + const { currentBlockTime: blockTime } = useCurrentBlockTime(); + + return ( + + {totalFunds.totalClaimed ? ( + + ) : ( + + {currencySymbolMap[currency]} 0.00 {currency} + + )} + + ), + }, + { + key: '2', + label: formatText(MSG.availableToClaim), + value: ( + + {streamingPayments.length ? ( + + getTotalFunds(streamingPayments, currentTimestamp) + } + isAtLeastOnePaymentActive={isAnyPaymentActive} + ratePerSecond={ratePerSecond} + currentTimestamp={Math.floor(blockTime ?? Date.now() / 1000)} + /> + ) : ( + + {currencySymbolMap[currency]} 0.00 {currency} + + )} + + ), + }, + ]} + > + {/* @todo: uncomment and update when claim all streams functionality will be implemented */} + {/*
+ +
*/} +
+ ); +}; + +StreamsInfoRow.displayName = displayName; +export default StreamsInfoRow; diff --git a/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/StreamsInfoRow/utils.ts b/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/StreamsInfoRow/utils.ts new file mode 100644 index 00000000000..c3860940bd1 --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/BalanceTab/partials/StreamsInfoRow/utils.ts @@ -0,0 +1,135 @@ +import Decimal from 'decimal.js'; + +import { type ColonyFragment, type SupportedCurrencies } from '~gql'; +import { type StreamingPaymentItems } from '~shared/StreamingPayments/types.ts'; +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { fetchCurrentPrice } from '~utils/currency/currency.ts'; +import { + getStreamingPaymentAmountsLeft, + getStreamingPaymentStatus, +} from '~utils/streamingPayments.ts'; +import { + getSelectedToken, + getTokenDecimalsWithFallback, +} from '~utils/tokens.ts'; + +export const calculateToCurrency = async ({ + amount, + tokenAddress, + currency, + colony, +}: { + amount: string; + tokenAddress: string; + currency: SupportedCurrencies; + colony: ColonyFragment; +}): Promise => { + const currentToken = getSelectedToken(colony, tokenAddress); + const { decimals } = currentToken || {}; + + const currentPrice = await fetchCurrentPrice({ + contractAddress: tokenAddress, + conversionDenomination: currency, + }); + + if (currentPrice === null) { + return null; + } + + const balanceInWeiToEth = new Decimal(amount).div( + 10 ** getTokenDecimalsWithFallback(decimals), + ); + + return new Decimal(balanceInWeiToEth).mul(currentPrice ?? 0); +}; + +export const calculateTotalsFromStreams = async ({ + colony, + currency, + streamingPayments, + currentTimestamp, +}: { + streamingPayments: StreamingPaymentItems; + currentTimestamp: number; + currency: SupportedCurrencies; + colony: ColonyFragment; +}) => { + const totals = streamingPayments.reduce( + async (result, item) => { + if (!item) { + return result; + } + + const { amountClaimedToDate, amountAvailableToClaim } = + getStreamingPaymentAmountsLeft(item, currentTimestamp); + + const paymentStatus = getStreamingPaymentStatus({ + streamingPayment: item, + currentTimestamp, + }); + + const amountAvailableToClaimToCurrency = await calculateToCurrency({ + amount: amountAvailableToClaim, + tokenAddress: item.tokenAddress, + currency, + colony, + }); + + const amountClaimedToDateToCurrency = await calculateToCurrency({ + amount: amountClaimedToDate, + tokenAddress: item.tokenAddress, + currency, + colony, + }); + + const ratePerSecondValue = new Decimal(item.amount || '0').div( + item.interval || 1, + ); + + const ratePerSecondToCurrency = await calculateToCurrency({ + amount: ratePerSecondValue.toString(), + tokenAddress: item.tokenAddress, + currency, + colony, + }); + + const { + totalAvailable, + totalClaimed, + isAtLeastOnePaymentActive, + ratePerSecond, + } = await result; + + return { + totalAvailable: totalAvailable.add( + amountAvailableToClaimToCurrency ?? '0', + ), + totalClaimed: totalClaimed.add(amountClaimedToDateToCurrency ?? '0'), + ratePerSecond: ratePerSecond.add(ratePerSecondToCurrency ?? '0'), + isAtLeastOnePaymentActive: + paymentStatus === StreamingPaymentStatus.Active || + isAtLeastOnePaymentActive, + }; + }, + Promise.resolve({ + totalAvailable: new Decimal(0), + totalClaimed: new Decimal(0), + ratePerSecond: new Decimal(0), + isAtLeastOnePaymentActive: false, + }), + ); + + const { + totalClaimed, + totalAvailable, + isAtLeastOnePaymentActive, + ratePerSecond, + } = await totals; + + return { + totalClaimed: totalClaimed.toNumber(), + totalAvailable: totalAvailable.toNumber(), + ratePerSecond: ratePerSecond.toNumber(), + isAtLeastOnePaymentActive, + }; +}; diff --git a/src/components/common/Extensions/UserHub/partials/ReputationTab/ReputationTab.tsx b/src/components/common/Extensions/UserHub/partials/ReputationTab/ReputationTab.tsx new file mode 100644 index 00000000000..416bf610197 --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/ReputationTab/ReputationTab.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { useAppContext } from '~context/AppContext/AppContext.ts'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; + +import PendingReputation from './partials/PendingReputation/PendingReputation.tsx'; +import TotalReputation from './partials/TotalReputation/TotalReputation.tsx'; + +const displayName = 'common.Extensions.UserHub.partials.ReputationTab'; + +const MSG = defineMessages({ + title: { + id: `${displayName}.title`, + defaultMessage: 'Reputation', + }, +}); + +const ReputationTab = () => { + const { formatMessage } = useIntl(); + const { + colony: { colonyAddress, nativeToken }, + } = useColonyContext(); + const { wallet } = useAppContext(); + + if (!wallet) { + return null; + } + + return ( +
+
{formatMessage(MSG.title)}
+ + +
+ ); +}; + +ReputationTab.displayName = displayName; + +export default ReputationTab; diff --git a/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/PendingReputation.tsx b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/PendingReputation.tsx new file mode 100644 index 00000000000..c7575b6e7e8 --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/PendingReputation.tsx @@ -0,0 +1,120 @@ +import React, { type FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; + +import UserHubInfoSection from '~common/Extensions/UserHub/partials/UserHubInfoSection/UserHubInfoSection.tsx'; +import { useGetReputationMiningCycleMetadataQuery } from '~gql'; +import useUserReputation from '~hooks/useUserReputation.ts'; +import TimeRelative from '~shared/TimeRelative/index.ts'; +import { formatText } from '~utils/intl.ts'; + +import { type PendingReputationProps } from './types.ts'; +import { + getNextMiningCycleDate, + getReputationDecayInNextDay, +} from './utils.ts'; + +const displayName = + 'common.Extensions.UserHub.partials.ReputationTab.partials.PendingReputation'; + +const MSG = defineMessages({ + title: { + id: `${displayName}.title`, + defaultMessage: 'Pending reputation', + }, + nextUpdate: { + id: `${displayName}.nextUpdate`, + defaultMessage: 'Next update', + }, + nextUpdateTooltip: { + id: `${displayName}.nextUpdateTooltip`, + defaultMessage: 'New reputation takes two update cycles to display ', + }, + reputationDecay: { + id: `${displayName}.reputationDecay`, + defaultMessage: 'Reputation decay', + }, + reputationDecayValue: { + id: `${displayName}.reputationDecayValue`, + defaultMessage: + '{points} {points, plural, one {pt} other {pts}} in next day', + }, +}); + +const UPDATE_INTERVAL = 15; + +const PendingReputation: FC = ({ + colonyAddress, + wallet, + nativeToken, + className, +}) => { + const { userReputation } = useUserReputation({ + colonyAddress, + walletAddress: wallet?.address, + }); + + const { data } = useGetReputationMiningCycleMetadataQuery(); + const { lastCompletedAt } = data?.getReputationMiningCycleMetadata ?? {}; + + const [nextMiningCycleDate, setNextMiningCycleDate] = useState( + lastCompletedAt + ? getNextMiningCycleDate(new Date(lastCompletedAt ?? '')) + : null, + ); + + useEffect(() => { + const intervalTimer = setInterval(() => { + if (!nextMiningCycleDate) { + return; + } + + const halfIntervalAgo = new Date( + Date.now() - (UPDATE_INTERVAL / 2) * 1000, + ); + + if (nextMiningCycleDate < halfIntervalAgo) { + setNextMiningCycleDate(getNextMiningCycleDate(nextMiningCycleDate)); + } + }, UPDATE_INTERVAL * 1000); + + return () => clearInterval(intervalTimer); + }, [nextMiningCycleDate]); + + return ( + + ) : ( + '-' + ), + }, + { + key: '2', + label: formatText(MSG.reputationDecay), + value: formatText(MSG.reputationDecayValue, { + points: userReputation + ? getReputationDecayInNextDay( + userReputation, + nativeToken.decimals, + ) + : '0', + }), + }, + ]} + /> + ); +}; + +PendingReputation.displayName = displayName; + +export default PendingReputation; diff --git a/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/types.ts b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/types.ts new file mode 100644 index 00000000000..c25e84e6e49 --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/types.ts @@ -0,0 +1,9 @@ +import { type Token } from '~types/graphql.ts'; +import { type ColonyWallet } from '~types/wallet.ts'; + +export interface PendingReputationProps { + nativeToken: Token; + colonyAddress: string; + wallet: ColonyWallet; + className?: string; +} diff --git a/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/utils.ts b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/utils.ts new file mode 100644 index 00000000000..94a2fdbd974 --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/PendingReputation/utils.ts @@ -0,0 +1,31 @@ +import Decimal from 'decimal.js'; + +/** + * Function calculating and formatting the amount of reputation decay in the next day + */ +export const getReputationDecayInNextDay = ( + currentReputation: string, + nativeTokenDecimals: number, +) => { + const convertedReputation = new Decimal(currentReputation); + const nextDayDecay = convertedReputation.sub( + convertedReputation.mul(new Decimal(0.5).pow(1 / 90)), + ); + return nextDayDecay + .div(new Decimal(10).pow(nativeTokenDecimals)) + .round() + .toString(); +}; + +const REPUTATION_CYCLE_DURATION_IN_HOURS = 1; + +/** + * Function calculating the date of the next reputation mining cycle, based on the date of the last completed cycle + */ +export const getNextMiningCycleDate = (lastCompletedAt: Date) => { + const nextCycleTime = new Date(lastCompletedAt); + nextCycleTime.setHours( + nextCycleTime.getHours() + REPUTATION_CYCLE_DURATION_IN_HOURS, + ); + return nextCycleTime; +}; diff --git a/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/TotalReputation/TotalReputation.tsx b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/TotalReputation/TotalReputation.tsx new file mode 100644 index 00000000000..3d1f8ddcc2d --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/TotalReputation/TotalReputation.tsx @@ -0,0 +1,94 @@ +import { Star } from '@phosphor-icons/react'; +import Decimal from 'decimal.js'; +import React, { type FC } from 'react'; +import { defineMessages } from 'react-intl'; + +import UserHubInfoSection from '~common/Extensions/UserHub/partials/UserHubInfoSection/UserHubInfoSection.tsx'; +import { DEFAULT_TOKEN_DECIMALS } from '~constants/index.ts'; +import useUserReputation from '~hooks/useUserReputation.ts'; +import Numeral from '~shared/Numeral/index.ts'; +import { formatText } from '~utils/intl.ts'; +import { ZeroValue, calculatePercentageReputation } from '~utils/reputation.ts'; +import { + getFormattedTokenValue, + getTokenDecimalsWithFallback, +} from '~utils/tokens.ts'; + +import { type TotalReputationProps } from './types.ts'; + +const displayName = + 'common.Extensions.UserHub.partials.ReputationTab.partials.TotalReputation'; + +const MSG = defineMessages({ + title: { + id: `${displayName}.title`, + defaultMessage: 'Total reputation in THIS Colony', + }, + influence: { + id: `${displayName}.influence`, + defaultMessage: 'Influence', + }, + reputationPoints: { + id: `${displayName}.reputationPoints`, + defaultMessage: 'Reputation points', + }, +}); + +const TotalReputation: FC = ({ + colonyAddress, + wallet, + nativeToken, + className, +}) => { + const { userReputation, totalReputation } = useUserReputation({ + colonyAddress, + walletAddress: wallet?.address, + }); + + const percentageReputation = calculatePercentageReputation( + userReputation || '0', + totalReputation || '0', + ); + + const formattedReputationPoints = getFormattedTokenValue( + new Decimal(userReputation || 0).toString(), + getTokenDecimalsWithFallback(nativeToken.decimals, DEFAULT_TOKEN_DECIMALS), + ); + + return ( + + + {percentageReputation && ( + + )} + + ), + }, + { + key: '2', + label: formatText(MSG.reputationPoints), + value: , + }, + ]} + /> + ); +}; + +TotalReputation.displayName = displayName; + +export default TotalReputation; diff --git a/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/TotalReputation/types.ts b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/TotalReputation/types.ts new file mode 100644 index 00000000000..bc271597124 --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/ReputationTab/partials/TotalReputation/types.ts @@ -0,0 +1,9 @@ +import { type Token } from '~types/graphql.ts'; +import { type ColonyWallet } from '~types/wallet.ts'; + +export interface TotalReputationProps { + colonyAddress: string; + wallet: ColonyWallet; + nativeToken: Token; + className?: string; +} diff --git a/src/components/common/Extensions/UserHub/partials/UserHubInfoSection/UserHubInfoSection.tsx b/src/components/common/Extensions/UserHub/partials/UserHubInfoSection/UserHubInfoSection.tsx new file mode 100644 index 00000000000..6933b7650ca --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/UserHubInfoSection/UserHubInfoSection.tsx @@ -0,0 +1,81 @@ +import clsx from 'clsx'; +import React, { type PropsWithChildren, type FC, Fragment } from 'react'; + +import Tooltip from '~shared/Extensions/Tooltip/Tooltip.tsx'; +import TextButton from '~v5/shared/Button/TextButton.tsx'; +import Link from '~v5/shared/Link/Link.tsx'; + +import { type UserHubInfoSectionProps } from './types.ts'; + +const UserHubInfoSection: FC> = ({ + title, + items, + viewLinkProps, + className, + children, +}) => { + return ( +
+
+
+ {title} +
+ {viewLinkProps && ( + <> + {'to' in viewLinkProps ? ( + + ) : ( + + )} + + )} +
+ {!!items.length && ( +
+ {items.map(({ key, label, labelTooltip, value, valueTooltip }) => ( + +
+ {labelTooltip ? ( + + {label} + + ) : ( + label + )} +
+
+ {valueTooltip ? ( + + {value} + + ) : ( + value + )} +
+
+ ))} +
+ )} + {children &&
{children}
} +
+ ); +}; + +export default UserHubInfoSection; diff --git a/src/components/common/Extensions/UserHub/partials/UserHubInfoSection/types.ts b/src/components/common/Extensions/UserHub/partials/UserHubInfoSection/types.ts new file mode 100644 index 00000000000..f61f64f642f --- /dev/null +++ b/src/components/common/Extensions/UserHub/partials/UserHubInfoSection/types.ts @@ -0,0 +1,17 @@ +import { type TextButtonProps } from '~v5/shared/Button/types.ts'; +import { type LinkProps } from '~v5/shared/Link/types.ts'; + +export interface UserHubInfoSectionItem { + key: string; + label: string; + labelTooltip?: string; + value: React.ReactNode; + valueTooltip?: string; +} + +export interface UserHubInfoSectionProps { + title: string; + items: UserHubInfoSectionItem[]; + viewLinkProps?: TextButtonProps | LinkProps; + className?: string; +} diff --git a/src/components/frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsHeader/ButtonWithLoader.tsx b/src/components/frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsHeader/ButtonWithLoader.tsx index 28b7a63a66f..35eaf5552c8 100644 --- a/src/components/frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsHeader/ButtonWithLoader.tsx +++ b/src/components/frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsHeader/ButtonWithLoader.tsx @@ -21,6 +21,7 @@ export const ButtonWithLoader: FC< loaderClassName = '!px-4 !text-md', loaderIconSize = 18, onClick, + className, }) => loading ? ( {children} diff --git a/src/components/frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsSidePanel/UninstallButton.tsx b/src/components/frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsSidePanel/UninstallButton.tsx index d6f919c46ee..e80bac10fa1 100644 --- a/src/components/frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsSidePanel/UninstallButton.tsx +++ b/src/components/frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsSidePanel/UninstallButton.tsx @@ -99,12 +99,31 @@ const MSG = { 'I understand that funds can be lost and are unrecoverable', }, }), + [Extension.StreamingPayments]: defineMessages({ + uninstallTitle: { + id: `${displayName}.${Extension.StreamingPayments}.uninstallTitle`, + defaultMessage: 'Extension removal warning', + }, + uninstallDescription: { + id: `${displayName}.${Extension.StreamingPayments}.uninstallDescription`, + defaultMessage: + 'Uninstalling this extension will remove the ability for the colony to create and manage streaming payments. Ensure you understand the potential risks before continuing.', + }, + uninstallWarning: { + id: `${displayName}.${Extension.StreamingPayments}.uninstallWarning`, + defaultMessage: + '{hasActiveStream, select, true {
  • The colony has at least one currently active stream. This extension cannot be uninstalled while any streams are still active, please cancel the stream or wait for its conclusion before uninstalling.
  • } other {}}{hasUnclaimedFunds, select, true {
  • The colony has at least one stream with unclaimed funds. This extension cannot be uninstalled until all streamed funds have been claimed. Please claim all remaining unclaimed funds before uninstalling.
  • } other {}}', + }, + uninstallConfirmation: { + id: `${displayName}.${Extension.StreamingPayments}.uninstallConfirmation`, + defaultMessage: 'I understand the risk of uninstalling this extension.', + }, + }), }; const ListChunks = (chunks: React.ReactNode[]) => (
      {chunks}
    ); -const ListItemChunks = (chunks: React.ReactNode[]) =>
  • {chunks}
  • ; const UninstallButton = ({ extensionData: { extensionId }, @@ -120,6 +139,16 @@ const UninstallButton = ({ formState: { isSubmitting }, } = useFormContext(); + const isStreamingPaymentsExtension = + extensionId === Extension.StreamingPayments; + + // @todo: add proper logic here to determine if there are active streams or unclaimed funds + const hasActiveStream = false; + const hasUnclaimedFunds = false; + + const shouldShowWarning = + isStreamingPaymentsExtension && (hasActiveStream || hasUnclaimedFunds); + return ( <>
    @@ -154,15 +183,18 @@ const UninstallButton = ({ })} disabled={!isCheckboxChecked} > -
    - -
    + {shouldShowWarning && ( +
    + +
    + )} {

    {formatText({ id: 'extensionsPage.availableExtensions' })}

    - {Object.entries(categorizedExtensions).map(([category, extensions]) => ( -
    -

    {category}

    -
      - {extensions.map((extension) => ( -
    • - -
    • - ))} -
    -
    - ))} + {Object.entries(categorizedExtensions) + .sort(([categoryA], [categoryB]) => categoryA.localeCompare(categoryB)) + .map(([category, extensions]) => ( +
    +

    {category}

    +
      + {sortBy(extensions, (item) => item.name.defaultMessage).map( + (extension) => ( +
    • + +
    • + ), + )} +
    +
    + ))}
    ); }; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/StreamingPaymentsPage.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/StreamingPaymentsPage.tsx new file mode 100644 index 00000000000..aca68031126 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/StreamingPaymentsPage.tsx @@ -0,0 +1,106 @@ +import { Extension } from '@colony/colony-js'; +import React from 'react'; +import { defineMessages } from 'react-intl'; + +import { useExtensionItem } from '~common/Extensions/ExtensionItem/hooks.ts'; +import { Action } from '~constants/actions.ts'; +import { currencySymbolMap } from '~constants/currency.ts'; +import { useActionSidebarContext } from '~context/ActionSidebarContext/ActionSidebarContext.ts'; +import { useSetPageHeadingTitle } from '~context/PageHeadingContext/PageHeadingContext.ts'; +import useGetSelectedDomainFilter from '~hooks/useGetSelectedDomainFilter.tsx'; +import { useStreamingPaymentsTotalFunds } from '~shared/StreamingPayments/hooks.ts'; +import { formatText } from '~utils/intl.ts'; +import { ACTION_TYPE_FIELD_NAME } from '~v5/common/ActionSidebar/consts.ts'; +import ContentWithTeamFilter from '~v5/frame/ContentWithTeamFilter/ContentWithTeamFilter.tsx'; +import Button from '~v5/shared/Button/Button.tsx'; + +import NoExtensionBanner from './partials/NoExtensionBanner/NoExtensionBanner.tsx'; +import StatsCards from './partials/StatsCards/StatsCards.tsx'; +import StreamingPaymentPageFilters from './partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/StreamingPaymentFilters.tsx'; +import StreamingPaymentsTable from './partials/StreamingPaymentsTable/StreamingPaymentsTable.tsx'; + +const displayName = 'v5.pages.StreamingPaymentsPage'; + +const MSG = defineMessages({ + title: { + id: `${displayName}.title`, + defaultMessage: 'Streaming Payments', + }, + createNewStream: { + id: `${displayName}.createNewStream`, + defaultMessage: 'Create new stream', + }, +}); + +const StreamingPaymentsPage = () => { + useSetPageHeadingTitle(formatText({ id: 'streamingPaymentsPage.title' })); + + const nativeDomainId = useGetSelectedDomainFilter()?.nativeId; + const { + actionSidebarToggle: [, { toggleOn: toggleActionSidebarOn }], + } = useActionSidebarContext(); + + const { + totalFunds, + activeStreamingPayments, + currency, + totalStreamed, + totalLastMonthStreaming, + } = useStreamingPaymentsTotalFunds({ + isFilteredByWalletAddress: false, + nativeDomainId, + }); + + const { isExtensionInstalled, isExtensionDataLoading, status } = + useExtensionItem(Extension.StreamingPayments); + + const isExtensionDisabled = + status === 'deprecated' || + (!isExtensionInstalled && !isExtensionDataLoading); + + return ( + +
    + +
    + {isExtensionDisabled && ( +
    + +
    + )} +
    +
    +

    {formatText(MSG.title)}

    +
    +
    + + +
    +
    + +
    + ); +}; + +StreamingPaymentsPage.displayName = displayName; + +export default StreamingPaymentsPage; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/NoExtensionBanner/NoExtensionBanner.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/NoExtensionBanner/NoExtensionBanner.tsx new file mode 100644 index 00000000000..aee44299e6b --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/NoExtensionBanner/NoExtensionBanner.tsx @@ -0,0 +1,41 @@ +import { Extension } from '@colony/colony-js'; +import { WarningCircle } from '@phosphor-icons/react'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { useExtensionItem } from '~common/Extensions/ExtensionItem/hooks.ts'; +import NotificationBanner from '~v5/shared/NotificationBanner/NotificationBanner.tsx'; + +const displayName = 'v5.pages.StreamingPaymentsPage.partials.NoExtensionBanner'; + +const MSG = defineMessages({ + bannerInfo: { + id: `${displayName}.bannerInfo`, + defaultMessage: 'Streaming payments extension is currently not enabled.', + }, + link: { + id: `${displayName}.link`, + defaultMessage: 'View extension', + }, +}); + +const NoExtensionBanner = () => { + const { formatMessage } = useIntl(); + + const { extensionUrl } = useExtensionItem(Extension.StreamingPayments); + + return ( + {formatMessage(MSG.link)}} + > + {formatMessage(MSG.bannerInfo)} + + ); +}; + +NoExtensionBanner.displayName = displayName; + +export default NoExtensionBanner; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StatsCards/StatsCards.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StatsCards/StatsCards.tsx new file mode 100644 index 00000000000..869cddcdf0b --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StatsCards/StatsCards.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import { getFormattedNumeralValue } from '~shared/Numeral/helpers.tsx'; +import { type NumeralValue } from '~shared/Numeral/types.ts'; +import { convertToDecimal } from '~utils/convertToDecimal.ts'; +import { formatText } from '~utils/intl.ts'; +import WidgetBox from '~v5/common/WidgetBox/WidgetBox.tsx'; + +export interface StatsCardsProps { + streamingPerMonth: NumeralValue; + totalActiveStreams: number; + totalStreamed: NumeralValue; + unclaimedFounds: NumeralValue; + prefix: string; + suffix: string; +} + +const displayName = 'v5.pages.StreamingPaymentsPage.partials.StatsCards'; + +const StatsCards = ({ + streamingPerMonth, + totalActiveStreams, + totalStreamed, + unclaimedFounds, + prefix, + suffix, +}: StatsCardsProps) => { + const streamingPerMonthDecimal = convertToDecimal(streamingPerMonth, 0); + const totalStreamedDecimal = convertToDecimal(totalStreamed, 0); + const unclaimedFoundsDecimal = convertToDecimal(unclaimedFounds, 0); + + return ( +
    + +
    +

    + {prefix} + {getFormattedNumeralValue(streamingPerMonthDecimal, 0)} +

    +
    +

    {suffix} / month

    +
    + } + /> + + +
    +

    + {prefix} + {getFormattedNumeralValue(totalStreamedDecimal, 0)} +

    +
    +

    {suffix}

    + + } + /> + +
    +

    + {prefix} + {getFormattedNumeralValue(unclaimedFoundsDecimal, 0)} +

    +
    +

    {suffix}

    + + } + /> + {totalActiveStreams}} + /> + + ); +}; + +StatsCards.displayName = displayName; + +export default StatsCards; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/StreamingActionsTable.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/StreamingActionsTable.tsx new file mode 100644 index 00000000000..25db598b3e2 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/StreamingActionsTable.tsx @@ -0,0 +1,133 @@ +import { ArrowCircleDown } from '@phosphor-icons/react'; +import { + type SortingState, + type Row, + getPaginationRowModel, +} from '@tanstack/react-table'; +import clsx from 'clsx'; +import React, { useState, type FC } from 'react'; +import { defineMessages } from 'react-intl'; + +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { useMobile, useTablet } from '~hooks'; +import { formatText } from '~utils/intl.ts'; +import { + EXPANDER_COLUMN_ID, + MEATBALL_MENU_COLUMN_ID, +} from '~v5/common/Table/consts.ts'; +import { Table } from '~v5/common/Table/Table.tsx'; + +import { + type StreamingTableFieldModel, + type StreamingActionTableFieldModel, +} from '../StreamingPaymentsTable/types.ts'; + +import { useStreamingActionsTableColumns } from './hooks.tsx'; +import useRenderSubComponent from './partials/StreamingActionMobileItem/hooks.tsx'; +import useRenderRowLink from './useRenderRowLink.tsx'; +import { orderActions } from './utils.ts'; + +const displayName = + 'pages.StreamingPaymentsPage.partials.StreamingActionsTable.StreamingActionsTable'; + +const MSG = defineMessages({ + loadMoreStreams: { + id: `${displayName}.loadMoreStreams`, + defaultMessage: 'Load more streams', + }, +}); + +interface StreamingActionsTableProps { + actionRow: Row; + isLoading: boolean; +} + +const StreamingActionsTable: FC = ({ + actionRow, + isLoading, +}) => { + const { colony } = useColonyContext(); + + const { original } = actionRow; + const isTablet = useTablet(); + const isMobile = useMobile(); + const columns = useStreamingActionsTableColumns(); + const [sorting, setSorting] = useState([ + { + id: 'totalStreamedAmount', + desc: true, + }, + ]); + + const renderSubComponent = useRenderSubComponent(); + const renderRowLink = useRenderRowLink(isLoading); + + const orderedActions = orderActions(original.actions, sorting, colony); + + return ( + + data={isLoading ? [] : orderedActions} + columns={columns} + renderCellWrapper={isMobile ? undefined : renderRowLink} + className={clsx( + 'rounded-none border-none [&_td:first-child]:!pl-0 [&_td:nth-child(2)>a]:px-2 [&_td>a]:px-[1.125rem] [&_td>a]:py-2 [&_td>div]:px-[1.125rem] [&_td>div]:py-2 [&_td]:border-b [&_td]:border-gray-100 [&_td]:!pr-0 [&_th:not(:first-child)]:sm:text-left [&_th:nth-child(2)]:px-2 [&_th]:!rounded-none [&_th]:!border-b [&_th]:!border-solid [&_th]:border-gray-200 [&_tr.expanded-below_td]:border-none sm:[&_tr:hover]:bg-gray-25 [&_tr:last-child_td]:border-none', + { + '[&_table]:table-auto lg:[&_table]:table-fixed [&_tbody_td]:h-[100px] [&_td:not(:first-child)>a]:p-0 [&_td:nth-child(2)>a]:items-end [&_td:nth-child(2)>a]:pr-8 [&_th:not(:first-child)]:p-0 [&_th:nth-child(2)]:pr-8 [&_th:nth-child(2)]:text-right': + !isTablet, + }, + )} + overrides={{ + enableSorting: true, + initialState: { + pagination: { + pageIndex: 0, + pageSize: 1000000, + }, + }, + loadMoreProps: { + renderContent: (loadMore) => ( + + ), + itemsPerPage: 5, + }, + state: { + sorting, + columnVisibility: isMobile + ? { + title: true, + streamed: true, + token: true, + team: false, + status: false, + [MEATBALL_MENU_COLUMN_ID]: false, + } + : { + [EXPANDER_COLUMN_ID]: false, + }, + }, + getRowId: (row) => row.transactionId, + onSortingChange: setSorting, + getPaginationRowModel: getPaginationRowModel(), + }} + pagination={{ + visible: false, + }} + rows={{ + canExpand: () => true, + renderSubComponent, + }} + tableClassName="border-none" + /> + ); +}; + +StreamingActionsTable.displayName = displayName; + +export default StreamingActionsTable; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/hooks.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/hooks.tsx new file mode 100644 index 00000000000..b150f15df6e --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/hooks.tsx @@ -0,0 +1,178 @@ +import { CaretDown, FilePlus, ShareNetwork } from '@phosphor-icons/react'; +import { createColumnHelper } from '@tanstack/react-table'; +import clsx from 'clsx'; +import React, { useCallback, useMemo } from 'react'; +import { generatePath, useNavigate } from 'react-router-dom'; + +import MeatballMenuCopyItem from '~common/ColonyActionsTable/partials/MeatballMenuCopyItem/MeatballMenuCopyItem.tsx'; +import { APP_URL, DEFAULT_TOKEN_DECIMALS } from '~constants'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { + COLONY_ACTIVITY_ROUTE, + COLONY_HOME_ROUTE, + TX_SEARCH_PARAM, +} from '~routes'; +import Numeral from '~shared/Numeral/Numeral.tsx'; +import { formatText } from '~utils/intl.ts'; +import StreamingPaymentStatusPill from '~v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/StreamingPaymentStatusPill.tsx'; +import { EXPANDER_COLUMN_ID } from '~v5/common/Table/consts.ts'; +import MeatBallMenu from '~v5/shared/MeatBallMenu/MeatBallMenu.tsx'; +import { TokenAvatar } from '~v5/shared/TokenAvatar/TokenAvatar.tsx'; + +import { type StreamingActionTableFieldModel } from '../StreamingPaymentsTable/types.ts'; + +import TeamField from './partials/TeamField/TeamField.tsx'; +import TitleField from './partials/TitleField/TitleField.tsx'; + +export const useStreamingActionsTableColumns = () => { + const navigate = useNavigate(); + const { colony } = useColonyContext(); + + const getMenuProps = useCallback( + ({ original: { transactionId } }) => ({ + items: [ + { + key: '1', + label: formatText({ id: 'activityFeedTable.menu.view' }), + icon: FilePlus, + onClick: () => { + navigate( + `${window.location.pathname}?${TX_SEARCH_PARAM}=${transactionId}`, + { + replace: true, + }, + ); + }, + }, + { + key: '2', + label: formatText({ id: 'activityFeedTable.menu.share' }), + renderItemWrapper: (itemWrapperProps, children) => ( + + {children} + + ), + icon: ShareNetwork, + onClick: () => false, + }, + ], + }), + [colony.name, navigate], + ); + return useMemo(() => { + const helper = createColumnHelper(); + + return [ + helper.display({ + id: 'title', + enableSorting: false, + cell: ({ row }) => ( + + ), + colSpan: (isExpanded) => (isExpanded ? 3 : undefined), + header: formatText({ id: 'streamingPayment.table.description' }), + }), + helper.accessor('totalStreamedAmount', { + id: 'totalStreamedAmount', + staticSize: '8.75rem', + enableSorting: true, + cell: ({ row }) => ( + + ), + colSpan: (isExpanded) => (isExpanded ? 0 : undefined), + header: formatText({ id: 'streamingPayment.table.streamed' }), + headCellClassName: '[&>svg]:opacity-100', + }), + helper.accessor('token.symbol', { + id: 'token', + staticSize: '6.25rem', + enableSorting: true, + cell: ({ row }) => ( +
    + +

    + {row.original.token?.symbol || ''} +

    +
    + ), + colSpan: (isExpanded) => (isExpanded ? 0 : undefined), + header: formatText({ id: 'streamingPayment.table.token' }), + headCellClassName: '[&>svg]:opacity-100', + }), + helper.accessor('nativeDomainId', { + id: 'team', + staticSize: '6.875rem', + enableSorting: true, + cell: ({ row }) => , + header: formatText({ id: 'streamingPayment.table.team' }), + }), + helper.display({ + id: 'status', + staticSize: '7.5rem', + enableSorting: false, + cell: ({ row }) => ( + + ), + header: formatText({ id: 'streamingPayment.table.status' }), + }), + helper.display({ + id: EXPANDER_COLUMN_ID, + staticSize: '2.25rem', + header: () => null, + enableSorting: false, + cell: ({ row }) => { + const meatBallMenuProps = getMenuProps(row); + return ( +
    + + + {meatBallMenuProps && row.getIsExpanded() && ( +
    + + clsx({ '!text-gray-600': !isMenuOpen }) + } + iconSize={18} + /> +
    + )} +
    + ); + }, + cellContentWrapperClassName: 'pl-0', + }), + ]; + }, [getMenuProps]); +}; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/StreamingActionMobileItem/StreamingActionMobileItem.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/StreamingActionMobileItem/StreamingActionMobileItem.tsx new file mode 100644 index 00000000000..a919c0f913b --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/StreamingActionMobileItem/StreamingActionMobileItem.tsx @@ -0,0 +1,101 @@ +import { type Row } from '@tanstack/react-table'; +import clsx from 'clsx'; +import React, { type FC } from 'react'; +import { defineMessages } from 'react-intl'; + +import { type StreamingActionTableFieldModel } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/types.ts'; +import Numeral from '~shared/Numeral/Numeral.tsx'; +import { formatText } from '~utils/intl.ts'; +import StreamingPaymentStatusPill from '~v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/StreamingPaymentStatusPill.tsx'; + +import TeamField from '../TeamField/TeamField.tsx'; + +interface StreamingActionMobileItemProps { + actionRow: Row; +} + +const displayName = + 'pages.StreamingPaymentsPage.partials.StreamingActionsTable.partials.StreamingActionMobileItem'; + +const MSG = defineMessages({ + streamed: { + id: `${displayName}.streamed`, + defaultMessage: '

    Streamed:

    {streamed}', + }, + token: { + id: `${displayName}.token`, + defaultMessage: '

    Token:

    {token}', + }, + team: { + id: `${displayName}.team`, + defaultMessage: '

    Team:

    {team}', + }, + status: { + id: `${displayName}.status`, + defaultMessage: '

    Status:

    {status}', + }, +}); + +const StreamingActionMobileItem: FC = ({ + actionRow, +}) => { + const { + original: { amount, nativeDomainId, token, status }, + } = actionRow; + + const textClassName = 'font-normal text-sm'; + const renderLabel = (chunks: string[]) => ( +

    {chunks}

    + ); + + return ( +
    +
    +
    + {formatText(MSG.streamed, { + streamed: ( + + + + ), + p: renderLabel, + })} +
    +
    + {formatText(MSG.token, { + token: ( + + {token?.symbol} + + ), + p: renderLabel, + })} +
    +
    + {formatText(MSG.team, { + team: ( + + + + ), + p: renderLabel, + })} +
    +
    + {formatText(MSG.status, { + status: ( + + + + ), + p: renderLabel, + })} +
    +
    +
    + ); +}; + +StreamingActionMobileItem.displayName = displayName; + +export default StreamingActionMobileItem; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/StreamingActionMobileItem/hooks.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/StreamingActionMobileItem/hooks.tsx new file mode 100644 index 00000000000..5ced40effcf --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/StreamingActionMobileItem/hooks.tsx @@ -0,0 +1,14 @@ +import { type Row } from '@tanstack/react-table'; +import React from 'react'; + +import { type StreamingActionTableFieldModel } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/types.ts'; + +import StreamingActionMobileItem from './StreamingActionMobileItem.tsx'; + +const useRenderSubComponent = () => { + return ({ row }: { row: Row }) => ( + + ); +}; + +export default useRenderSubComponent; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/TeamField/TeamField.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/TeamField/TeamField.tsx new file mode 100644 index 00000000000..7f4a6d3adcc --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/TeamField/TeamField.tsx @@ -0,0 +1,25 @@ +import React, { type FC } from 'react'; + +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { findDomainByNativeId } from '~utils/domains.ts'; +import TeamBadge from '~v5/common/Pills/TeamBadge/TeamBadge.tsx'; + +interface TeamFieldProps { + domainId: number; +} + +const TeamField: FC = ({ domainId }) => { + const { colony } = useColonyContext(); + const currentTeam = findDomainByNativeId(domainId, colony); + + return currentTeam ? ( + + ) : ( +
    + ); +}; + +export default TeamField; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/TitleField/TitleField.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/TitleField/TitleField.tsx new file mode 100644 index 00000000000..5b125f478b4 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/partials/TitleField/TitleField.tsx @@ -0,0 +1,97 @@ +import React, { type FC } from 'react'; +import { defineMessages } from 'react-intl'; + +import { DEFAULT_TOKEN_DECIMALS } from '~constants'; +import { useMobile } from '~hooks'; +import useUserByAddress from '~hooks/useUserByAddress.ts'; +import { formatText } from '~utils/intl.ts'; +import { getAmountPerValue } from '~utils/streamingPayments.ts'; +import { + getFormattedTokenValue, + getTokenDecimalsWithFallback, +} from '~utils/tokens.ts'; + +interface TitleFieldProps { + tokenSymbol: string; + amount: string; + period: string; + recipient: string; + initiator: string; + title: string; + decimals: number; + hideDescription: boolean; +} + +const displayName = + 'pages.StreamingPaymentsPage.partials.StreamingActionsTable.partials.TitleField'; + +const MSG = defineMessages({ + title: { + id: `${displayName}.title`, + defaultMessage: + 'Stream {amount} {tokenSymbol} / {period} to {recipient} by {initiator}', + }, +}); + +const TitleField: FC = ({ + tokenSymbol, + amount, + initiator, + period, + recipient, + title, + decimals, + hideDescription, +}) => { + const isMobile = useMobile(); + const { user: recipientUser, loading: isRecipientLoading } = useUserByAddress( + recipient, + true, + ); + const { user: initiatorUser, loading: isInitiatorLoading } = useUserByAddress( + initiator, + true, + ); + + return ( +
    +

    {title}

    + {isMobile && hideDescription && ( +

    + {formatText(MSG.title, { + amount: getFormattedTokenValue( + amount, + getTokenDecimalsWithFallback(decimals, DEFAULT_TOKEN_DECIMALS), + ), + tokenSymbol, + period: period || 0, + recipient: isRecipientLoading + ? '' + : recipientUser?.profile?.displayName, + initiator: isInitiatorLoading + ? '' + : initiatorUser?.profile?.displayName, + })} +

    + )} +

    + {formatText(MSG.title, { + amount: getFormattedTokenValue( + amount, + getTokenDecimalsWithFallback(decimals, DEFAULT_TOKEN_DECIMALS), + ), + tokenSymbol, + period: getAmountPerValue(period).toLowerCase(), + recipient: isRecipientLoading + ? '' + : recipientUser?.profile?.displayName, + initiator: isInitiatorLoading + ? '' + : initiatorUser?.profile?.displayName, + })} +

    +
    + ); +}; + +export default TitleField; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/useRenderRowLink.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/useRenderRowLink.tsx new file mode 100644 index 00000000000..62a804e01a4 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/useRenderRowLink.tsx @@ -0,0 +1,41 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { TX_SEARCH_PARAM } from '~routes/index.ts'; +import { tw } from '~utils/css/index.ts'; +import { setQueryParamOnUrl } from '~utils/urls.ts'; +import { MEATBALL_MENU_COLUMN_ID } from '~v5/common/Table/consts.ts'; +import { type RenderCellWrapper } from '~v5/common/Table/types.ts'; +import Link from '~v5/shared/Link/index.ts'; + +import { type StreamingActionTableFieldModel } from '../StreamingPaymentsTable/types.ts'; + +const useRenderRowLink = ( + loading: boolean, +): RenderCellWrapper => { + const cellClassName = tw( + 'flex h-full flex-col justify-center py-1 text-md text-gray-500', + ); + + return (_, content, { cell, row }) => + cell.column.columnDef.id === MEATBALL_MENU_COLUMN_ID || loading ? ( +
    + {content} +
    + ) : ( + + {content} + + ); +}; + +export default useRenderRowLink; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/utils.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/utils.ts new file mode 100644 index 00000000000..fb7600a21a9 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingActionsTable/utils.ts @@ -0,0 +1,47 @@ +import { type SortingState } from '@tanstack/react-table'; +import { orderBy } from 'lodash'; + +import { type ColonyFragment } from '~gql'; +import { findDomainByNativeId } from '~utils/domains.ts'; + +import { type StreamingActionTableFieldModel } from '../StreamingPaymentsTable/types.ts'; + +export const orderActions = ( + actions: StreamingActionTableFieldModel[], + sorting: SortingState, + colony: ColonyFragment, +) => { + const { id, desc } = sorting[0]; + + switch (id) { + case 'totalStreamedAmount': + return actions + ? [...actions].sort((a, b) => { + const diff = + BigInt(a.totalStreamedAmount) - BigInt(b.totalStreamedAmount); + + if (diff > 0n) return desc ? 1 : -1; + if (diff < 0n) return desc ? -1 : 1; + return 0; + }) + : []; + case 'token': + return orderBy( + actions, + (action) => action.token?.symbol, + desc && ['desc'], + ); + case 'team': + return [...actions].sort((a, b) => { + const nameA = + findDomainByNativeId(a.nativeDomainId, colony)?.metadata?.name || ''; + const nameB = + findDomainByNativeId(b.nativeDomainId, colony)?.metadata?.name || ''; + + return desc ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA); + }); + + default: + return actions; + } +}; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts new file mode 100644 index 00000000000..22b86be0222 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts @@ -0,0 +1,57 @@ +import { createContext, useContext } from 'react'; + +import { + type ModelSortDirection, + type StreamingPaymentEndCondition, +} from '~gql'; +import { type StreamingPaymentStatus } from '~types/streamingPayments.ts'; + +import { + type StreamingPaymentFilters, + type DateOptions, +} from '../partials/StreamingPaymentFilters/types.ts'; + +import { type TokenTypes } from './types.ts'; + +import type React from 'react'; + +interface StreamingFiltersContextValue { + searchFilter: string; + setSearchFilter: (searchValue: string) => void; + statuses: StreamingPaymentStatus[]; + endConditions: StreamingPaymentEndCondition[]; + tokenTypes: TokenTypes; + dateFilters: DateOptions; + activeFilters: StreamingPaymentFilters; + totalStreamedFilters: ModelSortDirection | undefined; + selectedFiltersCount: number; + handleStatusesFilterChange: ( + event: React.ChangeEvent, + ) => void; + handleEndConditionsFilterChange: ( + event: React.ChangeEvent, + ) => void; + handleTokenTypesFilterChange: ( + event: React.ChangeEvent, + ) => void; + handleTotalStreamedFilterChange: ( + event: React.ChangeEvent, + ) => void; + handleDateFilterChange: (event: React.ChangeEvent) => void; + handleCustomDateFilterChange: (date: [Date | null, Date | null]) => void; + handleResetFilters: (category: string) => void; +} + +export const StreamingFiltersContext = createContext< + StreamingFiltersContextValue | undefined +>(undefined); + +export const useStreamingFiltersContext = () => { + const context = useContext(StreamingFiltersContext); + if (context === undefined) { + throw new Error( + 'useFiltersContext must be used within the StreamingFiltersContextProvider', + ); + } + return context; +}; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContextProvider.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContextProvider.tsx new file mode 100644 index 00000000000..45a5ef9a2ee --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContextProvider.tsx @@ -0,0 +1,259 @@ +import React, { + type FC, + type PropsWithChildren, + useState, + useMemo, + useCallback, +} from 'react'; + +import { + type ModelSortDirection, + type StreamingPaymentEndCondition, +} from '~gql'; +import useCurrentBlockTime from '~hooks/useCurrentBlockTime.ts'; +import { type StreamingPaymentStatus } from '~types/streamingPayments.ts'; + +import { STATUS_FILTERS } from '../partials/StreamingPaymentFilters/partials/StatusFilters/consts.ts'; +import { + type StreamingPaymentFilters, + type DateOptions, +} from '../partials/StreamingPaymentFilters/types.ts'; +import { getDateFilter } from '../partials/StreamingPaymentFilters/utils.ts'; + +import { StreamingFiltersContext } from './StreamingFiltersContext.ts'; +import { FiltersValues, type TokenTypes } from './types.ts'; + +const StreamingFiltersContextProvider: FC = ({ + children, +}) => { + const [searchFilter, setSearchFilter] = useState(''); + const [statuses, setStatuses] = useState([]); + const [tokenTypes, setTokenTypes] = useState({}); + const [totalStreamedFilters, setTotalStreamedFilters] = useState< + ModelSortDirection | undefined + >(undefined); + const [endConditions, setEndConditions] = useState< + StreamingPaymentEndCondition[] + >([]); + const [dateFilters, setDateFilters] = useState({ + pastHour: false, + pastDay: false, + pastWeek: false, + pastMonth: false, + pastYear: false, + custom: undefined, + }); + + const { dateFromCurrentBlockTime } = useCurrentBlockTime(); + + const handleStatusesFilterChange = useCallback( + (event: React.ChangeEvent) => { + const isChecked = event.target.checked; + const name = event.target.name as StreamingPaymentStatus; + + if (isChecked) { + setStatuses([...statuses, name]); + } else { + setStatuses(statuses.filter((checkedItem) => checkedItem !== name)); + } + }, + [statuses], + ); + const handleEndConditionsFilterChange = useCallback( + (event: React.ChangeEvent) => { + const isChecked = event.target.checked; + const name = event.target.name as StreamingPaymentEndCondition; + + if (isChecked) { + setEndConditions([...endConditions, name]); + } else { + setEndConditions( + endConditions.filter((checkedItem) => checkedItem !== name), + ); + } + }, + [endConditions], + ); + const handleTokenTypesFilterChange = useCallback( + (event: React.ChangeEvent) => { + const isChecked = event.target.checked; + const name = event.target.name as keyof TokenTypes; + + if (isChecked) { + setTokenTypes({ ...tokenTypes, [name]: true }); + } else { + setTokenTypes({ ...tokenTypes, [name]: false }); + } + }, + [tokenTypes], + ); + const resetDateFilters = ( + filters: DateOptions, + date?: [Date | null, Date | null], + ) => { + const custom = date ?? undefined; + return Object.keys(filters).reduce((acc, key) => { + acc[key] = key === 'custom' ? custom : false; + return acc; + }, {} as DateOptions); + }; + const handleDateFilterChange = useCallback( + (event: React.ChangeEvent) => { + const isChecked = event.target.checked; + const name = event.target.name as keyof DateOptions; + const resetFields = resetDateFilters(dateFilters); + setDateFilters({ ...resetFields, [name]: isChecked }); + }, + [dateFilters], + ); + const handleCustomDateFilterChange = useCallback( + (date: [Date | null, Date | null]) => { + const [newStartDate, newEndDate] = date; + const resetFields = resetDateFilters(dateFilters, date); + + if (newStartDate && newEndDate) { + setDateFilters({ + ...resetFields, + custom: + !newStartDate && !newEndDate + ? undefined + : [newStartDate.toString(), newEndDate.toString()], + }); + } + }, + [dateFilters], + ); + const handleTotalStreamedFilterChange = useCallback( + (event: React.ChangeEvent) => { + const isChecked = event.target.checked; + const name = event.target.name as ModelSortDirection; + + if (isChecked) { + setTotalStreamedFilters(name); + } else { + setTotalStreamedFilters(undefined); + } + }, + [], + ); + + const activeFilters: StreamingPaymentFilters = useMemo(() => { + const date = getDateFilter( + dateFilters, + dateFromCurrentBlockTime ?? undefined, + ); + + return { + ...(statuses.length ? { statuses } : {}), + ...(endConditions.length ? { endConditions } : {}), + ...(totalStreamedFilters ? { totalStreamedFilters } : {}), + ...(tokenTypes.length ? { endConditions } : {}), + ...(searchFilter ? { search: searchFilter } : {}), + ...(Object.keys(tokenTypes).length ? { tokenTypes } : {}), + ...(date || {}), + }; + }, [ + dateFilters, + statuses, + endConditions, + tokenTypes, + searchFilter, + totalStreamedFilters, + dateFromCurrentBlockTime, + ]); + + const handleResetFilters = useCallback( + (filter: FiltersValues) => { + switch (filter) { + case FiltersValues.Status: { + return setStatuses([]); + } + case FiltersValues.Date: { + return setDateFilters({ + pastHour: false, + pastDay: false, + pastWeek: false, + pastMonth: false, + pastYear: false, + custom: dateFilters.custom, + }); + } + case FiltersValues.Custom: { + return setDateFilters({ + ...dateFilters, + custom: undefined, + }); + } + case FiltersValues.EndCondition: { + return setEndConditions([]); + } + case FiltersValues.TokenType: { + return setTokenTypes({}); + } + case FiltersValues.TotalStreamedFilters: { + return setTotalStreamedFilters(undefined); + } + default: { + return undefined; + } + } + }, + [dateFilters], + ); + + const statusCount = statuses.reduce((acc, current) => { + if (STATUS_FILTERS[current]) { + return acc + 1; + } + return acc; + }, 0); + + const selectedFiltersCount = + statusCount + Object.values(dateFilters).filter(Boolean).length; + + const value = useMemo( + () => ({ + searchFilter, + setSearchFilter, + dateFilters, + statuses, + endConditions, + tokenTypes, + activeFilters, + totalStreamedFilters, + selectedFiltersCount, + handleStatusesFilterChange, + handleResetFilters, + handleDateFilterChange, + handleCustomDateFilterChange, + handleEndConditionsFilterChange, + handleTokenTypesFilterChange, + handleTotalStreamedFilterChange, + }), + [ + searchFilter, + statuses, + endConditions, + dateFilters, + tokenTypes, + activeFilters, + totalStreamedFilters, + selectedFiltersCount, + handleStatusesFilterChange, + handleResetFilters, + handleDateFilterChange, + handleCustomDateFilterChange, + handleEndConditionsFilterChange, + handleTokenTypesFilterChange, + handleTotalStreamedFilterChange, + ], + ); + + return ( + + {children} + + ); +}; + +export default StreamingFiltersContextProvider; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/index.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/index.ts new file mode 100644 index 00000000000..753c56cab09 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/index.ts @@ -0,0 +1,2 @@ +export { useStreamingFiltersContext } from './StreamingFiltersContext.ts'; +export { default as FiltersContextProvider } from './StreamingFiltersContextProvider.tsx'; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/types.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/types.ts new file mode 100644 index 00000000000..31337e36a9b --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/types.ts @@ -0,0 +1,17 @@ +import { type SearchStreamingPaymentsQueryVariables } from '~gql'; + +export enum FiltersValues { + Status = 'status', + Date = 'date', + Custom = 'custom', + EndCondition = 'endCondition', + TokenType = 'tokenType', + TotalStreamedFilters = 'TotalStreamedFilters', +} + +export interface TokenTypes { + [key: string]: boolean; +} + +export type SearchStreamingPaymentFilterVariable = + SearchStreamingPaymentsQueryVariables['filter']; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/StreamingPaymentsTable.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/StreamingPaymentsTable.tsx new file mode 100644 index 00000000000..3017869b189 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/StreamingPaymentsTable.tsx @@ -0,0 +1,72 @@ +import { Binoculars } from '@phosphor-icons/react'; +import { getPaginationRowModel } from '@tanstack/react-table'; +import clsx from 'clsx'; +import React from 'react'; + +import EmptyContent from '~v5/common/EmptyContent/EmptyContent.tsx'; +import { Table } from '~v5/common/Table/Table.tsx'; + +import { useStreamingFiltersContext } from './FiltersContext/StreamingFiltersContext.ts'; +import { + useStreamingPaymentTable, + useStreamingTableColumns, +} from './hooks.tsx'; +import { type StreamingTableFieldModel } from './types.ts'; +import { useRenderSubComponent } from './useRenderSubComponent.tsx'; + +const displayName = + 'pages.StreamingPaymentsPage.partials.StreamingPaymentsTable'; + +const StreamingPaymentsTable = () => { + const { searchFilter, selectedFiltersCount } = useStreamingFiltersContext(); + const { items, loading } = useStreamingPaymentTable(); + + const columns = useStreamingTableColumns(loading); + const renderSubComponent = useRenderSubComponent(loading); + + return ( + + className={clsx( + 'overflow-hidden [&_table]:table-auto [&_table]:overflow-hidden lg:[&_table]:table-fixed [&_tbody_td]:h-[57px] [&_td:first-child]:pl-0 [&_td:last-child]:pr-0 [&_td]:border-gray-100 [&_td]:pr-0 [&_th]:border-none [&_tr.expanded-below+tr_td]:pl-0 [&_tr.expanded-below+tr_td]:pr-0 [&_tr.expanded-below>td]:border-gray-200 [&_tr.expanded-below_td]:h-[57px] [&_tr.expanded-item_td]:h-auto [&_tr.table-item:hover_td]:bg-gray-25 [&_tr:hover_.toggler]:text-blue-400 [&_tr:not(:last-child)_td]:border-b', + { 'pb-6': items.length > 10 }, + )} + data={loading ? Array(4).fill({}) : items} + columns={columns} + renderCellWrapper={(_, content) => content} + rows={{ + canExpand: () => true, + renderSubComponent, + }} + pagination={{ + disabled: loading, + }} + overrides={{ + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { + pageSize: 10, + pageIndex: 0, + }, + }, + }} + emptyContent={ + + } + /> + ); +}; + +StreamingPaymentsTable.displayName = displayName; + +export default StreamingPaymentsTable; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/hooks.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/hooks.tsx new file mode 100644 index 00000000000..8f6ca89c8f5 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/hooks.tsx @@ -0,0 +1,290 @@ +import { CaretDown } from '@phosphor-icons/react'; +import { createColumnHelper } from '@tanstack/react-table'; +import clsx from 'clsx'; +import { BigNumber } from 'ethers'; +import React, { useEffect, useMemo } from 'react'; + +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { useMemberContext } from '~context/MemberContext/MemberContext.ts'; +import { useSearchStreamingPaymentsQuery } from '~gql'; +import useCurrentBlockTime from '~hooks/useCurrentBlockTime.ts'; +import { useGetAllTokens } from '~hooks/useGetAllTokens.ts'; +import useGetSelectedDomainFilter from '~hooks/useGetSelectedDomainFilter.tsx'; +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { notNull } from '~utils/arrays/index.ts'; +import { + convertToMonthlyAmount, + getStreamingPaymentAmountsLeft, + getStreamingPaymentStatus, +} from '~utils/streamingPayments.ts'; +import { getTokenDecimalsWithFallback } from '~utils/tokens.ts'; + +import { useStreamingFiltersContext } from './FiltersContext/StreamingFiltersContext.ts'; +import { type SearchStreamingPaymentFilterVariable } from './FiltersContext/types.ts'; +import { type StreamingPaymentFilters } from './partials/StreamingPaymentFilters/types.ts'; +import UserField from './partials/UserField/UserField.tsx'; +import UserStreams from './partials/UserStreams/UserStreams.tsx'; +import { + type StreamingPaymentItem, + type StreamingTableFieldModel, +} from './types.ts'; +import { + filterByActionStatus, + filterByEndCondition, + searchStreamingPayments, + sortStreamingPayments, +} from './utils.ts'; + +export const useStreamingTableColumns = (loading: boolean) => { + return useMemo(() => { + const helper = createColumnHelper(); + + return [ + helper.display({ + id: 'user', + enableSorting: false, + header: () => null, + cell: ({ row }) => ( + + ), + }), + helper.display({ + id: 'amount', + staticSize: loading ? '0rem' : '13rem', + enableSorting: false, + header: () => null, + cell: ({ row }) => + loading ? null : ( + + ), + }), + helper.display({ + id: 'expander', + staticSize: loading ? '50%' : '2.25rem', + header: () => null, + enableSorting: false, + cell: ({ row: { getIsExpanded, toggleExpanded } }) => + loading ? ( +
    +
    + +
    + ) : ( + + ), + cellContentWrapperClassName: 'pl-0', + }), + ]; + }, [loading]); +}; + +const getSearchStreamingPaymentsFilterVariable = ( + colonyAddress: string, + { dateFrom, dateTo, tokenTypes }: StreamingPaymentFilters = {}, +): SearchStreamingPaymentFilterVariable => { + const dateFilter = + dateFrom && dateTo + ? { + createdAt: { + range: [dateFrom?.toISOString(), dateTo?.toISOString()], + }, + } + : { + ...(dateFrom + ? { + createdAt: { + gte: dateFrom?.toISOString(), + }, + } + : {}), + ...(dateTo + ? { + createdAt: { + lte: dateTo?.toISOString(), + }, + } + : {}), + }; + + const activeTokens = tokenTypes + ? Object.entries(tokenTypes) + .filter(([, value]) => value === true) + .map(([tokenAddress]) => ({ + tokenAddress: { eq: tokenAddress }, + })) + : []; + + return { + and: [ + { colonyId: { eq: colonyAddress } }, + ...(activeTokens.length > 0 ? [{ or: activeTokens }] : []), + dateFilter, + ].filter((obj) => Object.keys(obj).length > 0), + }; +}; + +export const useStreamingPaymentTable = () => { + const { + colony: { colonyAddress }, + } = useColonyContext(); + const { activeFilters, searchFilter } = useStreamingFiltersContext(); + + const { data, loading, refetch } = useSearchStreamingPaymentsQuery({ + variables: { + filter: getSearchStreamingPaymentsFilterVariable( + colonyAddress, + useMemo(() => activeFilters, [activeFilters]), + ), + }, + fetchPolicy: 'network-only', + }); + + const { currentBlockTime: blockTime, fetchCurrentBlockTime } = + useCurrentBlockTime(); + const { totalMembers: members } = useMemberContext(); + const allTokens = useGetAllTokens(); + + const nativeDomainId = useGetSelectedDomainFilter()?.nativeId; + + const filteredByDomainId = useMemo( + () => + nativeDomainId + ? data?.searchStreamingPayments?.items.filter( + (item) => item?.nativeDomainId === nativeDomainId, + ) + : data?.searchStreamingPayments?.items, + [data?.searchStreamingPayments?.items, nativeDomainId], + ); + + const groupedStreamingPayments = (filteredByDomainId || []) + .filter(notNull) + .map((item) => { + const { amountAvailableToClaim, amountClaimedToDate } = + getStreamingPaymentAmountsLeft( + item, + Math.floor(blockTime ?? Date.now() / 1000), + ); + const paymentStatus = getStreamingPaymentStatus({ + streamingPayment: item, + currentTimestamp: Math.floor(blockTime ?? Date.now() / 1000), + }); + const selectedToken = allTokens.find( + (token) => token.token.tokenAddress === item.tokenAddress, + ); + const summedAmount = BigNumber.from(amountAvailableToClaim).add( + BigNumber.from(amountClaimedToDate), + ); + + return { + ...item, + title: item.actions?.items[0]?.metadata?.customTitle || '', + token: item.token || selectedToken?.token, + paymentId: item.id, + totalStreamedAmount: summedAmount.toString(), + transactionId: item.actions?.items[0]?.transactionHash || '', + status: paymentStatus, + endCondition: item.metadata?.endCondition, + }; + }) + .reduce((result, item) => { + const { recipientAddress } = item; + + const newResult = { ...result }; + + if (!newResult[recipientAddress]) { + newResult[recipientAddress] = { + tokenTotalsPerMonth: {}, + actions: [], + }; + } + + const existingUser = newResult[recipientAddress]; + const tokenAddress = item.token?.tokenAddress || item.tokenAddress; + + const isPaymentActive = item.status === StreamingPaymentStatus.Active; + + if (!existingUser.tokenTotalsPerMonth[tokenAddress]) { + existingUser.tokenTotalsPerMonth[tokenAddress] = { + amount: '0', + tokenDecimals: getTokenDecimalsWithFallback(item.token?.decimals), + tokenSymbol: item.token?.symbol || '', + }; + } + + const monthlyAmount = isPaymentActive + ? convertToMonthlyAmount( + BigNumber.from(item.amount), + BigNumber.from(item.interval), + ) + : '0'; + + const currentStream = existingUser.tokenTotalsPerMonth[tokenAddress]; + const totalAmount = BigNumber.from(currentStream.amount) + .add(monthlyAmount) + .toString(); + + existingUser.tokenTotalsPerMonth[tokenAddress] = { + ...currentStream, + amount: totalAmount, + }; + + existingUser.actions = [...existingUser.actions, item]; + + return newResult; + }, {}); + + const paymentsArray: StreamingTableFieldModel[] = Object.entries( + groupedStreamingPayments, + ).map(([user, paymentData]) => ({ + user, + ...paymentData, + })); + + const searchedStreamingPayments = useMemo( + () => + paymentsArray + .map((action) => searchStreamingPayments(action, members, searchFilter)) + .filter(notNull), + [paymentsArray, searchFilter, members], + ); + + const filteredActions = searchedStreamingPayments + .map((action) => filterByActionStatus(action, activeFilters.statuses)) + .filter(notNull) + .map((action) => filterByEndCondition(action, activeFilters.endConditions)) + .filter(notNull); + + const sortedActions = useMemo( + () => sortStreamingPayments(filteredActions, activeFilters), + [filteredActions, activeFilters], + ); + + useEffect(() => { + fetchCurrentBlockTime(); + }, [data, fetchCurrentBlockTime]); + + return { + items: sortedActions, + loading, + refetch, + }; +}; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/ActiveFiltersList/ActiveFiltersList.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/ActiveFiltersList/ActiveFiltersList.tsx new file mode 100644 index 00000000000..f01145424d6 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/ActiveFiltersList/ActiveFiltersList.tsx @@ -0,0 +1,32 @@ +import { X } from '@phosphor-icons/react'; +import React, { type FC } from 'react'; + +import { useActiveFilters } from './hooks.ts'; + +const ActiveFiltersList: FC = () => { + const { activeFiltersToDisplay, handleResetFilters } = useActiveFilters(); + + return ( +
      + {activeFiltersToDisplay.map(({ filter, items, label }) => ( +
    • +
      +
      + {label}:{' '} + {items.join(', ')} +
      + +
      +
    • + ))} +
    + ); +}; + +export default ActiveFiltersList; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/ActiveFiltersList/hooks.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/ActiveFiltersList/hooks.ts new file mode 100644 index 00000000000..0d919679c2b --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/ActiveFiltersList/hooks.ts @@ -0,0 +1,122 @@ +import { useMemo } from 'react'; + +import { useStreamingFiltersContext } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts'; +import { FiltersValues } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/types.ts'; +import { formatText } from '~utils/intl.ts'; + +import { DATE_FILTERS } from '../StreamingPaymentFilters/partials/DateFilters/consts.ts'; +import { END_CONDITION_FILTERS } from '../StreamingPaymentFilters/partials/EndConditionFilters/consts.ts'; +import { STATUS_FILTERS } from '../StreamingPaymentFilters/partials/StatusFilters/consts.ts'; +import { useGetTokenTypeFilters } from '../StreamingPaymentFilters/partials/TokenFilters/hooks.ts'; +import { TOTAL_STREAMED_FILTERS } from '../StreamingPaymentFilters/partials/TotalStreamedFilters/consts.ts'; +import { getCustomDateLabel } from '../StreamingPaymentFilters/utils.ts'; + +export const useActiveFilters = () => { + const { + statuses, + handleResetFilters, + dateFilters, + endConditions, + tokenTypes, + totalStreamedFilters, + } = useStreamingFiltersContext(); + + const tokenTypesFilters = useGetTokenTypeFilters(); + const tokenItems = tokenTypesFilters.map(({ token }) => ({ + symbol: token.symbol, + name: token?.tokenAddress || '', + })); + + const activeFiltersToDisplay = useMemo(() => { + const customDate = getCustomDateLabel(dateFilters.custom); + const restDateFilters = DATE_FILTERS.filter( + ({ name }) => dateFilters[name], + ).map(({ label }) => label); + + return [ + ...(statuses.length + ? [ + { + filter: FiltersValues.Status, + label: formatText({ + id: 'streamingPayment.table.filter.status', + }), + items: STATUS_FILTERS.filter(({ name }) => + statuses.includes(name), + ).map(({ label }) => label), + }, + ] + : []), + ...(endConditions.length + ? [ + { + filter: FiltersValues.EndCondition, + label: formatText({ + id: 'streamingPayment.table.filter.endCondition', + }), + items: END_CONDITION_FILTERS.filter(({ name }) => + endConditions.includes(name), + ).map(({ label }) => label), + }, + ] + : []), + ...(totalStreamedFilters && totalStreamedFilters.length + ? [ + { + filter: FiltersValues.TotalStreamedFilters, + label: formatText({ + id: 'streamingPayment.table.filter.totalStreamed', + }), + items: TOTAL_STREAMED_FILTERS.filter(({ name }) => + totalStreamedFilters.includes(name), + ).map(({ label }) => label), + }, + ] + : []), + ...(Object.values(tokenTypes).some((value) => value === true) + ? [ + { + filter: FiltersValues.TokenType, + label: formatText({ + id: 'streamingPayment.table.filter.tokenType', + }), + items: tokenItems + .filter(({ name }) => tokenTypes[name]) + .map(({ symbol }) => symbol), + }, + ] + : []), + ...(dateFilters.custom && customDate + ? [ + { + filter: FiltersValues.Custom, + label: formatText({ + id: 'streamingPayment.table.filter.date.custom', + }), + items: [customDate], + }, + ] + : []), + ...(restDateFilters.length + ? [ + { + filter: FiltersValues.Date, + label: formatText({ + id: 'streamingPayment.table.filter.date', + }), + items: restDateFilters, + }, + ] + : []), + ]; + }, [ + dateFilters, + endConditions, + statuses, + tokenItems, + tokenTypes, + totalStreamedFilters, + ]); + + return { activeFiltersToDisplay, handleResetFilters }; +}; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/StreamingPaymentFilters.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/StreamingPaymentFilters.tsx new file mode 100644 index 00000000000..ecac6dc57c4 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/StreamingPaymentFilters.tsx @@ -0,0 +1,150 @@ +import { MagnifyingGlass } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import React, { useState, type FC } from 'react'; +import { usePopperTooltip } from 'react-popper-tooltip'; + +import { useStreamingFiltersContext } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts'; +import { useMobile } from '~hooks/index.ts'; +import { formatText } from '~utils/intl.ts'; +import SearchInput from '~v5/common/Filter/partials/SearchInput/index.ts'; +import Button from '~v5/shared/Button/Button.tsx'; +import FilterButton from '~v5/shared/Filter/FilterButton.tsx'; +import Modal from '~v5/shared/Modal/index.ts'; +import PopoverBase from '~v5/shared/PopoverBase/index.ts'; + +import ActiveFiltersList from '../ActiveFiltersList/ActiveFiltersList.tsx'; +import StreamingPaymentFiltersItem from '../StreamingPaymentPageFiltersItem/StreamingPaymentPageFiltersItem.tsx'; + +import { filterItems, sortItems } from './consts.tsx'; + +const StreamingPaymentPageFilters: FC = () => { + const { searchFilter, setSearchFilter, selectedFiltersCount } = + useStreamingFiltersContext(); + const [isOpened, setOpened] = useState(false); + const [isSearchOpened, setIsSearchOpened] = useState(false); + const isMobile = useMobile(); + const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = + usePopperTooltip({ + delayShow: 200, + delayHide: 200, + placement: 'bottom-start', + trigger: 'click', + interactive: true, + }); + + const SearchInputComponent = ( + setIsSearchOpened(false)} + searchValue={searchFilter} + setSearchValue={setSearchFilter} + searchInputPlaceholder={formatText({ + id: 'streamingPayment.table.filter.searchPlaceholder', + })} + /> + ); + const FiltersContent = ( +
    +

    + {formatText({ id: isMobile ? 'filterAndSort' : 'filters' })} +

    +
      + {filterItems.map(({ icon, label, children }) => ( +
    • + + {children} + +
    • + ))} +
    • +

      + {formatText({ id: 'streamingPayment.table.filter.sortBy' })} +

      +
    • + {sortItems.map(({ icon, label, children }) => ( +
    • + + {children} + +
    • + ))} +
    +
    + ); + + return ( + <> + {isMobile ? ( +
    + setOpened(!isOpened)} + numberSelectedFilters={selectedFiltersCount} + customLabel={formatText({ id: 'allFilters' })} + /> + + setOpened(false)} + isOpen={isOpened} + > + {FiltersContent} + + setIsSearchOpened(false)} + isOpen={isSearchOpened} + > +

    + {formatText({ + id: 'streamingPayment.table.filter.searchPlaceholder', + })} +

    +
    {SearchInputComponent}
    +
    +
    + ) : ( + <> +
    + + +
    + {visible && ( + +
    {SearchInputComponent}
    + {FiltersContent} +
    + )} + + )} + + ); +}; + +export default StreamingPaymentPageFilters; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/consts.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/consts.tsx new file mode 100644 index 00000000000..cf355620886 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/consts.tsx @@ -0,0 +1,52 @@ +import { + Calendar, + CalendarBlank, + ClockCountdown, + CoinVertical, + Vignette, +} from '@phosphor-icons/react'; +import React from 'react'; + +import { formatText } from '~utils/intl.ts'; + +import DateFilters from './partials/DateFilters/DateFilters.tsx'; +import EndConditionFilters from './partials/EndConditionFilters/EndConditionFilters.tsx'; +import StatusFilters from './partials/StatusFilters/StatusFilters.tsx'; +import TokenFilters from './partials/TokenFilters/TokenFilters.tsx'; +import TotalStreamedFilters from './partials/TotalStreamedFilters/TotalStreamedFilters.tsx'; + +export const filterItems = [ + { + icon: ClockCountdown, + label: formatText({ id: 'streamingPayment.table.filter.status' }), + name: 'status', + children: , + }, + { + icon: CalendarBlank, + label: formatText({ id: 'streamingPayment.table.filter.endCondition' }), + name: 'endCondition', + children: , + }, + { + icon: CoinVertical, + label: formatText({ id: 'streamingPayment.table.filter.tokenType' }), + name: 'endCondition', + children: , + }, + { + icon: Calendar, + label: formatText({ id: 'streamingPayment.table.filter.date' }), + name: 'date', + children: , + }, +]; + +export const sortItems = [ + { + icon: Vignette, + label: formatText({ id: 'streamingPayment.table.filter.totalStreamed' }), + name: 'totalStreamed', + children: , + }, +]; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/DateFilters/DateFilters.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/DateFilters/DateFilters.tsx new file mode 100644 index 00000000000..275dedb2655 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/DateFilters/DateFilters.tsx @@ -0,0 +1,62 @@ +import clsx from 'clsx'; +import React, { type FC } from 'react'; + +import { useStreamingFiltersContext } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts'; +import { useMobile } from '~hooks'; +import { formatText } from '~utils/intl.ts'; +import Checkbox from '~v5/common/Checkbox/index.ts'; +import RangeDatepicker from '~v5/common/Fields/datepickers/RangeDatepicker/index.ts'; + +import { DATE_FILTERS } from './consts.ts'; + +const DateFilters: FC = () => { + const isMobile = useMobile(); + const { dateFilters, handleCustomDateFilterChange, handleDateFilterChange } = + useStreamingFiltersContext(); + + const [startDate, endDate] = dateFilters.custom || []; + + return ( +
    +
    + {formatText({ id: 'streamingPayment.table.filters.date' })} +
    +
      + {DATE_FILTERS.map(({ label, name }) => { + const isChecked = dateFilters[name]; + + return ( +
    • + + {label} + +
    • + ); + })} +
    +
    + {formatText({ id: 'streamingPayment.table.filter.date.custom' })} +
    +
    + +
    +
    + ); +}; + +export default DateFilters; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/DateFilters/consts.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/DateFilters/consts.ts new file mode 100644 index 00000000000..808e11eeed1 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/DateFilters/consts.ts @@ -0,0 +1,26 @@ +import { formatText } from '~utils/intl.ts'; + +export const DATE_FILTERS = [ + { + label: formatText({ + id: 'streamingPayment.table.filter.date.pastHour', + }), + name: 'pastHour', + }, + { + label: formatText({ id: 'streamingPayment.table.filter.date.pastDay' }), + name: 'pastDay', + }, + { + label: formatText({ + id: 'streamingPayment.table.filter.date.pastWeek', + }), + name: 'pastWeek', + }, + { + label: formatText({ + id: 'streamingPayment.table.filter.date.pastMonth', + }), + name: 'pastMonth', + }, +]; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/EndConditionFilters/EndConditionFilters.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/EndConditionFilters/EndConditionFilters.tsx new file mode 100644 index 00000000000..77aa876f403 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/EndConditionFilters/EndConditionFilters.tsx @@ -0,0 +1,40 @@ +import React, { type FC } from 'react'; + +import { useStreamingFiltersContext } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts'; +import { formatText } from '~utils/intl.ts'; +import Checkbox from '~v5/common/Checkbox/index.ts'; + +import { END_CONDITION_FILTERS } from './consts.ts'; + +const ActiveStatusFilters: FC = () => { + const { endConditions, handleEndConditionsFilterChange } = + useStreamingFiltersContext(); + + return ( +
    +
    + {formatText({ id: 'streamingPayment.table.filter.endCondition' })} +
    +
      + {END_CONDITION_FILTERS.map(({ label, name }) => { + const isChecked = endConditions.includes(name); + + return ( +
    • + + {label} + +
    • + ); + })} +
    +
    + ); +}; + +export default ActiveStatusFilters; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/EndConditionFilters/consts.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/EndConditionFilters/consts.ts new file mode 100644 index 00000000000..2e52ef5a323 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/EndConditionFilters/consts.ts @@ -0,0 +1,17 @@ +import { StreamingPaymentEndCondition } from '~gql'; +import { formatText } from '~utils/intl.ts'; + +export const END_CONDITION_FILTERS = [ + { + label: formatText({ id: 'streamingPayment.table.filter.whenCancelled' }), + name: StreamingPaymentEndCondition.WhenCancelled, + }, + { + label: formatText({ id: 'streamingPayment.table.filter.fixedDate' }), + name: StreamingPaymentEndCondition.FixedTime, + }, + { + label: formatText({ id: 'streamingPayment.table.filter.limitReached' }), + name: StreamingPaymentEndCondition.LimitReached, + }, +]; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/StatusFilters/StatusFilters.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/StatusFilters/StatusFilters.tsx new file mode 100644 index 00000000000..377ffda7b50 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/StatusFilters/StatusFilters.tsx @@ -0,0 +1,39 @@ +import React, { type FC } from 'react'; + +import { useStreamingFiltersContext } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts'; +import { formatText } from '~utils/intl.ts'; +import Checkbox from '~v5/common/Checkbox/index.ts'; + +import { STATUS_FILTERS } from './consts.ts'; + +const StatusFilters: FC = () => { + const { statuses, handleStatusesFilterChange } = useStreamingFiltersContext(); + + return ( +
    +
    + {formatText({ id: 'streamingPayment.table.filter.status' })} +
    +
      + {STATUS_FILTERS.map(({ label, name }) => { + const isChecked = statuses.includes(name); + + return ( +
    • + + {label} + +
    • + ); + })} +
    +
    + ); +}; + +export default StatusFilters; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/StatusFilters/consts.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/StatusFilters/consts.ts new file mode 100644 index 00000000000..f02b60970ce --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/StatusFilters/consts.ts @@ -0,0 +1,25 @@ +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { formatText } from '~utils/intl.ts'; + +export const STATUS_FILTERS = [ + { + label: formatText({ id: 'streamingPayment.status.active' }), + name: StreamingPaymentStatus.Active, + }, + { + label: formatText({ id: 'streamingPayment.status.ended' }), + name: StreamingPaymentStatus.Ended, + }, + { + label: formatText({ id: 'streamingPayment.status.limitReached' }), + name: StreamingPaymentStatus.LimitReached, + }, + { + label: formatText({ id: 'streamingPayment.status.cancelled' }), + name: StreamingPaymentStatus.Cancelled, + }, + { + label: formatText({ id: 'streamingPayment.status.notStarted' }), + name: StreamingPaymentStatus.NotStarted, + }, +]; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TokenFilters/TokenFilters.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TokenFilters/TokenFilters.tsx new file mode 100644 index 00000000000..fadc1d1a7a8 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TokenFilters/TokenFilters.tsx @@ -0,0 +1,52 @@ +import React, { type FC } from 'react'; + +import { useStreamingFiltersContext } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts'; +import { formatText } from '~utils/intl.ts'; +import { multiLineTextEllipsis } from '~utils/strings.ts'; +import Checkbox from '~v5/common/Checkbox/index.ts'; +import { TokenAvatar } from '~v5/shared/TokenAvatar/TokenAvatar.tsx'; + +import { useGetTokenTypeFilters } from './hooks.ts'; + +const TokenFilters: FC = () => { + const { tokenTypes, handleTokenTypesFilterChange } = + useStreamingFiltersContext(); + const tokenTypesFilters = useGetTokenTypeFilters(); + + return ( +
    +
    + {formatText({ id: 'balancePage.filter.approvedTokenTypes' })} +
    +
      + {tokenTypesFilters.map(({ token }) => { + const name = token?.tokenAddress || ''; + const isChecked = tokenTypes[name]; + + return ( +
    • + +
      + + {multiLineTextEllipsis(token.symbol, 5)} +
      +
      +
    • + ); + })} +
    +
    + ); +}; + +export default TokenFilters; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TokenFilters/hooks.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TokenFilters/hooks.ts new file mode 100644 index 00000000000..c9753506923 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TokenFilters/hooks.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; + +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { notNull } from '~utils/arrays/index.ts'; + +export const useGetTokenTypeFilters = () => { + const { colony } = useColonyContext(); + + return useMemo( + () => + colony.tokens?.items.filter(notNull).sort((a, b) => { + if (!a.token || !b.token) return 0; + + return a.token.name + .toLowerCase() + .localeCompare(b.token.name.toLowerCase()); + }) || [], + [colony.tokens?.items], + ); +}; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TotalStreamedFilters/TotalStreamedFilters.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TotalStreamedFilters/TotalStreamedFilters.tsx new file mode 100644 index 00000000000..a77371ac0dc --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TotalStreamedFilters/TotalStreamedFilters.tsx @@ -0,0 +1,42 @@ +import React, { type FC } from 'react'; + +import { useStreamingFiltersContext } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/StreamingFiltersContext.ts'; +import { formatText } from '~utils/intl.ts'; +import Checkbox from '~v5/common/Checkbox/index.ts'; + +import { TOTAL_STREAMED_FILTERS } from './consts.ts'; + +const TotalStreamedFilters: FC = () => { + const { totalStreamedFilters, handleTotalStreamedFilterChange } = + useStreamingFiltersContext(); + + return ( +
    +
    + {formatText({ id: 'streamingPayment.table.filter.status' })} +
    +
      + {TOTAL_STREAMED_FILTERS.map(({ label, name }) => { + const isChecked = totalStreamedFilters + ? totalStreamedFilters.includes(name) + : false; + + return ( +
    • + + {label} + +
    • + ); + })} +
    +
    + ); +}; + +export default TotalStreamedFilters; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TotalStreamedFilters/consts.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TotalStreamedFilters/consts.ts new file mode 100644 index 00000000000..2744493156c --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/partials/TotalStreamedFilters/consts.ts @@ -0,0 +1,13 @@ +import { ModelSortDirection } from '~gql'; +import { formatText } from '~utils/intl.ts'; + +export const TOTAL_STREAMED_FILTERS = [ + { + label: formatText({ id: 'streamingPayment.table.filter.descending' }), + name: ModelSortDirection.Desc, + }, + { + label: formatText({ id: 'streamingPayment.table.filter.ascending' }), + name: ModelSortDirection.Asc, + }, +]; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/types.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/types.ts new file mode 100644 index 00000000000..a9989407816 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/types.ts @@ -0,0 +1,25 @@ +import { type TokenTypes } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/FiltersContext/types.ts'; +import { + type ModelSortDirection, + type StreamingPaymentEndCondition, +} from '~gql'; +import { type StreamingPaymentStatus } from '~types/streamingPayments.ts'; + +export interface StreamingPaymentFilters { + statuses?: StreamingPaymentStatus[]; + dateFrom?: Date; + dateTo?: Date; + search?: string; + tokenTypes?: TokenTypes; + endConditions?: StreamingPaymentEndCondition[]; + totalStreamedFilters?: ModelSortDirection | undefined; +} + +export interface DateOptions { + pastHour: boolean; + pastDay: boolean; + pastWeek: boolean; + pastMonth: boolean; + pastYear: boolean; + custom?: [string, string]; +} diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/utils.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/utils.ts new file mode 100644 index 00000000000..5477f601dee --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentFilters/utils.ts @@ -0,0 +1,83 @@ +import sub from 'date-fns/sub'; + +import { getFormattedDateFrom } from '~utils/getFormattedDateFrom.ts'; + +import { type StreamingPaymentFilters, type DateOptions } from './types.ts'; + +export const getDateFilter = ( + dateFilter: DateOptions | undefined, + currentDate?: Date, +): Pick | undefined => { + if (!dateFilter) { + return undefined; + } + + const now = currentDate ?? new Date(); + const baseFilter = { + dateTo: now, + }; + + switch (true) { + case !!dateFilter.custom: { + const filteredDates = dateFilter.custom?.filter( + (date): date is string => !!date, + ); + + if (!filteredDates) { + return undefined; + } + + const [from, to] = filteredDates; + + return { + dateFrom: new Date(from), + dateTo: new Date(to), + }; + } + case dateFilter.pastYear: { + return { + dateFrom: sub(now, { years: 1 }), + ...baseFilter, + }; + } + case dateFilter.pastMonth: { + return { + dateFrom: sub(now, { months: 1 }), + ...baseFilter, + }; + } + case dateFilter.pastWeek: { + return { + dateFrom: sub(now, { weeks: 1 }), + ...baseFilter, + }; + } + case dateFilter.pastDay: { + return { + dateFrom: sub(now, { days: 1 }), + ...baseFilter, + }; + } + case dateFilter.pastHour: { + return { + dateFrom: sub(now, { hours: 1 }), + ...baseFilter, + }; + } + default: { + return undefined; + } + } +}; + +export const getCustomDateLabel = (dateRange?: DateOptions['custom']) => { + const [startDateString, endDateString] = dateRange || []; + if (!startDateString || !endDateString) { + return null; + } + + const startDate = new Date(startDateString); + const endDate = new Date(endDateString); + + return `${getFormattedDateFrom(startDate)} - ${getFormattedDateFrom(endDate)}`; +}; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentPageFiltersItem/StreamingPaymentPageFiltersItem.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentPageFiltersItem/StreamingPaymentPageFiltersItem.tsx new file mode 100644 index 00000000000..e7655a893e0 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentPageFiltersItem/StreamingPaymentPageFiltersItem.tsx @@ -0,0 +1,92 @@ +import { CaretDown } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import { AnimatePresence, motion } from 'framer-motion'; +import React, { type FC } from 'react'; +import { usePopperTooltip } from 'react-popper-tooltip'; + +import { accordionAnimation } from '~constants/accordionAnimation.ts'; +import { useMobile } from '~hooks'; +import useToggle from '~hooks/useToggle/index.ts'; +import PopoverBase from '~v5/shared/PopoverBase/index.ts'; + +import { type StreamingPaymentPageFiltersItemProps } from './types.ts'; + +const StreamingPaymentPageFiltersItem: FC< + StreamingPaymentPageFiltersItemProps +> = ({ icon: Icon, label, children }) => { + const isMobile = useMobile(); + const [isOpen, { toggle }] = useToggle({ defaultToggleState: true }); + const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = + usePopperTooltip({ + delayShow: 200, + delayHide: 200, + placement: 'left-start', + interactive: true, + offset: [0, 16], + trigger: 'hover', + }); + + return isMobile ? ( +
    + + + {isOpen && ( + +
    {children}
    +
    + )} +
    +
    + ) : ( +
    + + {visible && ( + + {children} + + )} +
    + ); +}; + +export default StreamingPaymentPageFiltersItem; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentPageFiltersItem/types.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentPageFiltersItem/types.ts new file mode 100644 index 00000000000..f05a86cb786 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/StreamingPaymentPageFiltersItem/types.ts @@ -0,0 +1,8 @@ +import { type IconProps } from '@phosphor-icons/react'; +import { type ComponentType } from 'react'; + +export interface StreamingPaymentPageFiltersItemProps { + icon: ComponentType; + label: string; + children: React.ReactNode; +} diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/UserField/UserField.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/UserField/UserField.tsx new file mode 100644 index 00000000000..e9edfcdd5e7 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/UserField/UserField.tsx @@ -0,0 +1,52 @@ +import React, { type FC } from 'react'; + +import useUserByAddress from '~hooks/useUserByAddress.ts'; +import UserAvatar from '~v5/shared/UserAvatar/UserAvatar.tsx'; + +const displayName = + 'pages.StreamingPaymentsPage.partials.StreamingPaymentsTable.partials.UserField'; + +interface UserFieldProps { + address: string; + isLoading: boolean; + toggleExpanded: (expanded?: boolean | undefined) => void; +} + +const UserField: FC = ({ + address, + isLoading, + toggleExpanded, +}) => { + const { user, loading: isUserLoading } = useUserByAddress(address, true); + + return isUserLoading || isLoading ? ( +
    +
    +
    +
    +
    +
    +
    +
    + ) : ( + + ); +}; + +UserField.displayName = displayName; + +export default UserField; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/UserStreams/UserStreams.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/UserStreams/UserStreams.tsx new file mode 100644 index 00000000000..467ba125e7d --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/partials/UserStreams/UserStreams.tsx @@ -0,0 +1,167 @@ +import Decimal from 'decimal.js'; +import React, { + useCallback, + type FC, + useState, + useEffect, + useMemo, +} from 'react'; + +import { calculateToCurrency } from '~common/Extensions/UserHub/partials/BalanceTab/partials/StreamsInfoRow/utils.ts'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { useCurrencyContext } from '~context/CurrencyContext/CurrencyContext.ts'; +import { type StreamingActionTableFieldModel } from '~frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/types.ts'; +import useCurrentBlockTime from '~hooks/useCurrentBlockTime.ts'; +import Tooltip from '~shared/Extensions/Tooltip/Tooltip.tsx'; +import Numeral from '~shared/Numeral/Numeral.tsx'; +import { NumeralCurrency } from '~shared/Numeral/NumeralCurrency.tsx'; +import { calculateTotalsFromStreams } from '~shared/StreamingPayments/utils.ts'; + +export interface UserStreamsItem { + amount: string; + tokenDecimals: number; + tokenSymbol: string; +} + +interface UserStreamsProps { + items: { + [tokenAddress: string]: UserStreamsItem; + }; + actions: StreamingActionTableFieldModel[]; + toggleExpanded: (expanded?: boolean | undefined) => void; +} + +const UserStreams: FC = ({ + items, + actions, + toggleExpanded, +}) => { + const { currency } = useCurrencyContext(); + const { colony } = useColonyContext(); + const [calculatedAmountPerToken, setCalculatedAmountPerToken] = useState<{ + [tokenAddress: string]: Decimal; + }>({}); + const [lastMonthStreamed, setLastMonthStreamed] = useState(0); + + const tokenTotals = useMemo( + () => + Object.entries(items).map(([tokenAddress, item]) => ({ + ...item, + tokenAddress, + })), + [items], + ); + const { currentBlockTime: blockTime } = useCurrentBlockTime(); + + const calculateFunds = useCallback(async () => { + const accumulatedAmounts: { [tokenAddress: string]: Decimal } = {}; + + const calculationPromises = tokenTotals.map( + async ({ amount, tokenAddress }) => { + const calculatedAmount = await calculateToCurrency({ + amount, + tokenAddress, + currency, + colony, + }); + + if (accumulatedAmounts[tokenAddress]) { + accumulatedAmounts[tokenAddress] = accumulatedAmounts[ + tokenAddress + ].plus(calculatedAmount || new Decimal(0)); + } else { + accumulatedAmounts[tokenAddress] = calculatedAmount || new Decimal(0); + } + }, + ); + + await Promise.all(calculationPromises); + + setCalculatedAmountPerToken(accumulatedAmounts); + }, [colony, currency, tokenTotals]); + + const getTotalFunds = useCallback(async () => { + const { lastMonthStreaming } = await calculateTotalsFromStreams({ + streamingPayments: actions, + currentTimestamp: Math.floor(blockTime ?? Date.now() / 1000), + currency, + colony, + }); + setLastMonthStreamed(lastMonthStreaming); + }, [actions, blockTime, currency, colony]); + + useEffect(() => { + calculateFunds(); + }, [calculateFunds]); + + useEffect(() => { + getTotalFunds(); + }, [getTotalFunds]); + + const calculatedAmountArray = Object.entries(calculatedAmountPerToken).map( + ([tokenAddress, amount]) => ({ tokenAddress, amount }), + ); + + const totalFunds = calculatedAmountArray.reduce( + (acc, { amount }) => acc.plus(amount || 0), + new Decimal(0), + ); + const shouldShowTooltip = calculatedAmountArray.length > 0; + const content = ( +
    + + {currency} {' /month'} +
    + ); + + return totalFunds ? ( + <> + {shouldShowTooltip ? ( +
    + } + > + {content} + + + ) : ( + + )} + + ) : ( +
    + ); +}; + +export default UserStreams; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/types.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/types.ts new file mode 100644 index 00000000000..3de9d3578fb --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/types.ts @@ -0,0 +1,31 @@ +import { type StreamingPaymentEndCondition } from '~gql'; +import { type StreamingPayment } from '~types/graphql.ts'; +import { type StreamingPaymentStatus } from '~types/streamingPayments.ts'; + +import { type UserStreamsItem } from './partials/UserStreams/UserStreams.ts'; + +export interface StreamingPaymentItem { + [user: string]: { + tokenTotalsPerMonth: { + [token: string]: UserStreamsItem; + }; + actions: StreamingActionTableFieldModel[]; + }; +} + +export interface StreamingTableFieldModel { + user: string; + tokenTotalsPerMonth: { + [token: string]: UserStreamsItem; + }; + actions: StreamingActionTableFieldModel[]; +} + +export interface StreamingActionTableFieldModel extends StreamingPayment { + title: string; + paymentId: string; + transactionId: string; + status: StreamingPaymentStatus; + endCondition?: StreamingPaymentEndCondition; + totalStreamedAmount: string; +} diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/useRenderSubComponent.tsx b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/useRenderSubComponent.tsx new file mode 100644 index 00000000000..b2ee70fd3be --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/useRenderSubComponent.tsx @@ -0,0 +1,12 @@ +import { type Row } from '@tanstack/react-table'; +import React from 'react'; + +import StreamingActionsTable from '../StreamingActionsTable/StreamingActionsTable.tsx'; + +import { type StreamingTableFieldModel } from './types.ts'; + +export const useRenderSubComponent = (loading: boolean) => { + return ({ row }: { row: Row }) => ( + + ); +}; diff --git a/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/utils.ts b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/utils.ts new file mode 100644 index 00000000000..88adbb051d9 --- /dev/null +++ b/src/components/frame/v5/pages/StreamingPaymentsPage/partials/StreamingPaymentsTable/utils.ts @@ -0,0 +1,141 @@ +import { BigNumber } from 'ethers'; + +import { ModelSortDirection, type StreamingPaymentEndCondition } from '~gql'; +import { type ColonyContributor } from '~types/graphql.ts'; +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; + +import { type StreamingPaymentFilters } from './partials/StreamingPaymentFilters/types.ts'; +import { type StreamingTableFieldModel } from './types.ts'; + +export const searchStreamingPayments = ( + streamingPayment: StreamingTableFieldModel, + members: ColonyContributor[], + searchValue?: string, +): StreamingTableFieldModel | null => { + if (!searchValue) { + return streamingPayment; + } + + const searchedMembers = members.filter( + (member) => + member.user?.profile?.displayName + ?.toLowerCase() + .includes(searchValue.toLowerCase()) || + member.contributorAddress.includes(searchValue), + ); + + const member = searchedMembers.find( + (searchedMember) => + searchedMember.contributorAddress === streamingPayment.user, + ); + const filteredActions = streamingPayment.actions.filter((action) => + action.title.toLowerCase().includes(searchValue.toLowerCase()), + ); + + if (member) { + return { + user: streamingPayment.user, + tokenTotalsPerMonth: streamingPayment.tokenTotalsPerMonth, + actions: streamingPayment.actions, + }; + } + + if (filteredActions.length > 0) { + return { + user: streamingPayment.user, + tokenTotalsPerMonth: streamingPayment.tokenTotalsPerMonth, + actions: filteredActions, + }; + } + + return null; +}; + +export const filterByActionStatus = ( + action: StreamingTableFieldModel, + statuses?: StreamingPaymentStatus[], +): StreamingTableFieldModel | null => { + if (!statuses) { + return action; + } + + const filteredActions = action.actions.filter((actionItem) => + statuses.includes(actionItem.status), + ); + + if (filteredActions.length > 0) { + return { + user: action.user, + tokenTotalsPerMonth: action.tokenTotalsPerMonth, + actions: filteredActions, + }; + } + + return null; +}; + +export const filterByEndCondition = ( + action: StreamingTableFieldModel, + endConditions?: StreamingPaymentEndCondition[], +): StreamingTableFieldModel | null => { + if (!endConditions) { + return action; + } + + const filteredActions = action.actions.filter((actionItem) => { + const { endCondition } = actionItem || {}; + + if (!endCondition) { + return false; + } + + return endConditions.includes(endCondition); + }); + + if (filteredActions.length > 0) { + return { + ...action, + actions: filteredActions, + }; + } + + return null; +}; + +export const sortStreamingPayments = ( + actions: StreamingTableFieldModel[], + activeFilters: StreamingPaymentFilters, +) => + actions.sort((a, b) => { + const totalStreamedFilter = activeFilters.totalStreamedFilters; + + if (totalStreamedFilter) { + const aTotalStreamed = a.actions.reduce( + (acc, { totalStreamedAmount }) => + acc.add(BigNumber.from(totalStreamedAmount)), + BigNumber.from(0), + ); + const bTotalStreamed = b.actions.reduce( + (acc, { totalStreamedAmount }) => + acc.add(BigNumber.from(totalStreamedAmount)), + BigNumber.from(0), + ); + + if (totalStreamedFilter === ModelSortDirection.Asc) { + return aTotalStreamed.lt(bTotalStreamed) ? -1 : 1; + } + return aTotalStreamed.gt(bTotalStreamed) ? -1 : 1; + } + + const aHasActive = a.actions.some( + (action) => action.status === StreamingPaymentStatus.Active, + ); + const bHasActive = b.actions.some( + (action) => action.status === StreamingPaymentStatus.Active, + ); + + if (aHasActive === bHasActive) { + return 0; + } + return aHasActive ? -1 : 1; + }); diff --git a/src/components/shared/Extensions/Tooltip/Tooltip.styles.ts b/src/components/shared/Extensions/Tooltip/Tooltip.styles.ts index 5cad2e9a082..18c57ca984a 100644 --- a/src/components/shared/Extensions/Tooltip/Tooltip.styles.ts +++ b/src/components/shared/Extensions/Tooltip/Tooltip.styles.ts @@ -42,6 +42,7 @@ const bottomPlacementClasses = tw` const topAndBottomStartPlacementClasses = tw` group-data-[popper-placement='bottom-start']:!left-2 group-data-[popper-placement='top-start']:!left-2 + group-data-[popper-placement='top-start']:!top-auto group-data-[popper-placement='bottom-start']:!transform-none group-data-[popper-placement='top-start']:!transform-none `; @@ -84,6 +85,7 @@ const tooltipClasses = { ${topAndBottomEndPlacementClasses} ${topAndBottomStartPlacementClasses} ${rightPlacementClasses} + ${topAndBottomEndPlacementClasses} `, }; diff --git a/src/components/shared/StreamingPayments/hooks.ts b/src/components/shared/StreamingPayments/hooks.ts new file mode 100644 index 00000000000..bc4c3691164 --- /dev/null +++ b/src/components/shared/StreamingPayments/hooks.ts @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useAppContext } from '~context/AppContext/AppContext.ts'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { useCurrencyContext } from '~context/CurrencyContext/CurrencyContext.ts'; +import { useGetStreamingPaymentsByColonyQuery } from '~gql'; +import useCurrentBlockTime from '~hooks/useCurrentBlockTime.ts'; +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { notNull } from '~utils/arrays/index.ts'; +import { getStreamingPaymentStatus } from '~utils/streamingPayments.ts'; + +import { type StreamingPaymentItems } from './types.ts'; +import { calculateTotalsFromStreams } from './utils.ts'; + +interface useStreamingPaymentsTotalFundsProps { + isFilteredByWalletAddress?: boolean; + nativeDomainId?: number; +} + +export const useStreamingPaymentsTotalFunds = ({ + isFilteredByWalletAddress = true, + nativeDomainId = undefined, +}: useStreamingPaymentsTotalFundsProps) => { + const { user } = useAppContext(); + const { walletAddress } = user ?? {}; + const { currentBlockTime: blockTime } = useCurrentBlockTime(); + const { colony } = useColonyContext(); + + const { data, loading, fetchMore } = useGetStreamingPaymentsByColonyQuery({ + variables: { + ...(isFilteredByWalletAddress && + walletAddress && { + recipientAddress: walletAddress, + }), + ...(nativeDomainId && { + domainId: nativeDomainId, + }), + colonyId: colony.colonyAddress, + }, + onCompleted: (receivedData) => { + if (receivedData?.getStreamingPaymentsByColony?.nextToken) { + fetchMore({ + variables: { + nextToken: receivedData.getStreamingPaymentsByColony.nextToken, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + + return { + ...prev, + getStreamingPaymentsByColony: { + ...prev.getStreamingPaymentsByColony, + items: [ + ...(prev?.getStreamingPaymentsByColony?.items || []), + ...(fetchMoreResult?.getStreamingPaymentsByColony?.items || + []), + ], + nextToken: + fetchMoreResult?.getStreamingPaymentsByColony?.nextToken, + }, + }; + }, + }); + } + }, + }); + + const streamingPayments = useMemo( + () => data?.getStreamingPaymentsByColony?.items?.filter(notNull) || [], + [data?.getStreamingPaymentsByColony?.items], + ); + + const { currency } = useCurrencyContext(); + + const [totalFunds, setTotalFunds] = useState<{ + totalAvailable: number; + totalClaimed: number; + }>({ + totalAvailable: 0, + totalClaimed: 0, + }); + const [isAnyPaymentActive, setIsAnyPaymentActive] = useState(false); + const [ratePerSecond, setRatePerSecond] = useState(0); + const [activeStreamingPayments, setActiveStreamingPayments] = useState(0); + const [totalLastMonthStreaming, setTotalLastMonthStreaming] = useState(0); + + const getTotalFunds = useCallback( + async (items: StreamingPaymentItems, currentTimestamp: number) => { + const { + totalAvailable, + totalClaimed, + isAtLeastOnePaymentActive, + ratePerSecond: ratePerSecondValue, + lastMonthStreaming, + } = await calculateTotalsFromStreams({ + streamingPayments: items, + currentTimestamp, + currency, + colony, + }); + + setTotalLastMonthStreaming(lastMonthStreaming); + + setIsAnyPaymentActive(isAtLeastOnePaymentActive); + setTotalFunds({ + totalAvailable, + totalClaimed, + }); + setRatePerSecond(ratePerSecondValue); + }, + [colony, currency], + ); + + const getTotalActiveStreamingPayments = useCallback( + (items: StreamingPaymentItems) => { + const activeStreams = items.filter((item) => { + return ( + getStreamingPaymentStatus({ + streamingPayment: item, + currentTimestamp: Math.floor(blockTime ?? Date.now() / 1000), + }) === StreamingPaymentStatus.Active + ); + }); + + setActiveStreamingPayments(activeStreams.length); + }, + [blockTime], + ); + + useEffect(() => { + fetchMore({ + variables: { + ...(isFilteredByWalletAddress && + walletAddress && { + recipientAddress: walletAddress, + }), + ...(nativeDomainId && { + domainId: nativeDomainId, + }), + colonyId: colony.colonyAddress, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + + return { + ...prev, + getStreamingPaymentsByColony: { + ...prev.getStreamingPaymentsByColony, + items: [ + ...(prev?.getStreamingPaymentsByColony?.items || []), + ...(fetchMoreResult?.getStreamingPaymentsByColony?.items || []), + ], + nextToken: fetchMoreResult?.getStreamingPaymentsByColony?.nextToken, + }, + }; + }, + }); + }, [ + colony.colonyAddress, + fetchMore, + walletAddress, + isFilteredByWalletAddress, + nativeDomainId, + ]); + useEffect(() => { + if (streamingPayments.length) { + getTotalFunds( + streamingPayments, + Math.floor(blockTime ?? Date.now() / 1000), + ); + getTotalActiveStreamingPayments(streamingPayments); + } + }, [ + blockTime, + getTotalFunds, + streamingPayments, + getTotalActiveStreamingPayments, + ]); + + return { + totalStreamed: totalFunds.totalClaimed + totalFunds.totalAvailable, + totalFunds, + isAnyPaymentActive, + ratePerSecond, + loading, + currency, + getTotalFunds, + streamingPayments, + activeStreamingPayments, + totalLastMonthStreaming, + }; +}; diff --git a/src/components/shared/StreamingPayments/types.ts b/src/components/shared/StreamingPayments/types.ts new file mode 100644 index 00000000000..48671709c51 --- /dev/null +++ b/src/components/shared/StreamingPayments/types.ts @@ -0,0 +1,5 @@ +import { type GetStreamingPaymentsByColonyQuery } from '~gql'; + +export type StreamingPaymentItems = NonNullable< + NonNullable +>['items']; diff --git a/src/components/shared/StreamingPayments/utils.ts b/src/components/shared/StreamingPayments/utils.ts new file mode 100644 index 00000000000..ce582cbceaa --- /dev/null +++ b/src/components/shared/StreamingPayments/utils.ts @@ -0,0 +1,171 @@ +import Decimal from 'decimal.js'; + +import { type ColonyFragment, type SupportedCurrencies } from '~gql'; +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { fetchCurrentPrice } from '~utils/currency/currency.ts'; +import { + getStreamingPaymentAmountsLeft, + getStreamingPaymentStatus, +} from '~utils/streamingPayments.ts'; +import { + getSelectedToken, + getTokenDecimalsWithFallback, +} from '~utils/tokens.ts'; + +import { type StreamingPaymentItems } from './types.ts'; + +export const calculateToCurrency = async ({ + amount, + tokenAddress, + currency, + colony, +}: { + amount: string; + tokenAddress: string; + currency: SupportedCurrencies; + colony: ColonyFragment; +}): Promise => { + const currentToken = getSelectedToken(colony, tokenAddress); + const { decimals } = currentToken || {}; + + const currentPrice = await fetchCurrentPrice({ + contractAddress: tokenAddress, + conversionDenomination: currency, + }); + + if (currentPrice === null) { + return null; + } + + const balanceInWeiToEth = new Decimal(amount).div( + 10 ** getTokenDecimalsWithFallback(decimals), + ); + + return new Decimal(balanceInWeiToEth).mul(currentPrice ?? 0); +}; + +const calculateActiveStreaming = ( + singleStreamAmount: number, + streamingInterval: number, +) => { + const thirtyDaysInSeconds = 30 * 24 * 3600; + + const activeStreaming = + (singleStreamAmount / streamingInterval) * thirtyDaysInSeconds; + return activeStreaming; +}; + +export const calculateTotalsFromStreams = async ({ + colony, + currency, + streamingPayments, + currentTimestamp, +}: { + streamingPayments: StreamingPaymentItems; + currentTimestamp: number; + currency: SupportedCurrencies; + colony: ColonyFragment; +}) => { + const totals = streamingPayments.reduce( + async (result, item) => { + if (!item) { + return result; + } + + const { amountClaimedToDate, amountAvailableToClaim } = + getStreamingPaymentAmountsLeft(item, currentTimestamp); + + const paymentStatus = getStreamingPaymentStatus({ + streamingPayment: item, + currentTimestamp, + }); + + const amountAvailableToClaimToCurrency = await calculateToCurrency({ + amount: amountAvailableToClaim, + tokenAddress: item.tokenAddress, + currency, + colony, + }); + + const amountClaimedToDateToCurrency = await calculateToCurrency({ + amount: amountClaimedToDate, + tokenAddress: item.tokenAddress, + currency, + colony, + }); + + const ratePerSecondValue = new Decimal(item.amount || '0').div( + item.interval || 1, + ); + + const ratePerSecondToCurrency = await calculateToCurrency({ + amount: ratePerSecondValue.toString(), + tokenAddress: item.tokenAddress, + currency, + colony, + }); + + const singleStreamAmount = await calculateToCurrency({ + amount: item.amount, + tokenAddress: item.tokenAddress, + currency, + colony, + }); + + const singleStreamAmountToNumber = singleStreamAmount?.toNumber() ?? 0; + + const streamedFundsInLast30Days = calculateActiveStreaming( + singleStreamAmountToNumber, + Number(item.interval), + ); + + const { + totalAvailable, + totalClaimed, + isAtLeastOnePaymentActive, + ratePerSecond, + lastMonthStreaming, + } = await result; + + return { + totalAvailable: totalAvailable.add( + amountAvailableToClaimToCurrency ?? '0', + ), + totalClaimed: totalClaimed.add(amountClaimedToDateToCurrency ?? '0'), + ratePerSecond: ratePerSecond.add(ratePerSecondToCurrency ?? '0'), + isAtLeastOnePaymentActive: + paymentStatus === StreamingPaymentStatus.Active || + isAtLeastOnePaymentActive, + lastMonthStreaming: lastMonthStreaming.add( + paymentStatus === StreamingPaymentStatus.Active + ? streamedFundsInLast30Days + : 0, + ), + }; + }, + Promise.resolve({ + totalAvailable: new Decimal(0), + totalClaimed: new Decimal(0), + ratePerSecond: new Decimal(0), + isAtLeastOnePaymentActive: false, + itemsWithinLastMonth: [], + lastMonthStreaming: new Decimal(0), + }), + ); + + const { + totalClaimed, + totalAvailable, + isAtLeastOnePaymentActive, + ratePerSecond, + lastMonthStreaming, + } = await totals; + + return { + totalClaimed: totalClaimed.toNumber(), + totalAvailable: totalAvailable.toNumber(), + ratePerSecond: ratePerSecond.toNumber(), + isAtLeastOnePaymentActive, + lastMonthStreaming: lastMonthStreaming.toNumber(), + }; +}; diff --git a/src/components/v5/common/ActionSidebar/ActionSidebar.tsx b/src/components/v5/common/ActionSidebar/ActionSidebar.tsx index 1ad718640c8..6e1b7da4da6 100644 --- a/src/components/v5/common/ActionSidebar/ActionSidebar.tsx +++ b/src/components/v5/common/ActionSidebar/ActionSidebar.tsx @@ -1,44 +1,30 @@ -import { - ArrowLineRight, - ArrowsOutSimple, - ShareNetwork, - WarningCircle, - X, -} from '@phosphor-icons/react'; +import { WarningCircle } from '@phosphor-icons/react'; import clsx from 'clsx'; -import { motion } from 'framer-motion'; import React, { type FC, type PropsWithChildren, useEffect, - useLayoutEffect, useRef, } from 'react'; -import { isFullScreen } from '~constants/index.ts'; import { useActionContext } from '~context/ActionContext/ActionContext.ts'; import { useActionSidebarContext } from '~context/ActionSidebarContext/ActionSidebarContext.ts'; -import { useMobile } from '~hooks/index.ts'; -import useCopyToClipboard from '~hooks/useCopyToClipboard.ts'; +import ActionStatusContextProvider from '~context/ActionStatusContext/ActionStatusContextProvider.tsx'; import useDisableBodyScroll from '~hooks/useDisableBodyScroll/index.ts'; import { useDraftAgreement } from '~hooks/useDraftAgreement.ts'; -import useToggle from '~hooks/useToggle/index.ts'; -import Tooltip from '~shared/Extensions/Tooltip/Tooltip.tsx'; import { formatText } from '~utils/intl.ts'; import Modal from '~v5/shared/Modal/index.ts'; import CompletedAction from '../CompletedAction/index.ts'; -import PillsBase from '../Pills/PillsBase.tsx'; -import { ACTION_TYPE_FIELD_NAME, actionSidebarAnimation } from './consts.ts'; +import { ACTION_TYPE_FIELD_NAME } from './consts.ts'; import useCloseSidebarClick from './hooks/useCloseSidebarClick.ts'; import useGetGroupedActionComponent from './hooks/useGetGroupedActionComponent.tsx'; import { ActionNotFound } from './partials/ActionNotFound.tsx'; import ActionSidebarContent from './partials/ActionSidebarContent/ActionSidebarContent.tsx'; -import ActionSidebarLoadingSkeleton from './partials/ActionSidebarLoadingSkeleton/ActionSidebarLoadingSkeleton.tsx'; -import ExpenditureActionStatusBadge from './partials/ExpenditureActionStatusBadge/ExpenditureActionStatusBadge.tsx'; +import ActionSidebarLayout from './partials/ActionSidebarLayout/ActionSidebarLayout.tsx'; +import ActionSidebarStatusPill from './partials/ActionSidebarStatusPill/ActionSidebarStatusPill.tsx'; import { GoBackButton } from './partials/GoBackButton/GoBackButton.tsx'; -import MotionOutcomeBadge from './partials/MotionOutcomeBadge/index.ts'; import { type ActionSidebarProps } from './types.ts'; import { getActionGroup, mapActionTypeToAction } from './utils.ts'; @@ -46,6 +32,7 @@ const displayName = 'v5.common.ActionSidebar'; const ActionSidebar: FC> = ({ children, + transactionId, className, }) => { const { @@ -54,9 +41,7 @@ const ActionSidebar: FC> = ({ isValidTransactionHash, loadingAction, isMotion, - isMultiSig, motionState, - expenditure, loadingExpenditure, startActionPoll, stopActionPoll, @@ -75,17 +60,9 @@ const ActionSidebar: FC> = ({ actionSidebarInitialValues?.[ACTION_TYPE_FIELD_NAME] || actionType, ); const GroupedActionComponent = useGetGroupedActionComponent(); - const [isSidebarFullscreen, { toggle: toggleIsSidebarFullscreen, toggleOn }] = - useToggle(); const timeout = useRef(); - useLayoutEffect(() => { - if (localStorage.getItem(isFullScreen) === 'true') { - toggleOn(); - } - }, [toggleOn]); - useEffect(() => { clearTimeout(timeout.current); @@ -107,9 +84,6 @@ const ActionSidebar: FC> = ({ formContextOverride: formRef.current, }); - const { isCopied, handleClipboardCopy } = useCopyToClipboard(); - const isMobile = useMobile(); - useDisableBodyScroll(isActionSidebarOpen); const isLoading = !!transactionHash && (loadingAction || loadingExpenditure); @@ -145,165 +119,64 @@ const ActionSidebar: FC> = ({ ); }; - const getShareButton = () => - !!transactionHash && ( - - - - ); - return ( - -
    -
    -
    - - {actionGroupType && ( - - )} - {!isMobile && ( -
    -
    - - {getShareButton()} -
    - {action && - !isMotion && - !isMultiSig && - !expenditure && - !loadingExpenditure && ( - - {formatText({ id: 'action.passed' })} - - )} - {!!expenditure && ( - - )} - {(!!( - isMotion && action?.motionData?.motionStateHistory.endedAt - ) || - !!isMultiSig) && - motionState && ( - - )} -
    - )} - {isMobile && getShareButton()} -
    -
    {children}
    -
    -
    - {isLoading ? ( - - ) : ( -
    + + closeSidebarClick({ + shouldShowCancelModal: !getIsDraftAgreement(), + }) + } + className={className} + additionalTopContent={children} + isLoading={isLoading} + statusPill={ + action && !isLoading ? : undefined + } + transactionId={transactionId} + isMotion={isMotion} + actionNotFound={!!actionNotFound} + goBackButton={ + actionGroupType ? ( + + ) : null + } + transactionHash={transactionHash} + > +
    {getSidebarContent()}
    - )} - { - toggleCancelModalOff(); - toggleActionSidebarOff(); - }} - icon={WarningCircle} - buttonMode="primarySolid" - confirmMessage={formatText({ id: 'button.cancelAction' })} - closeMessage={formatText({ - id: 'button.continueAction', - })} - /> - + { + toggleCancelModalOff(); + toggleActionSidebarOff(); + }} + icon={WarningCircle} + buttonMode="primarySolid" + confirmMessage={formatText({ id: 'button.cancelAction' })} + closeMessage={formatText({ + id: 'button.continueAction', + })} + /> +
    + ); }; diff --git a/src/components/v5/common/ActionSidebar/hooks/permissions/helpers.ts b/src/components/v5/common/ActionSidebar/hooks/permissions/helpers.ts index aecab9d9511..1c2b3812082 100644 --- a/src/components/v5/common/ActionSidebar/hooks/permissions/helpers.ts +++ b/src/components/v5/common/ActionSidebar/hooks/permissions/helpers.ts @@ -109,6 +109,8 @@ export const getPermissionsNeededForAction = ( case Action.ArbitraryTxs: { return PERMISSIONS_NEEDED_FOR_ACTION.ArbitraryTxs; } + case Action.StreamingPayment: + return PERMISSIONS_NEEDED_FOR_ACTION.StreamingPayment; default: return undefined; diff --git a/src/components/v5/common/ActionSidebar/hooks/useActionFormBaseHook.ts b/src/components/v5/common/ActionSidebar/hooks/useActionFormBaseHook.ts index 02ec3dd95a9..4fe68961b9f 100644 --- a/src/components/v5/common/ActionSidebar/hooks/useActionFormBaseHook.ts +++ b/src/components/v5/common/ActionSidebar/hooks/useActionFormBaseHook.ts @@ -46,6 +46,7 @@ const useActionFormBaseHook: UseActionFormBaseHook = ({ validationSchema, actionType, id, + transform, primaryButton?.onClick, primaryButton?.type, onFormClose?.shouldShowCancelModal, diff --git a/src/components/v5/common/ActionSidebar/hooks/useActionsList.ts b/src/components/v5/common/ActionSidebar/hooks/useActionsList.ts index a5c77e75d6e..50d6c9e737b 100644 --- a/src/components/v5/common/ActionSidebar/hooks/useActionsList.ts +++ b/src/components/v5/common/ActionSidebar/hooks/useActionsList.ts @@ -31,11 +31,6 @@ const useActionsList = () => { value: Action.PaymentBuilder, isNew: true, }, - // @BETA: Disabled for now - // { - // label: { id: 'actions.batchPayment' }, - // value: Action.BatchPayment, - // }, { label: { id: 'actions.splitPayment' }, value: Action.SplitPayment, @@ -46,10 +41,10 @@ const useActionsList = () => { value: Action.StagedPayment, isNew: true, }, - // { - // label: { id: 'actions.streamingPayment' }, - // value: Action.StreamingPayment, - // }, + { + label: { id: 'actions.streamingPayment' }, + value: Action.StreamingPayment, + }, ], }, { diff --git a/src/components/v5/common/ActionSidebar/hooks/useGetStreamingPaymentData.ts b/src/components/v5/common/ActionSidebar/hooks/useGetStreamingPaymentData.ts new file mode 100644 index 00000000000..c8cc5b07aa1 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/hooks/useGetStreamingPaymentData.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; + +import { FAILED_LOADING_DURATION as POLLING_TIMEOUT } from '~frame/LoadingTemplate/index.ts'; +import { useGetStreamingPaymentQuery } from '~gql'; +import noop from '~utils/noop.ts'; +import { getSafePollingInterval } from '~utils/queries.ts'; + +export const useGetStreamingPaymentData = ( + streamingPaymentId: string | null | undefined, +) => { + const pollInterval = getSafePollingInterval(); + + const { data, refetch, loading, startPolling, stopPolling } = + useGetStreamingPaymentQuery({ + variables: { + streamingPaymentId: streamingPaymentId || '', + }, + skip: Number.isNaN(streamingPaymentId) || !streamingPaymentId, + fetchPolicy: 'cache-and-network', + pollInterval, + }); + + const streamingPayment = data?.getStreamingPayment; + + useEffect(() => { + const shouldPoll = streamingPaymentId && !streamingPayment; + + if (!shouldPoll) { + return noop; + } + + const cancelPollingTimer = setTimeout(stopPolling, POLLING_TIMEOUT); + + startPolling(pollInterval); + + return () => { + if (cancelPollingTimer) { + clearTimeout(cancelPollingTimer); + } + + stopPolling(); + }; + }, [ + streamingPayment, + streamingPaymentId, + stopPolling, + startPolling, + pollInterval, + ]); + + return { + streamingPaymentData: streamingPayment, + loadingStreamingPayment: loading, + refetchStreamingPayment: refetch, + startPolling, + stopPolling, + }; +}; + +export type UseGetStreamingPaymentDataReturnType = ReturnType< + typeof useGetStreamingPaymentData +>; diff --git a/src/components/v5/common/ActionSidebar/hooks/useSidebarActionForm.ts b/src/components/v5/common/ActionSidebar/hooks/useSidebarActionForm.ts index dd3bd96583a..dc327208e87 100644 --- a/src/components/v5/common/ActionSidebar/hooks/useSidebarActionForm.ts +++ b/src/components/v5/common/ActionSidebar/hooks/useSidebarActionForm.ts @@ -20,6 +20,7 @@ import PaymentBuilderForm from '../partials/forms/PaymentBuilderForm/index.ts'; import SinglePaymentForm from '../partials/forms/SimplePaymentForm/index.ts'; import SplitPaymentForm from '../partials/forms/SplitPaymentForm/index.ts'; import StagedPaymentForm from '../partials/forms/StagedPaymentForm/StagedPaymentForm.tsx'; +import StreamingPaymentForm from '../partials/forms/StreamingPaymentForm/StreamingPaymentForm.tsx'; import TransferFundsForm from '../partials/forms/TransferFundsForm/index.ts'; import UnlockTokenForm from '../partials/forms/UnlockTokenForm/index.ts'; import UpgradeColonyForm from '../partials/forms/UpgradeColonyForm/index.ts'; @@ -49,6 +50,7 @@ const useSidebarActionForm = () => { [Action.ManageVerifiedMembers]: ManageVerifiedMembersForm, [Action.ManageReputation]: ManageReputationForm, [Action.ArbitraryTxs]: ArbitraryTxsForm, + [Action.StreamingPayment]: StreamingPaymentForm, }), [], ); diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarContent/partials/PermissionSidebar.tsx b/src/components/v5/common/ActionSidebar/partials/ActionSidebarContent/partials/PermissionSidebar.tsx index 191cc11a7a4..e0fec485e53 100644 --- a/src/components/v5/common/ActionSidebar/partials/ActionSidebarContent/partials/PermissionSidebar.tsx +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarContent/partials/PermissionSidebar.tsx @@ -1,6 +1,7 @@ import React, { type FC } from 'react'; import PermissionRow from '~frame/v5/pages/VerifiedPage/partials/PermissionRow/index.ts'; +import { formatDate } from '~utils/date.ts'; import { formatText } from '~utils/intl.ts'; import MenuWithStatusText from '~v5/shared/MenuWithStatusText/index.ts'; import RelativeDate from '~v5/shared/RelativeDate/index.ts'; diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/ActionSidebarDescription.tsx b/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/ActionSidebarDescription.tsx index 3a9fce5b269..2470645c9a9 100644 --- a/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/ActionSidebarDescription.tsx +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/ActionSidebarDescription.tsx @@ -18,6 +18,7 @@ import PaymentBuilderDescription from './partials/PaymentBuilderDescription.tsx' import SimplePaymentDescription from './partials/SimplePaymentDescription.tsx'; import SplitPaymentDescription from './partials/SplitPaymentDescription.tsx'; import StagedPaymentsDescription from './partials/StagedPaymentsDescription.tsx'; +import StreamingPaymentDescription from './partials/StreamingPaymentDescription.tsx'; import TransferFundsDescription from './partials/TransferFundsDescription.tsx'; import UnlockTokenDescription from './partials/UnlockTokenDescription.tsx'; import UpgradeColonyDescription from './partials/UpgradeColonyDescription.tsx'; @@ -65,6 +66,8 @@ const ActionSidebarDescription = () => { return ; case Action.ArbitraryTxs: return ; + case Action.StreamingPayment: + return ; default: return null; } diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/partials/StreamingPaymentDescription.tsx b/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/partials/StreamingPaymentDescription.tsx new file mode 100644 index 00000000000..84b11139b23 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/partials/StreamingPaymentDescription.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { useFormContext } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import Numeral from '~shared/Numeral/Numeral.tsx'; +import { ColonyActionType } from '~types/graphql.ts'; +import { formatText } from '~utils/intl.ts'; +import { getAmountPerValue } from '~utils/streamingPayments.ts'; +import { getSelectedToken } from '~utils/tokens.ts'; +import { type StreamingPaymentFormValues } from '~v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/hooks.ts'; +import { getInterval } from '~v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/utils.ts'; + +import CurrentUser from './CurrentUser.tsx'; +import RecipientUser from './RecipientUser.tsx'; + +const displayName = + 'v5.common.ActionsSidebar.partials.ActionSidebarDescription.partials.StreamingPaymentDescription'; + +export const StreamingPaymentDescription = () => { + const { colony } = useColonyContext(); + const formValues = useFormContext().getValues(); + const { amount, recipient, tokenAddress, period } = formValues; + + if (!amount || !tokenAddress) { + return ( + , + }} + /> + ); + } + + const recipientUser = recipient ? ( + + ) : ( + formatText({ + id: 'actionSidebar.metadataDescription.recipient', + }) + ); + + const selectedToken = getSelectedToken(colony, tokenAddress); + const interval = getInterval(period); + + return ( + , + period: + period && interval + ? getAmountPerValue(interval.toString()).toLowerCase() + : formatText({ + id: 'actionSidebar.amountPer.options.week', + }).toLowerCase(), + initiator: , + }} + /> + ); +}; + +StreamingPaymentDescription.displayName = displayName; +export default StreamingPaymentDescription; diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/ActionSidebarLayout.tsx b/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/ActionSidebarLayout.tsx new file mode 100644 index 00000000000..76b45e87ddd --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/ActionSidebarLayout.tsx @@ -0,0 +1,171 @@ +import { + ArrowLineRight, + ArrowsOutSimple, + ShareNetwork, + X, +} from '@phosphor-icons/react'; +import clsx from 'clsx'; +import { motion } from 'framer-motion'; +import React, { + type PropsWithChildren, + useLayoutEffect, + forwardRef, +} from 'react'; + +import { isFullScreen } from '~constants/index.ts'; +import { useMobile } from '~hooks/index.ts'; +import useCopyToClipboard from '~hooks/useCopyToClipboard.ts'; +import useToggle from '~hooks/useToggle/index.ts'; +import Tooltip from '~shared/Extensions/Tooltip/Tooltip.tsx'; +import { formatText } from '~utils/intl.ts'; + +import ActionSidebarLoadingSkeleton from '../ActionSidebarLoadingSkeleton/ActionSidebarLoadingSkeleton.tsx'; + +import { actionSidebarAnimation } from './consts.ts'; +import { type ActionSidebarLayoutProps } from './types.ts'; + +const displayName = 'v5.common.ActionSidebar.partials.ActionSidebarLayout'; + +const ActionSidebarLayout = forwardRef< + HTMLDivElement, + PropsWithChildren +>( + ( + { + onCloseClick, + children, + additionalTopContent, + statusPill, + isLoading, + className, + isMotion, + transactionId, + actionNotFound, + goBackButton, + transactionHash, + }, + ref, + ) => { + const isMobile = useMobile(); + const { isCopied, handleClipboardCopy } = useCopyToClipboard(); + + const [ + isSidebarFullscreen, + { toggle: toggleIsSidebarFullscreen, toggleOn }, + ] = useToggle(); + + useLayoutEffect(() => { + if (localStorage.getItem(isFullScreen) === 'true') { + toggleOn(); + } + }, [toggleOn]); + + const getShareButton = () => + !!transactionHash && ( + + + + ); + + return ( + +
    +
    +
    + + {goBackButton && goBackButton} + {isMobile ? ( + getShareButton() + ) : ( +
    +
    + + {getShareButton()} +
    + {statusPill} +
    + )} +
    + {additionalTopContent &&
    {additionalTopContent}
    } +
    +
    + {isLoading ? : children} +
    + ); + }, +); + +ActionSidebarLayout.displayName = displayName; +export default ActionSidebarLayout; diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/consts.ts b/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/consts.ts new file mode 100644 index 00000000000..5903318ae98 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/consts.ts @@ -0,0 +1,10 @@ +import { type Variants } from 'framer-motion'; + +export const actionSidebarAnimation: Variants = { + hidden: { + x: '100%', + }, + visible: { + x: 0, + }, +}; diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/types.ts b/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/types.ts new file mode 100644 index 00000000000..52e47aab20b --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarLayout/types.ts @@ -0,0 +1,12 @@ +export interface ActionSidebarLayoutProps { + onCloseClick: () => void; + additionalTopContent?: React.ReactNode; + statusPill?: React.ReactNode; + className?: string; + isLoading?: boolean; + transactionId?: string; + isMotion: boolean; + actionNotFound?: boolean; + goBackButton?: React.ReactNode; + transactionHash: string | null; +} diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarNotFoundContent/ActionSidebarNotFoundContent.tsx b/src/components/v5/common/ActionSidebar/partials/ActionSidebarNotFoundContent/ActionSidebarNotFoundContent.tsx new file mode 100644 index 00000000000..5e4b92ab454 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarNotFoundContent/ActionSidebarNotFoundContent.tsx @@ -0,0 +1,81 @@ +import React, { type FC } from 'react'; +import { Link } from 'react-router-dom'; + +import { COLONY_ACTIVITY_ROUTE, TX_SEARCH_PARAM } from '~routes'; +import { formatText } from '~utils/intl.ts'; +import { removeQueryParamFromUrl } from '~utils/urls.ts'; +import FourOFourMessage from '~v5/common/FourOFourMessage/FourOFourMessage.tsx'; +import Button from '~v5/shared/Button/Button.tsx'; +import ButtonLink from '~v5/shared/Button/ButtonLink.tsx'; + +import { type ActionSidebarNotFoundContentProps } from './types.ts'; + +const ActionSidebarNotFoundContent: FC = ({ + toggleActionSidebarOff, + startPollingForAction, + isInvalidTransactionHash, +}) => { + return ( +
    + + {!isInvalidTransactionHash && ( + + {formatText({ + id: 'actionSidebar.fourOfour.activityPageLink', + })} + + )} + + {formatText({ + id: 'actionSidebar.fourOfour.createNewAction', + })} + + + } + primaryLinkButton={ + isInvalidTransactionHash ? ( + + {formatText({ + id: 'actionSidebar.fourOfour.activityPageLink', + })} + + ) : ( + + ) + } + /> +
    + ); +}; + +export default ActionSidebarNotFoundContent; diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarNotFoundContent/types.ts b/src/components/v5/common/ActionSidebar/partials/ActionSidebarNotFoundContent/types.ts new file mode 100644 index 00000000000..1e88b1e7cba --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarNotFoundContent/types.ts @@ -0,0 +1,5 @@ +export interface ActionSidebarNotFoundContentProps { + toggleActionSidebarOff: () => void; + startPollingForAction: () => void; + isInvalidTransactionHash?: boolean; +} diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarStatusPill/ActionSidebarStatusPill.tsx b/src/components/v5/common/ActionSidebar/partials/ActionSidebarStatusPill/ActionSidebarStatusPill.tsx new file mode 100644 index 00000000000..5e54855f6c4 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarStatusPill/ActionSidebarStatusPill.tsx @@ -0,0 +1,75 @@ +import React, { type FC } from 'react'; + +import LoadingSkeleton from '~common/LoadingSkeleton/LoadingSkeleton.tsx'; +import { useActionStatusContext } from '~context/ActionStatusContext/ActionStatusContext.ts'; +import { ColonyActionType } from '~gql'; +import { type ExpenditureActionStatus } from '~types/expenditures.ts'; +import { type StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { type MotionState } from '~utils/colonyMotions.ts'; +import { formatText } from '~utils/intl.ts'; +import PillsBase from '~v5/common/Pills/PillsBase.tsx'; + +import ExpenditureActionStatusBadge from '../ExpenditureActionStatusBadge/ExpenditureActionStatusBadge.tsx'; +import MotionOutcomeBadge from '../MotionOutcomeBadge/MotionOutcomeBadge.tsx'; +import StreamingPaymentStatusPill from '../StreamingPaymentStatusPill/StreamingPaymentStatusPill.tsx'; + +const ActionSidebarStatusPill: FC = () => { + const { actionType, actionStatus, isLoading } = useActionStatusContext(); + + if (!actionType || !actionStatus) { + return null; + } + + const getStatuPill = () => { + switch (actionType) { + case ColonyActionType.CreateExpenditure: { + return ( + + ); + } + case ColonyActionType.CreateStreamingPayment: { + return ( + + ); + } + case ColonyActionType.CancelStreamingPayment: { + return ( + + ); + } + default: { + if (actionType.endsWith('Motion') || actionType.endsWith('Multisig')) { + return ( + + ); + } + + return ( + + {formatText({ id: 'action.passed' })} + + ); + } + } + }; + + return ( + + {getStatuPill()} + + ); +}; + +export default ActionSidebarStatusPill; diff --git a/src/components/v5/common/ActionSidebar/partials/AmountField/AmountField.tsx b/src/components/v5/common/ActionSidebar/partials/AmountField/AmountField.tsx index eb47844be4d..9d29ab2b8b6 100644 --- a/src/components/v5/common/ActionSidebar/partials/AmountField/AmountField.tsx +++ b/src/components/v5/common/ActionSidebar/partials/AmountField/AmountField.tsx @@ -72,8 +72,8 @@ const AmountField: FC = ({ tokenAddressController.value, ); - const [value, setValue] = useState( - field.value ? formatNumeral(field.value, formattingOptions) : undefined, + const [value, setValue] = useState( + field.value ? formatNumeral(field.value, formattingOptions) : '', ); const handleFieldChange = (e: ChangeEvent) => { diff --git a/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/AmountPerPeriodRow.tsx b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/AmountPerPeriodRow.tsx new file mode 100644 index 00000000000..68a458f2bba --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/AmountPerPeriodRow.tsx @@ -0,0 +1,57 @@ +import { Calendar } from '@phosphor-icons/react'; +import React from 'react'; + +import { formatText } from '~utils/intl.ts'; +import ActionFormRow from '~v5/common/ActionFormRow/index.ts'; +import useHasNoDecisionMethods from '~v5/common/ActionSidebar/hooks/permissions/useHasNoDecisionMethods.ts'; + +import { OPTIONS } from './consts.ts'; +import AmountPerPeriodRowField from './partials/AmountPerPeriodRowField/AmountPerPeriodRowField.tsx'; +import { type AmountPerPeriodRowProps } from './types.ts'; + +const displayName = 'v5.common.ActionSidebar.partials.AmountPerPeriodRow'; + +const AmountPerPeriodRow = ({ + title, + tooltips, + name, +}: AmountPerPeriodRowProps) => { + const hasNoDecisionMethods = useHasNoDecisionMethods(); + + return ( + +
    + +
    +
    + ); +}; + +AmountPerPeriodRow.displayName = displayName; + +export default AmountPerPeriodRow; diff --git a/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/consts.ts b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/consts.ts new file mode 100644 index 00000000000..e4a9c6be085 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/consts.ts @@ -0,0 +1,37 @@ +import { formatText } from '~utils/intl.ts'; +import { type CardSelectOptionsGroup } from '~v5/common/Fields/CardSelect/types.ts'; + +import { AmountPerInterval } from './types.ts'; + +export const OPTIONS: CardSelectOptionsGroup[] = [ + { + key: '1', + title: formatText({ id: 'actionSidebar.amountPer.options.title' }), + options: [ + { + value: AmountPerInterval.Hour, + label: formatText({ + id: 'actionSidebar.amountPer.options.hour', + }), + }, + { + value: AmountPerInterval.Day, + label: formatText({ + id: 'actionSidebar.amountPer.options.day', + }), + }, + { + value: AmountPerInterval.Week, + label: formatText({ + id: 'actionSidebar.amountPer.options.week', + }), + }, + { + value: AmountPerInterval.Custom, + label: formatText({ + id: 'actionSidebar.amountPer.options.custom', + }), + }, + ], + }, +]; diff --git a/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/partials/AmountPerPeriodRowField/AmountPerPeriodRowField.tsx b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/partials/AmountPerPeriodRowField/AmountPerPeriodRowField.tsx new file mode 100644 index 00000000000..28e4b2d392c --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/partials/AmountPerPeriodRowField/AmountPerPeriodRowField.tsx @@ -0,0 +1,132 @@ +import clsx from 'clsx'; +import React, { useState, type FC } from 'react'; +import { useController } from 'react-hook-form'; + +import SpecialInputBase from '~common/Extensions/SpecialInput/SpecialInputBase.tsx'; +import { ONE_DAY_IN_SECONDS } from '~constants/time.ts'; +import { useMobile } from '~hooks'; +import { AmountPerInterval } from '~v5/common/ActionSidebar/partials/AmountPerPeriodRow/types.ts'; +import CardSelect from '~v5/common/Fields/CardSelect/CardSelect.tsx'; +import { FieldState } from '~v5/common/Fields/consts.ts'; + +import { type AmountPerPeriodRowFieldProps } from './types.ts'; + +const displayName = + 'v5.common.ActionSidebar.partials.AmountPerPeriodRow.partials.AmountPerPeriodRowField'; + +const AmountPerPeriodRowField: FC = ({ + name, + options, + placeholder: placeholderProp, + selectedValueWrapperClassName, +}) => { + const isMobile = useMobile(); + const { + field, + fieldState: { error }, + } = useController({ + name: `${name}.interval`, + }); + const { + field: fieldCustom, + fieldState: { error: errorCustom }, + } = useController({ + name: `${name}.custom`, + }); + + const [customInputValue, setCustomInputValue] = useState( + fieldCustom.value + ? (fieldCustom.value / ONE_DAY_IN_SECONDS).toString() + : '30', + ); + + return ( + + options={options} + state={error || errorCustom ? FieldState.Error : undefined} + value={field.value} + placeholder={placeholderProp} + cardClassName={clsx('sm:!w-[16rem]', { + '!left-6 right-6': isMobile, + 'pb-0': field.value === AmountPerInterval.Custom, + })} + renderSelectedValue={(selectedValue, placeholder) => { + if (selectedValue?.value === AmountPerInterval.Custom) { + return ( +
    + {`${customInputValue} Days`} +
    + ); + } + return ( +
    + {selectedValue?.label || placeholder} +
    + ); + }} + renderOptionWrapper={( + { value: itemValue, onClick, className, ...props }, + children, + ) => { + const isCustomValue = itemValue === AmountPerInterval.Custom; + + return isCustomValue ? ( +
    + + {field.value === AmountPerInterval.Custom && ( +
    + { + setCustomInputValue(e.currentTarget.value.substring(0, 5)); + + const calculatedValue = + Number(e.currentTarget.value.substring(0, 5)) * + ONE_DAY_IN_SECONDS; + + fieldCustom.onChange(calculatedValue); + }} + /> +
    + )} +
    + ) : ( + + ); + }} + /> + ); +}; + +AmountPerPeriodRowField.displayName = displayName; + +export default AmountPerPeriodRowField; diff --git a/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/partials/AmountPerPeriodRowField/types.ts b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/partials/AmountPerPeriodRowField/types.ts new file mode 100644 index 00000000000..85dcda5e72e --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/partials/AmountPerPeriodRowField/types.ts @@ -0,0 +1,8 @@ +import { type CardSelectOptionsGroup } from '~v5/common/Fields/CardSelect/types.ts'; + +export interface AmountPerPeriodRowFieldProps { + name: string; + options: CardSelectOptionsGroup[]; + placeholder: string; + selectedValueWrapperClassName?: string; +} diff --git a/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/types.ts b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/types.ts new file mode 100644 index 00000000000..92c41d21375 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/AmountPerPeriodRow/types.ts @@ -0,0 +1,14 @@ +import { type ActionFormRowProps } from '~v5/common/ActionFormRow/types.ts'; + +export enum AmountPerInterval { + Hour = 'hour', + Day = 'day', + Week = 'week', + Custom = 'custom', +} + +export interface AmountPerPeriodRowProps + extends Pick { + name: string; + title?: React.ReactNode; +} diff --git a/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/ExpenditureActionStatusBadge.tsx b/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/ExpenditureActionStatusBadge.tsx index 92a78a74d64..084ecbc5993 100644 --- a/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/ExpenditureActionStatusBadge.tsx +++ b/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/ExpenditureActionStatusBadge.tsx @@ -3,15 +3,12 @@ import React, { type FC } from 'react'; import { defineMessages } from 'react-intl'; import Tooltip from '~shared/Extensions/Tooltip/index.ts'; +import { ExpenditureActionStatus } from '~types/expenditures.ts'; import { formatText } from '~utils/intl.ts'; import PillsBase from '~v5/common/Pills/PillsBase.tsx'; import { EXPENDITURE_STATUS_TO_CLASSNAME_MAP } from './consts.ts'; -import { useExpenditureActionStatus } from './hooks.ts'; -import { - ExpenditureActionStatus, - type ExpenditureActionStatusBadgeProps, -} from './types.ts'; +import { type ExpenditureActionStatusBadgeProps } from './types.ts'; const displayName = 'v5.common.ActionSidebar.partials.ExpenditureActionStatusBadge'; @@ -19,7 +16,7 @@ const displayName = const MSG = defineMessages({ badgeText: { id: `${displayName}.badgeText`, - defaultMessage: `{activeStatus, select, + defaultMessage: `{status, select, ${ExpenditureActionStatus.Funding} {Funding} ${ExpenditureActionStatus.Cancel} {Cancel} ${ExpenditureActionStatus.Changes} {Changes} @@ -33,7 +30,7 @@ const MSG = defineMessages({ }, tooltipText: { id: `${displayName}.tooltipText`, - defaultMessage: `{activeStatus, select, + defaultMessage: `{status, select, ${ExpenditureActionStatus.Funding} {There is an active funding request for this payment.} ${ExpenditureActionStatus.Review} { Payment is currently in review. The payment creator can make changes freely until details are confirmed. @@ -51,25 +48,19 @@ const MSG = defineMessages({ }); const ExpenditureActionStatusBadge: FC = ({ - expenditure, + status, className, - withAdditionalStatuses, }) => { - const activeStatus = useExpenditureActionStatus( - expenditure, - withAdditionalStatuses, - ); - const pill = ( - {formatText(MSG.badgeText, { activeStatus })} + {formatText(MSG.badgeText, { status })} ); @@ -78,11 +69,8 @@ const ExpenditureActionStatusBadge: FC = ({ ExpenditureActionStatus.Review, ExpenditureActionStatus.Changes, ExpenditureActionStatus.Edit, - ].includes(activeStatus) ? ( - + ].includes(status) ? ( + {pill} ) : ( diff --git a/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/consts.ts b/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/consts.ts index 462a93606be..cde2f9bbcb2 100644 --- a/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/consts.ts +++ b/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/consts.ts @@ -1,7 +1,6 @@ +import { ExpenditureActionStatus } from '~types/expenditures.ts'; import { tw } from '~utils/css/index.ts'; -import { ExpenditureActionStatus } from './types.ts'; - export const EXPENDITURE_STATUS_TO_CLASSNAME_MAP: Record< ExpenditureActionStatus, string diff --git a/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/types.ts b/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/types.ts index d2de35fe417..bb72fbd561a 100644 --- a/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/types.ts +++ b/src/components/v5/common/ActionSidebar/partials/ExpenditureActionStatusBadge/types.ts @@ -1,19 +1,6 @@ -import { type Expenditure } from '~types/graphql.ts'; - -export enum ExpenditureActionStatus { - Review = 'Review', - Funding = 'Funding', - Release = 'Release', - Changes = 'Changes', - Cancel = 'Cancel', - Canceled = 'Canceled', - Payable = 'Payable', - Passed = 'Passed', - Edit = 'Edit', -} +import { type ExpenditureActionStatus } from '~types/expenditures.ts'; export interface ExpenditureActionStatusBadgeProps { - expenditure: Expenditure; + status: ExpenditureActionStatus; className?: string; - withAdditionalStatuses?: boolean; } diff --git a/src/components/v5/common/ActionSidebar/partials/PaymentGroup/GroupList.ts b/src/components/v5/common/ActionSidebar/partials/PaymentGroup/GroupList.ts index 6548b7f078e..1b73ce1b79a 100644 --- a/src/components/v5/common/ActionSidebar/partials/PaymentGroup/GroupList.ts +++ b/src/components/v5/common/ActionSidebar/partials/PaymentGroup/GroupList.ts @@ -7,6 +7,7 @@ import { ArrowsOutLineHorizontal, // @TODO: uncomment when staged payment is ready Steps, + Waves, type Icon, } from '@phosphor-icons/react'; @@ -40,16 +41,15 @@ export const GROUP_LIST: GroupListItem[] = [ action: Action.PaymentBuilder, isNew: true, }, - // @TODO: uncomment when streaming payment is ready - // { - // title: formatText({ id: 'actions.streamingPayment' }), - // description: formatText({ - // id: 'actions.description.streamingPayment', - // }), - // Icon: Waves, - // action: Action.StreamingPayment, - // isNew: true, - // }, + { + title: formatText({ id: 'actions.streamingPayment' }), + description: formatText({ + id: 'actions.description.streamingPayment', + }), + Icon: Waves, + action: Action.StreamingPayment, + isNew: true, + }, { title: formatText({ id: 'actions.splitPayment' }), description: formatText({ id: 'actions.description.splitPayment' }), diff --git a/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/StreamingPaymentStatusPill.tsx b/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/StreamingPaymentStatusPill.tsx new file mode 100644 index 00000000000..9bd5aac332d --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/StreamingPaymentStatusPill.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx'; +import React, { type FC } from 'react'; + +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import PillsBase from '~v5/common/Pills/PillsBase.tsx'; + +import { type StreamingPaymentStatusPillProps } from './types.ts'; +import { getStatusLabel } from './utils.ts'; + +const StreamingPaymentStatusPill: FC = ({ + status, +}) => ( + + {getStatusLabel(status)} + +); + +export default StreamingPaymentStatusPill; diff --git a/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/types.ts b/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/types.ts new file mode 100644 index 00000000000..375c10a8e62 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/types.ts @@ -0,0 +1,5 @@ +import { type StreamingPaymentStatus } from '~types/streamingPayments.ts'; + +export interface StreamingPaymentStatusPillProps { + status: StreamingPaymentStatus; +} diff --git a/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/utils.ts b/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/utils.ts new file mode 100644 index 00000000000..65140a3bcb5 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/StreamingPaymentStatusPill/utils.ts @@ -0,0 +1,19 @@ +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { formatText } from '~utils/intl.ts'; + +export const getStatusLabel = (paymentStatus: StreamingPaymentStatus) => { + switch (paymentStatus) { + case StreamingPaymentStatus.Active: + return formatText({ id: 'streamingPayment.status.active' }); + case StreamingPaymentStatus.NotStarted: + return formatText({ id: 'streamingPayment.status.notStarted' }); + case StreamingPaymentStatus.Ended: + return formatText({ id: 'streamingPayment.status.ended' }); + case StreamingPaymentStatus.Cancelled: + return formatText({ id: 'streamingPayment.status.cancelled' }); + case StreamingPaymentStatus.LimitReached: + return formatText({ id: 'streamingPayment.status.limitReached' }); + default: + return ''; + } +}; diff --git a/src/components/v5/common/ActionSidebar/partials/TimeRow/TimeRow.tsx b/src/components/v5/common/ActionSidebar/partials/TimeRow/TimeRow.tsx new file mode 100644 index 00000000000..b51a6b98f66 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/TimeRow/TimeRow.tsx @@ -0,0 +1,89 @@ +import { + CalendarCheck, + CalendarPlus, + WarningCircle, +} from '@phosphor-icons/react'; +import clsx from 'clsx'; +import isDate from 'date-fns/isDate'; +import isPast from 'date-fns/isPast'; +import React from 'react'; +import { useWatch } from 'react-hook-form'; + +import { StreamingPaymentEndCondition } from '~gql'; +import Tooltip from '~shared/Extensions/Tooltip/Tooltip.tsx'; +import { formatText } from '~utils/intl.ts'; +import ActionFormRow from '~v5/common/ActionFormRow/index.ts'; +import useHasNoDecisionMethods from '~v5/common/ActionSidebar/hooks/permissions/useHasNoDecisionMethods.ts'; + +import { END_OPTIONS, START_OPTIONS } from './consts.ts'; +import { CUSTOM_DATE_VALUE } from './partials/TimeRowField/consts.ts'; +import TimeRowField from './partials/TimeRowField/TimeRowField.tsx'; +import { type TimeRowProps } from './types.ts'; + +const displayName = 'v5.common.ActionSidebar.partials.TimeRow'; + +const TimeRow = ({ + title, + tooltips, + type = 'start', + name, + minDate, +}: TimeRowProps) => { + const hasNoDecisionMethods = useHasNoDecisionMethods(); + const selectedDate = useWatch({ name }); + + const isDateInPast = + type === 'start' && isDate(selectedDate) && isPast(selectedDate); + + return ( + +
    + +
    + {isDateInPast && ( + + + + )} +
    + ); +}; + +TimeRow.displayName = displayName; + +export default TimeRow; diff --git a/src/components/v5/common/ActionSidebar/partials/TimeRow/consts.ts b/src/components/v5/common/ActionSidebar/partials/TimeRow/consts.ts new file mode 100644 index 00000000000..16feda47423 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/TimeRow/consts.ts @@ -0,0 +1,55 @@ +import { StreamingPaymentEndCondition } from '~gql'; +import { formatText } from '~utils/intl.ts'; +import { type CardSelectOptionsGroup } from '~v5/common/Fields/CardSelect/types.ts'; + +import { CUSTOM_DATE_VALUE } from './partials/TimeRowField/consts.ts'; + +export const START_IMMEDIATELY_VALUE = 'start_immediately'; + +export const START_OPTIONS: CardSelectOptionsGroup[] = [ + { + key: '1', + title: formatText({ id: 'actionSidebar.starts.options.title' }), + options: [ + { + value: START_IMMEDIATELY_VALUE, + label: formatText({ + id: 'actionSidebar.starts.options.startImidiately', + }), + }, + { + value: CUSTOM_DATE_VALUE, + label: formatText({ + id: 'actionSidebar.starts.options.customDateAndTime', + }), + }, + ], + }, +]; + +export const END_OPTIONS: CardSelectOptionsGroup[] = [ + { + key: '1', + title: formatText({ id: 'actionSidebar.ends.options.title' }), + options: [ + { + value: StreamingPaymentEndCondition.WhenCancelled, + label: formatText({ + id: 'actionSidebar.ends.options.whenCancelled', + }), + }, + { + value: StreamingPaymentEndCondition.LimitReached, + label: formatText({ + id: 'actionSidebar.ends.options.limitReached', + }), + }, + { + value: StreamingPaymentEndCondition.FixedTime, + label: formatText({ + id: 'actionSidebar.ends.options.fixedTime', + }), + }, + ], + }, +]; diff --git a/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/TimeRowField.tsx b/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/TimeRowField.tsx new file mode 100644 index 00000000000..9aee4a83293 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/TimeRowField.tsx @@ -0,0 +1,135 @@ +import clsx from 'clsx'; +import { addYears, subYears } from 'date-fns'; +import format from 'date-fns/format'; +import isDate from 'date-fns/isDate'; +import React, { useState, type FC } from 'react'; +import { useController } from 'react-hook-form'; + +import { useMobile } from '~hooks'; +import CardSelect from '~v5/common/Fields/CardSelect/CardSelect.tsx'; +import { FieldState } from '~v5/common/Fields/consts.ts'; +import { DEFAULT_DATE_TIME_FORMAT } from '~v5/common/Fields/datepickers/common/consts.ts'; +import DatepickerWithTime from '~v5/common/Fields/datepickers/DatepickerWithTime/DatepickerWithTime.tsx'; + +import { type TimeRowFieldProps } from './types.ts'; + +const displayName = + 'v5.common.ActionSidebar.partials.TimeRow.partials.TimeRowField'; + +const TimeRowField: FC = ({ + name, + options, + placeholder: placeholderProp, + selectedValueWrapperClassName, + minDate, + customDateValue, +}) => { + const isMobile = useMobile(); + const [isDatepickerVisible, setIsDatepickerVisible] = useState(false); + const { + field, + fieldState: { error }, + } = useController({ + name, + }); + + const [value, setValue] = useState( + isDate(field.value) ? customDateValue : field.value, + ); + + const year15YearsAgo = subYears(new Date(), 15).getFullYear(); + const year15YearsAhead = addYears(new Date(), 15).getFullYear(); + + return ( + + options={options} + state={error ? FieldState.Error : undefined} + value={value} + placeholder={placeholderProp} + cardClassName={clsx('sm:!w-[20.5rem]', { + '!left-6 right-6': isMobile, + 'pb-0': isDatepickerVisible, + })} + renderSelectedValue={(selectedValue, placeholder) => { + if (selectedValue?.value === customDateValue) { + return ( +
    + {field.value + ? format(field.value, DEFAULT_DATE_TIME_FORMAT) + : 'Custom date and time'} +
    + ); + } + return ( +
    + {selectedValue?.label || placeholder} +
    + ); + }} + renderOptionWrapper={( + { value: itemValue, onClick, className, ...props }, + children, + ) => { + const isCustomDate = itemValue === customDateValue; + + return isCustomDate ? ( +
    + + {isDatepickerVisible && ( +
    + { + setValue(itemValue || ''); + field.onChange(date ?? ''); + }} + inline + onClose={onClick} + minDate={minDate} + minYear={year15YearsAgo} + maxYear={year15YearsAhead} + /> +
    + )} +
    + ) : ( + + ); + }} + /> + ); +}; + +TimeRowField.displayName = displayName; + +export default TimeRowField; diff --git a/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/consts.ts b/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/consts.ts new file mode 100644 index 00000000000..588004ec8b2 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/consts.ts @@ -0,0 +1 @@ +export const CUSTOM_DATE_VALUE = 'custom'; diff --git a/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/types.ts b/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/types.ts new file mode 100644 index 00000000000..f5007f349dd --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/TimeRow/partials/TimeRowField/types.ts @@ -0,0 +1,10 @@ +import { type CardSelectOptionsGroup } from '~v5/common/Fields/CardSelect/types.ts'; + +export interface TimeRowFieldProps { + name: string; + options: CardSelectOptionsGroup[]; + placeholder: string; + selectedValueWrapperClassName?: string; + minDate?: Date; + customDateValue: string; +} diff --git a/src/components/v5/common/ActionSidebar/partials/TimeRow/types.ts b/src/components/v5/common/ActionSidebar/partials/TimeRow/types.ts new file mode 100644 index 00000000000..151f6d8dff8 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/TimeRow/types.ts @@ -0,0 +1,8 @@ +import { type ActionFormRowProps } from '~v5/common/ActionFormRow/types.ts'; + +export interface TimeRowProps extends Pick { + name: string; + title?: React.ReactNode; + type?: 'start' | 'end'; + minDate?: Date; +} diff --git a/src/components/v5/common/ActionSidebar/partials/forms/SimplePaymentForm/SimplePaymentForm.tsx b/src/components/v5/common/ActionSidebar/partials/forms/SimplePaymentForm/SimplePaymentForm.tsx index aaf90b98718..b426fbfa1cb 100644 --- a/src/components/v5/common/ActionSidebar/partials/forms/SimplePaymentForm/SimplePaymentForm.tsx +++ b/src/components/v5/common/ActionSidebar/partials/forms/SimplePaymentForm/SimplePaymentForm.tsx @@ -73,8 +73,6 @@ const SimplePaymentForm: FC = ({ getFormOptions }) => { - {/* This input is needed for default values when changing the action */} - {/* Disabled for now */} {/* */} diff --git a/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/StreamingPaymentForm.tsx b/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/StreamingPaymentForm.tsx new file mode 100644 index 00000000000..e5c2a70d135 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/StreamingPaymentForm.tsx @@ -0,0 +1,128 @@ +import { HandPalm, UserFocus, UsersThree } from '@phosphor-icons/react'; +import isDate from 'date-fns/isDate'; +import React, { type FC } from 'react'; +import { useWatch } from 'react-hook-form'; + +import { StreamingPaymentEndCondition } from '~gql'; +import { formatText } from '~utils/intl.ts'; +import ActionFormRow from '~v5/common/ActionFormRow/ActionFormRow.tsx'; +import useHasNoDecisionMethods from '~v5/common/ActionSidebar/hooks/permissions/useHasNoDecisionMethods.ts'; +import useFilterCreatedInField from '~v5/common/ActionSidebar/hooks/useFilterCreatedInField.ts'; +import AmountField from '~v5/common/ActionSidebar/partials/AmountField/AmountField.tsx'; +import AmountPerPeriodRow from '~v5/common/ActionSidebar/partials/AmountPerPeriodRow/AmountPerPeriodRow.tsx'; +import AmountRow from '~v5/common/ActionSidebar/partials/AmountRow/AmountRow.tsx'; +import CreatedIn from '~v5/common/ActionSidebar/partials/CreatedIn/CreatedIn.tsx'; +import DecisionMethodField from '~v5/common/ActionSidebar/partials/DecisionMethodField/DecisionMethodField.tsx'; +import Description from '~v5/common/ActionSidebar/partials/Description/Description.tsx'; +import TeamsSelect from '~v5/common/ActionSidebar/partials/TeamsSelect/TeamsSelect.tsx'; +import TimeRow from '~v5/common/ActionSidebar/partials/TimeRow/TimeRow.tsx'; +import UserSelect from '~v5/common/ActionSidebar/partials/UserSelect/UserSelect.tsx'; +import { type ActionFormBaseProps } from '~v5/common/ActionSidebar/types.ts'; + +import { useStreamingPayment } from './hooks.ts'; + +const StreamingPaymentForm: FC = ({ getFormOptions }) => { + useStreamingPayment(getFormOptions); + + const hasNoDecisionMethods = useHasNoDecisionMethods(); + const createdInFilterFn = useFilterCreatedInField('from'); + const selectedTeam = useWatch({ name: 'from' }); + const startsCondition = useWatch({ name: 'starts' }); + const endsCondition = useWatch({ name: 'ends' }); + + return ( + <> + + + + + + + + + + + {endsCondition === StreamingPaymentEndCondition.LimitReached && ( + + + + )} + + + + + ); +}; + +export default StreamingPaymentForm; diff --git a/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/hooks.ts b/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/hooks.ts new file mode 100644 index 00000000000..4cf10b86ae5 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/hooks.ts @@ -0,0 +1,186 @@ +import { Id } from '@colony/colony-js'; +import isDate from 'date-fns/isDate'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { type DeepPartial } from 'utility-types'; +import { type InferType, number, object, string, date, lazy, mixed } from 'yup'; + +import { ONE_DAY_IN_SECONDS } from '~constants/time.ts'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { StreamingPaymentEndCondition } from '~gql'; +import useCurrentBlockTime from '~hooks/useCurrentBlockTime.ts'; +import useNetworkInverseFee from '~hooks/useNetworkInverseFee.ts'; +import { ActionTypes } from '~redux/index.ts'; +import { mapPayload, pipe } from '~utils/actions.ts'; +import getLastIndexFromPath from '~utils/getLastIndexFromPath.ts'; +import { formatText } from '~utils/intl.ts'; +import { amountGreaterThanValidation } from '~utils/validation/amountGreaterThanValidation.ts'; +import { amountGreaterThanZeroValidation } from '~utils/validation/amountGreaterThanZeroValidation.ts'; +import { ACTION_BASE_VALIDATION_SCHEMA } from '~v5/common/ActionSidebar/consts.ts'; +import useActionFormBaseHook from '~v5/common/ActionSidebar/hooks/useActionFormBaseHook.ts'; +import { AmountPerInterval } from '~v5/common/ActionSidebar/partials/AmountPerPeriodRow/types.ts'; +import { type ActionFormBaseProps } from '~v5/common/ActionSidebar/types.ts'; + +import { getStreamingPaymentPayload } from './utils.ts'; + +export const useValidationSchema = (networkInverseFee: string | undefined) => { + const { colony } = useColonyContext(); + const fromDomainId: number | undefined = useWatch({ name: 'from' }); + const amount: string | undefined = useWatch({ name: 'amount' }); + + const validationSchema = useMemo( + () => + object() + .shape({ + amount: string() + .required(() => formatText({ id: 'errors.amount' })) + .test( + 'more-than-zero', + ({ path }) => { + const index = getLastIndexFromPath(path); + if (index === undefined) { + return formatText({ + id: 'errors.amount.greaterThanZero', + }); + } + return formatText( + { id: 'errors.amount.greaterThanZeroIn' }, + { paymentIndex: index + 1 }, + ); + }, + (value, context) => + amountGreaterThanZeroValidation({ value, context, colony }), + ), + tokenAddress: string().address().required(), + limitTokenAddress: string().address(), + limit: string().when('ends', { + is: StreamingPaymentEndCondition.LimitReached, + then: string() + .required(() => formatText({ id: 'errors.amount' })) + .test( + 'more-than-amount', + () => { + return formatText( + { id: 'errors.amount.greaterThan' }, + { amount: amount || '0' }, + ); + }, + (value, context) => + amountGreaterThanValidation({ + value, + context, + colony, + greaterThanValue: context.parent.amount || '0', + }), + ), + }), + createdIn: number().defined(), + recipient: string().address().required(), + from: number().required(), + starts: lazy((value) => + isDate(value) ? date().required() : string().required(), + ), + ends: lazy((value) => + isDate(value) + ? date().required() + : mixed() + .oneOf(Object.values(StreamingPaymentEndCondition)) + .required(), + ), + decisionMethod: string().defined(), + period: object() + .shape({ + interval: mixed() + .oneOf(Object.values(AmountPerInterval)) + .required(() => + formatText({ id: 'errors.amountPer.required' }), + ), + custom: number().when('interval', { + is: AmountPerInterval.Custom, + then: (schema) => + schema + .min( + ONE_DAY_IN_SECONDS, + formatText({ id: 'errors.amountPer.min' }), + ) + .max( + ONE_DAY_IN_SECONDS * 99999, + formatText( + { id: 'errors.amountPer.max' }, + { max: '99999 days' }, + ), + ) + .required(), + }), + }) + .required(), + }) + .defined() + .concat(ACTION_BASE_VALIDATION_SCHEMA), + [amount, colony, fromDomainId, networkInverseFee], + ); + + return validationSchema; +}; + +export type StreamingPaymentFormValues = InferType< + ReturnType +>; + +export const useStreamingPayment = ( + getFormOptions: ActionFormBaseProps['getFormOptions'], +) => { + const { networkInverseFee } = useNetworkInverseFee(); + const { colony } = useColonyContext(); + const validationSchema = useValidationSchema(networkInverseFee); + const tokenAddress = useWatch({ name: 'tokenAddress' }); + const limitTokenAddress = useWatch({ name: 'limitTokenAddress' }); + const endCondition = useWatch({ name: 'ends' }); + const { setValue, resetField, trigger } = useFormContext(); + const { currentBlockTime: blockTime } = useCurrentBlockTime(); + + useEffect(() => { + if ( + tokenAddress && + tokenAddress !== limitTokenAddress && + endCondition === StreamingPaymentEndCondition.LimitReached + ) { + setValue('limitTokenAddress', tokenAddress); + } + }, [endCondition, limitTokenAddress, setValue, tokenAddress]); + + useEffect(() => { + if ( + limitTokenAddress && + endCondition !== StreamingPaymentEndCondition.LimitReached + ) { + trigger('limit'); + resetField('limit'); + } + }, [endCondition, limitTokenAddress, resetField, trigger]); + + useActionFormBaseHook({ + validationSchema, + defaultValues: useMemo>( + () => ({ + createdIn: Id.RootDomain, + tokenAddress: colony.nativeToken.tokenAddress, + period: { + custom: ONE_DAY_IN_SECONDS * 30, + }, + }), + [colony.nativeToken.tokenAddress], + ), + actionType: ActionTypes.STREAMING_PAYMENT_CREATE, + getFormOptions, + // eslint-disable-next-line react-hooks/exhaustive-deps + transform: useCallback( + pipe( + mapPayload((values: StreamingPaymentFormValues) => { + return getStreamingPaymentPayload(colony, values, blockTime); + }), + ), + [colony, networkInverseFee, blockTime], + ), + }); +}; diff --git a/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/utils.ts b/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/utils.ts new file mode 100644 index 00000000000..62d58cb6470 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/forms/StreamingPaymentForm/utils.ts @@ -0,0 +1,95 @@ +import isDate from 'date-fns/isDate'; +import { BigNumber } from 'ethers'; + +import { ONE_DAY_IN_SECONDS, ONE_HOUR_IN_SECONDS } from '~constants/time.ts'; +import { StreamingPaymentEndCondition } from '~gql'; +import { type CreateStreamingPaymentPayload } from '~redux/sagas/expenditures/createStreamingPayment.ts'; +import { DecisionMethod } from '~types/actions.ts'; +import { type Colony } from '~types/graphql.ts'; +import { findDomainByNativeId } from '~utils/domains.ts'; +import { + getSelectedToken, + getTokenDecimalsWithFallback, +} from '~utils/tokens.ts'; +import { AmountPerInterval } from '~v5/common/ActionSidebar/partials/AmountPerPeriodRow/types.ts'; +import { START_IMMEDIATELY_VALUE } from '~v5/common/ActionSidebar/partials/TimeRow/consts.ts'; + +import { type StreamingPaymentFormValues } from './hooks.ts'; + +export const getInterval = (period: StreamingPaymentFormValues['period']) => { + switch (period.interval) { + case AmountPerInterval.Hour: + return ONE_HOUR_IN_SECONDS; + case AmountPerInterval.Day: + return ONE_DAY_IN_SECONDS; + case AmountPerInterval.Week: + return ONE_DAY_IN_SECONDS * 7; + case AmountPerInterval.Custom: + return period.custom; + default: + return 0; + } +}; + +export const getStreamingPaymentPayload = ( + colony: Colony, + values: StreamingPaymentFormValues, + blockTime: number | null, +): CreateStreamingPaymentPayload | null => { + const { + amount, + tokenAddress, + description: annotationMessage, + createdIn, + recipient, + period, + ends, + starts, + limit, + from, + decisionMethod, + } = values; + + const selectedToken = getSelectedToken(colony, tokenAddress); + const decimals = getTokenDecimalsWithFallback(selectedToken?.decimals); + const createdInDomain = findDomainByNativeId(createdIn, colony); + const fromDomain = findDomainByNativeId(from, colony); + + if (!createdInDomain || !fromDomain) { + return null; + } + + const interval = getInterval(period); + + if (!interval) { + return null; + } + + return { + colonyAddress: colony.colonyAddress, + createdInDomain: + decisionMethod === DecisionMethod.Reputation + ? createdInDomain + : fromDomain, + amount, + endCondition: isDate(ends) ? StreamingPaymentEndCondition.FixedTime : ends, + interval, + recipientAddress: recipient ?? '', + startTimestamp: + starts === START_IMMEDIATELY_VALUE + ? BigNumber.from( + Math.floor(blockTime ?? new Date().getTime() / 1000), + ).toString() + : BigNumber.from( + Math.floor(new Date(starts).getTime() / 1000), + ).toString(), + tokenAddress, + tokenDecimals: decimals, + endTimestamp: isDate(ends) + ? BigNumber.from(Math.floor(new Date(ends).getTime() / 1000)).toString() + : undefined, + limitAmount: + ends === StreamingPaymentEndCondition.LimitReached ? limit : undefined, + annotationMessage, + }; +}; diff --git a/src/components/v5/common/ActionSidebar/partials/hooks.ts b/src/components/v5/common/ActionSidebar/partials/hooks.ts index 997c8d8bf09..f2aca18154b 100644 --- a/src/components/v5/common/ActionSidebar/partials/hooks.ts +++ b/src/components/v5/common/ActionSidebar/partials/hooks.ts @@ -18,6 +18,8 @@ import { type FinalizeSuccessCallback, } from '../types.ts'; +import { getNeededExtension } from './utils.ts'; + const SUBMIT_BUTTON_TEXT_MAP: Partial> = { [Action.PaymentBuilder]: 'button.createPayment', [Action.SimplePayment]: 'button.createPayment', diff --git a/src/components/v5/common/ActionSidebar/partials/utils.ts b/src/components/v5/common/ActionSidebar/partials/utils.ts new file mode 100644 index 00000000000..401fedd6813 --- /dev/null +++ b/src/components/v5/common/ActionSidebar/partials/utils.ts @@ -0,0 +1,14 @@ +import { Extension } from '@colony/colony-js'; + +import { Action } from '~constants/actions.ts'; + +export const getNeededExtension = (action: Action) => { + switch (action) { + case Action.CreateDecision: + return Extension.VotingReputation; + case Action.StreamingPayment: + return Extension.StreamingPayments; + default: + return ''; + } +}; diff --git a/src/components/v5/common/CompletedAction/CompletedAction.tsx b/src/components/v5/common/CompletedAction/CompletedAction.tsx index 586c7f22964..a44d4fcfcf5 100644 --- a/src/components/v5/common/CompletedAction/CompletedAction.tsx +++ b/src/components/v5/common/CompletedAction/CompletedAction.tsx @@ -24,6 +24,8 @@ import RemoveVerifiedMembers from './partials/RemoveVerifiedMembers/index.ts'; import SetUserRoles from './partials/SetUserRoles/index.ts'; import SimplePayment from './partials/SimplePayment/index.ts'; import SplitPayment from './partials/SplitPayment/SplitPayment.tsx'; +import StreamingPaymentWidget from './partials/StreamingPayment/partials/StreamingPaymentWidget/StreamingPaymentWidget.tsx'; +import StreamingPayment from './partials/StreamingPayment/StreamingPayment.tsx'; import TransferFunds from './partials/TransferFunds/index.ts'; import UnlockToken from './partials/UnlockToken/index.ts'; import UpgradeColonyObjective from './partials/UpgradeColonyObjective/index.ts'; @@ -116,6 +118,10 @@ const CompletedAction = ({ action }: ICompletedAction) => { return ; case ExtendedColonyActionType.SplitPayment: return ; + case ColonyActionType.CreateStreamingPayment: + return ; + case ColonyActionType.CancelStreamingPayment: + return ; default: console.warn('Unsupported action display', action); return
    Not implemented yet
    ; @@ -165,6 +171,10 @@ const CompletedAction = ({ action }: ICompletedAction) => { case ExtendedColonyActionType.StagedPayment: case ExtendedColonyActionType.SplitPayment: return ; + case ColonyActionType.CreateStreamingPayment: + return ; + case ColonyActionType.CancelStreamingPayment: + return ; default: return ; } diff --git a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/ActionWithPermissionsInfo/ActionWithPermissionsInfo.tsx b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/ActionWithPermissionsInfo/ActionWithPermissionsInfo.tsx index c9bc01e24e8..6b91580a058 100644 --- a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/ActionWithPermissionsInfo/ActionWithPermissionsInfo.tsx +++ b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/ActionWithPermissionsInfo/ActionWithPermissionsInfo.tsx @@ -1,9 +1,7 @@ -import { isToday, isYesterday } from 'date-fns'; import React, { type FC } from 'react'; -import { FormattedDate, defineMessages } from 'react-intl'; import PermissionRow from '~frame/v5/pages/VerifiedPage/partials/PermissionRow/index.ts'; -import { getFormattedDateFrom } from '~utils/getFormattedDateFrom.ts'; +import { formatDate } from '~utils/date.ts'; import { formatText } from '~utils/intl.ts'; import MenuWithStatusText from '~v5/shared/MenuWithStatusText/index.ts'; import { StatusTypes } from '~v5/shared/StatusText/consts.ts'; @@ -13,55 +11,7 @@ import UserPopover from '~v5/shared/UserPopover/UserPopover.tsx'; import { type ActionWithPermissionsInfoProps } from './types.ts'; const displayName = - 'v5.common.CompletedAction.partials.ActionWithPermissionsInfoProps'; - -const MSG = defineMessages({ - todayAt: { - id: `${displayName}.todayAt`, - defaultMessage: 'Today at', - }, - yestardayAt: { - id: `${displayName}.yestardayAt`, - defaultMessage: 'Yesterday at', - }, - at: { - id: `${displayName}.at`, - defaultMessage: 'at', - }, -}); - -const formatDate = (value: string | undefined) => { - if (!value) { - return undefined; - } - - const date = new Date(value); - - if (isToday(date)) { - return ( - <> - {formatText(MSG.todayAt)}{' '} - - - ); - } - - if (isYesterday(date)) { - return ( - <> - {formatText(MSG.yestardayAt)}{' '} - - - ); - } - - return ( - <> - {getFormattedDateFrom(value)} {formatText(MSG.at)}{' '} - - - ); -}; + 'v5.common.CompletedAction.partials.ActionWithPermissionsInfo'; const ActionWithPermissionsInfo: FC = ({ action, @@ -142,4 +92,5 @@ const ActionWithPermissionsInfo: FC = ({ ); }; +ActionWithPermissionsInfo.displayName = displayName; export default ActionWithPermissionsInfo; diff --git a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/CancelModal.tsx b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/CancelModal.tsx index e093e68ac4b..bcc977908c6 100644 --- a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/CancelModal.tsx +++ b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/CancelModal.tsx @@ -2,6 +2,7 @@ import { Prohibit, SpinnerGap } from '@phosphor-icons/react'; import React, { useState, type FC } from 'react'; import { toast } from 'react-toastify'; +import { getRole } from '~constants/permissions.ts'; import { useAppContext } from '~context/AppContext/AppContext.ts'; import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; import { usePaymentBuilderContext } from '~context/PaymentBuilderContext/PaymentBuilderContext.ts'; @@ -10,6 +11,8 @@ import { ActionTypes } from '~redux'; import { type CancelExpenditurePayload } from '~redux/types/actions/expenditures.ts'; import Toast from '~shared/Extensions/Toast/index.ts'; import { Form } from '~shared/Fields/index.ts'; +import { getHighestTierRoleForUser } from '~transformers'; +import { extractColonyRoles } from '~utils/colonyRoles.ts'; import { formatText } from '~utils/intl.ts'; import IconButton from '~v5/shared/Button/IconButton.tsx'; import Button, { ActionButton } from '~v5/shared/Button/index.ts'; @@ -19,12 +22,9 @@ import Modal from '~v5/shared/Modal/index.ts'; import DecisionMethodSelect from '../DecisionMethodSelect/DecisionMethodSelect.tsx'; import { ExpenditureStep } from '../PaymentBuilderWidget/types.ts'; -import { - cancelDecisionMethodDescriptions, - cancelDecisionMethodItems, - validationSchema, -} from './consts.ts'; +import { cancelDecisionMethodItems, validationSchema } from './consts.ts'; import { type CancelModalProps } from './types.ts'; +import { getCancelDecisionMethodDescriptions } from './utils.ts'; const CancelModal: FC = ({ isOpen, @@ -75,6 +75,8 @@ const CancelModal: FC = ({ expenditure.lockingActions?.items && expenditure.lockingActions.items.length > 0; + const colonyRoles = extractColonyRoles(colony.roles); + return ( = ({ })}

    - {formatText({ - id: isExpenditureLocked - ? 'cancelModal.locked.description' - : 'cancelModal.description', - })} + {formatText( + { + id: isExpenditureLocked + ? 'cancelModal.locked.description' + : 'cancelModal.description', + }, + { role: 'Payer' }, + )}

    {isExpenditureLocked ? (
    = ({ > {({ watch }) => { const method = watch('decisionMethod'); + const highestTierRole = getHighestTierRoleForUser( + colonyRoles, + user?.walletAddress || '', + ); + + const highestTierRoleMeta = highestTierRole + ? getRole(highestTierRole) + : undefined; + + const cancelDecisionMethodDescriptions = + getCancelDecisionMethodDescriptions( + highestTierRoleMeta?.name || formatText({ id: 'role.mod' }), + ); return ( <> diff --git a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/consts.ts b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/consts.ts index dd7b42e6ed8..cbb97b2dc2e 100644 --- a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/consts.ts +++ b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/consts.ts @@ -11,12 +11,6 @@ export const cancelDecisionMethodItems: SelectBaseOption[] = [ }, ]; -export const cancelDecisionMethodDescriptions = { - [DecisionMethod.Permissions]: formatText({ - id: 'cancelModal.permissionsDescription', - }), -}; - export const validationSchema = object() .shape({ decisionMethod: object().shape({ diff --git a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/utils.ts b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/utils.ts new file mode 100644 index 00000000000..55f5790dc43 --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/CancelModal/utils.ts @@ -0,0 +1,11 @@ +import { DecisionMethod } from '~types/actions.ts'; +import { formatText } from '~utils/intl.ts'; + +export const getCancelDecisionMethodDescriptions = (role: string) => ({ + [DecisionMethod.Permissions]: formatText( + { + id: 'cancelModal.permissionsDescription', + }, + { role }, + ), +}); diff --git a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/PaymentBuilderWidget/utils.ts b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/PaymentBuilderWidget/utils.ts index a6918267e66..4bea621e627 100644 --- a/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/PaymentBuilderWidget/utils.ts +++ b/src/components/v5/common/CompletedAction/partials/PaymentBuilder/partials/PaymentBuilderWidget/utils.ts @@ -6,10 +6,6 @@ import { MotionState } from '~utils/colonyMotions.ts'; import { ExpenditureStep } from './types.ts'; -/** - * Returns a boolean indicating whether the expenditure is fully funded, - * i.e. the balance of each token is greater than or equal to the sum of its payouts - */ export const isExpenditureFullyFunded = (expenditure?: Expenditure | null) => { if (!expenditure) { return false; diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/StreamingPayment.tsx b/src/components/v5/common/CompletedAction/partials/StreamingPayment/StreamingPayment.tsx new file mode 100644 index 00000000000..3765947b685 --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/StreamingPayment.tsx @@ -0,0 +1,447 @@ +import { useApolloClient } from '@apollo/client'; +import { ColonyRole } from '@colony/colony-js'; +import { + Calendar, + CalendarCheck, + CalendarPlus, + Copy, + HandPalm, + Prohibit, + Repeat, + UserFocus, +} from '@phosphor-icons/react'; +import clsx from 'clsx'; +import format from 'date-fns/format'; +import { BigNumber } from 'ethers'; +import React, { useState, type FC, useEffect } from 'react'; +import { defineMessages } from 'react-intl'; +import { generatePath } from 'react-router-dom'; + +import MeatballMenuCopyItem from '~common/ColonyActionsTable/partials/MeatballMenuCopyItem/MeatballMenuCopyItem.tsx'; +import { ADDRESS_ZERO, APP_URL } from '~constants'; +import { Action } from '~constants/actions.ts'; +import { ONE_DAY_IN_SECONDS, ONE_HOUR_IN_SECONDS } from '~constants/time.ts'; +import { useActionSidebarContext } from '~context/ActionSidebarContext/ActionSidebarContext.ts'; +import { useActionStatusContext } from '~context/ActionStatusContext/ActionStatusContext.ts'; +import { useAppContext } from '~context/AppContext/AppContext.ts'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { + ColonyActionType, + SearchStreamingPaymentsDocument, + StreamingPaymentEndCondition, + useGetUserByAddressQuery, +} from '~gql'; +import { useMobile } from '~hooks'; +import useToggle from '~hooks/useToggle/index.ts'; +import { + COLONY_ACTIVITY_ROUTE, + COLONY_HOME_ROUTE, + TX_SEARCH_PARAM, +} from '~routes'; +import SpinnerLoader from '~shared/Preloaders/SpinnerLoader.tsx'; +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { addressHasRoles } from '~utils/checks/userHasRoles.ts'; +import { findDomainByNativeId } from '~utils/domains.ts'; +import { formatText } from '~utils/intl.ts'; +import { isQueryActive } from '~utils/isQueryActive.ts'; +import { + getAmountPerValue, + getStreamingPaymentLimit, +} from '~utils/streamingPayments.ts'; +import { getNumeralTokenAmount, getSelectedToken } from '~utils/tokens.ts'; +import { + ACTION_TYPE_FIELD_NAME, + AMOUNT_FIELD_NAME, + CREATED_IN_FIELD_NAME, + DECISION_METHOD_FIELD_NAME, + DESCRIPTION_FIELD_NAME, + FROM_FIELD_NAME, + RECIPIENT_FIELD_NAME, + TITLE_FIELD_NAME, + TOKEN_FIELD_NAME, +} from '~v5/common/ActionSidebar/consts.ts'; +import { useGetStreamingPaymentData } from '~v5/common/ActionSidebar/hooks/useGetStreamingPaymentData.ts'; +import { + END_OPTIONS, + START_IMMEDIATELY_VALUE, +} from '~v5/common/ActionSidebar/partials/TimeRow/consts.ts'; +import { useDecisionMethod } from '~v5/common/CompletedAction/hooks.ts'; +import { DEFAULT_DATE_TIME_FORMAT } from '~v5/common/Fields/datepickers/common/consts.ts'; +import MeatBallMenu from '~v5/shared/MeatBallMenu/index.ts'; +import { type MeatBallMenuItem } from '~v5/shared/MeatBallMenu/types.ts'; +import UserInfoPopover from '~v5/shared/UserInfoPopover/UserInfoPopover.tsx'; +import UserPopover from '~v5/shared/UserPopover/UserPopover.tsx'; + +import { + ActionDataGrid, + ActionSubtitle, + ActionTitle, +} from '../Blocks/Blocks.tsx'; +import ActionData from '../rows/ActionData.tsx'; +import ActionTypeRow from '../rows/ActionType.tsx'; +import AmountRow from '../rows/Amount.tsx'; +import CreatedInRow from '../rows/CreatedIn.tsx'; +import DecisionMethodRow from '../rows/DecisionMethod.tsx'; +import DescriptionRow from '../rows/Description.tsx'; +import TeamFromRow from '../rows/TeamFrom.tsx'; + +import CancelModal from './partials/CancelModal/CancelModal.tsx'; +import { type StreamingPaymentProps } from './types.ts'; + +const displayName = 'v5.common.CompletedAction.partials.StreamingPayment'; + +const MSG = defineMessages({ + defaultTitle: { + id: `${displayName}.defaultTitle`, + defaultMessage: 'Streaming payment', + }, +}); + +const StreamingPayment: FC = ({ action }) => { + const { colony } = useColonyContext(); + const { user } = useAppContext(); + const isMobile = useMobile(); + const { + actionSidebarToggle: [ + , + { toggleOn: toggleActionSidebarOn, toggleOff: toggleActionSidebarOff }, + ], + } = useActionSidebarContext(); + const decisionMethod = useDecisionMethod(action); + + const { actionStatus, setIsLoading } = useActionStatusContext(); + const [expectedActionStatus, setExpectedActionStatus] = + useState(null); + + const [ + isCancelModalOpen, + { toggleOn: toggleCancelModalOn, toggleOff: toggleCancelModalOff }, + ] = useToggle(); + + const { loadingStreamingPayment, streamingPaymentData } = + useGetStreamingPaymentData(action?.streamingPaymentId); + + const { data: recipentData } = useGetUserByAddressQuery({ + variables: { address: streamingPaymentData?.recipientAddress || '' }, + skip: !streamingPaymentData?.recipientAddress, + }); + + const recipientName = + recipentData?.getUserByAddress?.items[0]?.profile?.displayName ?? ''; + const client = useApolloClient(); + + useEffect(() => { + if (isQueryActive('SearchStreamingPayments')) { + client.refetchQueries({ + include: [SearchStreamingPaymentsDocument], + }); + } + }, [client]); + + useEffect(() => { + if (expectedActionStatus && expectedActionStatus !== actionStatus) { + setIsLoading(true); + return; + } + + if (expectedActionStatus && expectedActionStatus === actionStatus) { + setIsLoading(false); + setExpectedActionStatus(null); + } + }, [actionStatus, expectedActionStatus, setIsLoading]); + + if (loadingStreamingPayment) { + return ( +
    + +

    + {formatText({ id: 'actionSidebar.loading' })} +

    +
    + ); + } + if (!streamingPaymentData) { + return null; + } + const { + metadata, + initiatorUser, + transactionHash, + isMotion, + motionData, + annotation, + createdAt, + } = action || {}; + const { customTitle = formatText(MSG.defaultTitle) } = metadata || {}; + const { + amount, + tokenAddress, + recipientAddress, + interval, + nativeDomainId, + endTime, + startTime, + metadata: streamingPaymentMetadata, + } = streamingPaymentData; + const selectedToken = getSelectedToken(colony, tokenAddress || ''); + const formattedAmount = getNumeralTokenAmount( + amount || '1', + selectedToken?.decimals, + ); + const { endCondition } = streamingPaymentMetadata || {}; + const selectedTeam = findDomainByNativeId(nativeDomainId, colony); + const motionDomain = motionData?.motionDomain ?? null; + + const limitAmount = getStreamingPaymentLimit({ + streamingPayment: streamingPaymentData, + }); + + const formattedLimitAmount = getNumeralTokenAmount( + limitAmount || '0', + selectedToken?.decimals, + ); + + const hasPermissions = addressHasRoles({ + address: user?.walletAddress || '', + colony, + requiredRoles: [ColonyRole.Arbitration], + requiredRolesDomain: streamingPaymentData.nativeDomainId, + }); + + const showCancelOption = + actionStatus && + [StreamingPaymentStatus.Active, StreamingPaymentStatus.NotStarted].includes( + actionStatus as StreamingPaymentStatus, + ) && + (user?.walletAddress === initiatorUser?.walletAddress || hasPermissions) && + expectedActionStatus !== StreamingPaymentStatus.Cancelled; + + const isCustomInterval = + Number(interval) !== ONE_HOUR_IN_SECONDS && + Number(interval) !== ONE_DAY_IN_SECONDS && + Number(interval) !== ONE_DAY_IN_SECONDS * 7; + const startTimeDate = new Date(Number(startTime) * 1000); + + const isStartImmediately = + Math.abs(startTimeDate.getTime() - new Date(createdAt).getTime()) < 60000; + + // console.log({isStartImmediately, startTimeDate, createdAt}); + + const meatballOptions: MeatBallMenuItem[] = [ + { + key: '1', + label: formatText({ id: 'completedAction.redoAction' }), + icon: Repeat, + onClick: () => { + toggleActionSidebarOff(); + + setTimeout(() => { + toggleActionSidebarOn({ + [TITLE_FIELD_NAME]: customTitle, + [ACTION_TYPE_FIELD_NAME]: Action.StreamingPayment, + [FROM_FIELD_NAME]: streamingPaymentData?.nativeDomainId, + [RECIPIENT_FIELD_NAME]: recipientAddress, + [AMOUNT_FIELD_NAME]: formattedAmount, + [TOKEN_FIELD_NAME]: selectedToken?.tokenAddress, + [DECISION_METHOD_FIELD_NAME]: decisionMethod, + starts: isStartImmediately + ? START_IMMEDIATELY_VALUE + : startTimeDate, + ends: + endCondition === StreamingPaymentEndCondition.FixedTime + ? new Date(Number(endTime) * 1000) + : endCondition, + period: isCustomInterval + ? { + custom: Number(interval), + interval: 'custom', + } + : { + interval: getAmountPerValue(interval).toLowerCase(), + }, + limit: limitAmount ? formattedLimitAmount : undefined, + limitTokenAddress: limitAmount + ? selectedToken?.tokenAddress + : undefined, + [CREATED_IN_FIELD_NAME]: isMotion + ? motionDomain?.nativeId + : streamingPaymentData?.nativeDomainId, + [DESCRIPTION_FIELD_NAME]: annotation?.message, + }); + }, 500); + }, + }, + ...(showCancelOption + ? [ + { + key: '2', + label: formatText({ id: 'expenditure.cancelPayment' }), + icon: Prohibit, + onClick: toggleCancelModalOn, + }, + ] + : []), + { + key: '3', + label: formatText({ id: 'expenditure.copyLink' }), + renderItemWrapper: (itemWrapperProps, children) => ( + + {children} + + ), + icon: Copy, + }, + ]; + + return ( + <> +
    + {customTitle} + +
    + + {formatText( + { id: 'action.title' }, + { + actionType: ColonyActionType.CreateStreamingPayment, + amount: formattedAmount, + tokenSymbol: selectedToken?.symbol, + period: getAmountPerValue(interval).toLowerCase(), + recipient: recipientAddress ? ( + + {recipientName} + + ) : null, + initiator: initiatorUser ? ( + + {initiatorUser.profile?.displayName} + + ) : null, + }, + )} + + + + {selectedTeam?.metadata && ( + + )} + + } + RowIcon={UserFocus} + tooltipContent={formatText({ + id: 'actionSidebar.tooltip.simplePayment.recipient', + })} + /> + + {format( + new Date(BigNumber.from(Number(startTime) * 1000).toNumber()), + DEFAULT_DATE_TIME_FORMAT, + )} +

    + } + RowIcon={CalendarPlus} + tooltipContent={formatText({ + id: 'actionSidebar.tooltip.streamingPayment.starts', + })} + /> + + {format( + new Date(BigNumber.from(Number(endTime) * 1000).toNumber()), + DEFAULT_DATE_TIME_FORMAT, + )} +

    + ) : ( +

    + { + END_OPTIONS[0].options.find( + ({ value }) => value === endCondition, + )?.label + } +

    + ) + } + RowIcon={CalendarCheck} + tooltipContent={formatText({ + id: 'actionSidebar.tooltip.streamingPayment.ends', + })} + /> + + {getAmountPerValue(interval)}

    } + RowIcon={Calendar} + tooltipContent={formatText({ + id: 'actionSidebar.tooltip.streamingPayment.amount.per', + })} + /> + {endCondition === StreamingPaymentEndCondition.LimitReached && + limitAmount && ( + + )} + + {action.motionData?.motionDomain.metadata && ( + + )} +
    + {action.annotation?.message && ( + + )} + + setExpectedActionStatus(StreamingPaymentStatus.Cancelled) + } + /> + + ); +}; + +StreamingPayment.displayName = displayName; + +export default StreamingPayment; diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/AvailableToClaimCounter/AvailableToClaimCounter.tsx b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/AvailableToClaimCounter/AvailableToClaimCounter.tsx new file mode 100644 index 00000000000..a531347002c --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/AvailableToClaimCounter/AvailableToClaimCounter.tsx @@ -0,0 +1,128 @@ +import { WarningCircle } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import Decimal from 'decimal.js'; +import { AnimatePresence, motion } from 'framer-motion'; +import React, { useEffect, type FC, useState } from 'react'; + +import Numeral from '~shared/Numeral/Numeral.tsx'; +import { StreamingPaymentStatus } from '~types/streamingPayments.ts'; + +import { type AvailableToClaimCounterProps } from './types.ts'; + +const displayName = + 'v5.common.CompletedAction.partials.StreamingPayment.partials.AvailableToClaimCounter'; + +const AvailableToClaimCounter: FC = ({ + hasEnoughFunds, + status, + amountAvailableToClaim, + decimals, + tokenSymbol, + getAmounts, + ratePerSecond, + currentTime: currentTimeProp, +}) => { + const [currentTime, setCurrentTime] = useState(-1); + + useEffect(() => { + const timer = setInterval(() => { + if ( + [ + StreamingPaymentStatus.Active, + StreamingPaymentStatus.NotStarted, + ].includes(status) + ) { + getAmounts(currentTime); + setCurrentTime((oldTime) => oldTime + 1); + } + }, 1000); + + if ( + ![ + StreamingPaymentStatus.Active, + StreamingPaymentStatus.NotStarted, + ].includes(status) + ) { + clearInterval(timer); + } + + return () => { + clearInterval(timer); + }; + }, [currentTime, getAmounts, status]); + + useEffect(() => { + setCurrentTime(currentTimeProp); + }, [currentTimeProp]); + + const formattedRate = new Decimal(ratePerSecond) + .div(10 ** decimals) + .toString(); + + const decimalPlaces = formattedRate.toString().split('.')[1]?.length || 0; + const fixedDecimalPlaces = decimalPlaces < 5 ? decimalPlaces : 5; + + const formattedNumber = new Decimal(amountAvailableToClaim) + .div(10 ** decimals) + .toFixed(fixedDecimalPlaces) + .toString(); + + const digits = formattedNumber.split('').map((char, index) => ({ + char, + key: `${char}-${index}`, + isStatic: char === ',' || char === '.', // Handle commas as static + })); + + return status === StreamingPaymentStatus.Active ? ( + + +
    + {digits.map(({ char, key, isStatic }) => ( +
    + {isStatic ? ( +
    + {char} +
    + ) : ( + + + {char} + + + )} +
    + ))} +
    + {tokenSymbol} +
    + {!hasEnoughFunds && } +
    + ) : ( + + ); +}; + +AvailableToClaimCounter.displayName = displayName; +export default AvailableToClaimCounter; diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/AvailableToClaimCounter/types.ts b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/AvailableToClaimCounter/types.ts new file mode 100644 index 00000000000..a1ae7945c4e --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/AvailableToClaimCounter/types.ts @@ -0,0 +1,12 @@ +import { type StreamingPaymentStatus } from '~types/streamingPayments.ts'; + +export interface AvailableToClaimCounterProps { + hasEnoughFunds: boolean; + status: StreamingPaymentStatus; + amountAvailableToClaim: string; + decimals: number; + tokenSymbol: string; + ratePerSecond: string; + getAmounts: (currentTime: number) => void; + currentTime: number; +} diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/CancelModal.tsx b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/CancelModal.tsx new file mode 100644 index 00000000000..3f976b227fd --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/CancelModal.tsx @@ -0,0 +1,142 @@ +import { Prohibit } from '@phosphor-icons/react'; +import React, { type FC } from 'react'; + +import { getRole } from '~constants/permissions.ts'; +import { useAppContext } from '~context/AppContext/AppContext.ts'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { ButtonWithLoader } from '~frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsHeader/ButtonWithLoader.tsx'; +import useAsyncFunction from '~hooks/useAsyncFunction.ts'; +import { ActionTypes } from '~redux'; +import { type CancelStreamingPaymentPayload } from '~redux/types/actions/expenditures.ts'; +import { Form } from '~shared/Fields/index.ts'; +import { getHighestTierRoleForUser } from '~transformers'; +import { extractColonyRoles } from '~utils/colonyRoles.ts'; +import { formatText } from '~utils/intl.ts'; +import DecisionMethodSelect from '~v5/common/CompletedAction/partials/PaymentBuilder/partials/DecisionMethodSelect/DecisionMethodSelect.tsx'; +import Button from '~v5/shared/Button/index.ts'; +import Modal from '~v5/shared/Modal/index.ts'; + +import { cancelDecisionMethodItems, validationSchema } from './consts.ts'; +import { type CancelModalProps } from './types.ts'; +import { getCancelDecisionMethodDescriptions } from './utils.ts'; + +const CancelModal: FC = ({ + isOpen, + onClose, + streamingPayment, + onSuccess, + ...rest +}) => { + const { user } = useAppContext(); + const { colony } = useColonyContext(); + + const cancel = useAsyncFunction({ + submit: ActionTypes.STREAMING_PAYMENT_CANCEL, + error: ActionTypes.STREAMING_PAYMENT_CANCEL_ERROR, + success: ActionTypes.STREAMING_PAYMENT_CANCEL_SUCCESS, + }); + + const handleCancelStreamingPayment = async ({ + shouldWaive, + }: Pick) => { + try { + if (!streamingPayment) { + return; + } + + const payload: CancelStreamingPaymentPayload = { + colonyAddress: colony.colonyAddress, + streamingPayment, + userAddress: user?.walletAddress ?? '', + shouldWaive, + }; + + await cancel(payload); + onSuccess(); + onClose(); + } catch (err) { + onClose(); + } + }; + + const colonyRoles = extractColonyRoles(colony.roles); + + return ( + +
    + {formatText({ + id: 'cancelModal.locked.title', + })} +
    +

    + {formatText( + { + id: 'cancelModal.locked.description', + }, + { + role: formatText({ id: 'role.mod' }), + }, + )} +

    + handleCancelStreamingPayment({ shouldWaive: false })} + validationSchema={validationSchema} + defaultValues={{ decisionMethod: {} }} + > + {({ watch, formState: { isSubmitting } }) => { + const method = watch('decisionMethod'); + const highestTierRole = getHighestTierRoleForUser( + colonyRoles, + user?.walletAddress || '', + ); + + const highestTierRoleMeta = highestTierRole + ? getRole(highestTierRole) + : undefined; + + const cancelDecisionMethodDescriptions = + getCancelDecisionMethodDescriptions( + highestTierRoleMeta?.name || formatText({ id: 'role.mod' }), + ); + + return ( + <> +
    + + {method && method.value && ( +
    +

    + + {formatText({ id: 'cancelModal.note' })} + + {cancelDecisionMethodDescriptions[method.value]} +

    +
    + )} +
    +
    + + + {formatText({ id: 'cancelModal.confirmCancellation' })} + +
    + + ); + }} + +
    + ); +}; + +export default CancelModal; diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/consts.ts b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/consts.ts new file mode 100644 index 00000000000..cbb97b2dc2e --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/consts.ts @@ -0,0 +1,20 @@ +import { object, string } from 'yup'; + +import { DecisionMethod } from '~types/actions.ts'; +import { formatText } from '~utils/intl.ts'; +import { type SelectBaseOption } from '~v5/common/Fields/Select/types.ts'; + +export const cancelDecisionMethodItems: SelectBaseOption[] = [ + { + label: formatText({ id: 'decisionMethod.permissions' }), + value: DecisionMethod.Permissions, + }, +]; + +export const validationSchema = object() + .shape({ + decisionMethod: object().shape({ + value: string().required(), + }), + }) + .defined(); diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/types.ts b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/types.ts new file mode 100644 index 00000000000..b31667d8f85 --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/types.ts @@ -0,0 +1,7 @@ +import { type StreamingPayment } from '~types/graphql.ts'; +import { type ModalProps } from '~v5/shared/Modal/types.ts'; + +export interface CancelModalProps extends ModalProps { + streamingPayment: StreamingPayment; + onSuccess: () => void; +} diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/utils.ts b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/utils.ts new file mode 100644 index 00000000000..55f5790dc43 --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CancelModal/utils.ts @@ -0,0 +1,11 @@ +import { DecisionMethod } from '~types/actions.ts'; +import { formatText } from '~utils/intl.ts'; + +export const getCancelDecisionMethodDescriptions = (role: string) => ({ + [DecisionMethod.Permissions]: formatText( + { + id: 'cancelModal.permissionsDescription', + }, + { role }, + ), +}); diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CreatedWithPermissionsInfo/CreatedWithPermissionsInfo.tsx b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CreatedWithPermissionsInfo/CreatedWithPermissionsInfo.tsx new file mode 100644 index 00000000000..cccf2cf2d24 --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CreatedWithPermissionsInfo/CreatedWithPermissionsInfo.tsx @@ -0,0 +1,91 @@ +import React, { type FC } from 'react'; + +import PermissionRow from '~frame/v5/pages/VerifiedPage/partials/PermissionRow/index.ts'; +import { formatDate } from '~utils/date.ts'; +import { formatText } from '~utils/intl.ts'; +import MenuWithStatusText from '~v5/shared/MenuWithStatusText/index.ts'; +import { StatusTypes } from '~v5/shared/StatusText/consts.ts'; +import StatusText from '~v5/shared/StatusText/StatusText.tsx'; +import UserPopover from '~v5/shared/UserPopover/UserPopover.tsx'; + +import { type CreatedWithPermissionsInfoProps } from './types.ts'; + +const displayName = + 'v5.common.CompletedAction.partials.CreatedWithPermissionsInfo'; + +const CreatedWithPermissionsInfo: FC = ({ + userAdddress, + createdAt, +}) => { + const formattedDate = formatDate(createdAt); + + return ( + + {formatText({ + id: 'action.executed.permissions.description', + })} + + } + sections={[ + { + key: '1', + content: ( + <> +

    + {formatText({ + id: 'action.executed.permissions.overview', + })} +

    + {userAdddress && ( + <> +
    + + {formatText({ + id: 'action.executed.permissions.member', + })} + +
    + +
    +
    +
    + + {formatText({ + id: 'action.executed.permissions.permission', + })} + + +
    + + )} + {createdAt && ( +
    + + {formatText({ + id: 'action.executed.permissions.date', + })} + + {formattedDate} +
    + )} + + ), + }, + ]} + /> + ); +}; + +CreatedWithPermissionsInfo.displayName = displayName; +export default CreatedWithPermissionsInfo; diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CreatedWithPermissionsInfo/types.ts b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CreatedWithPermissionsInfo/types.ts new file mode 100644 index 00000000000..c6ec613cc9b --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/CreatedWithPermissionsInfo/types.ts @@ -0,0 +1,4 @@ +export interface CreatedWithPermissionsInfoProps { + userAdddress: string | undefined | null; + createdAt?: string; +} diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/StreamingPaymentWidget.tsx b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/StreamingPaymentWidget.tsx new file mode 100644 index 00000000000..9dea2fb840c --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/StreamingPaymentWidget.tsx @@ -0,0 +1,342 @@ +import { Id } from '@colony/colony-js'; +import clsx from 'clsx'; +import { BigNumber } from 'ethers'; +import React, { type FC, useState, useEffect, useMemo } from 'react'; +import { defineMessages } from 'react-intl'; + +import LoadingSkeleton from '~common/LoadingSkeleton/LoadingSkeleton.tsx'; +import { Action } from '~constants/actions.ts'; +import { useActionSidebarContext } from '~context/ActionSidebarContext/ActionSidebarContext.ts'; +import { useActionStatusContext } from '~context/ActionStatusContext/ActionStatusContext.ts'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { ButtonWithLoader } from '~frame/Extensions/pages/ExtensionDetailsPage/partials/ExtensionDetailsHeader/ButtonWithLoader.tsx'; +import useAsyncFunction from '~hooks/useAsyncFunction.ts'; +import useCurrentBlockTime from '~hooks/useCurrentBlockTime.ts'; +import useEnabledExtensions from '~hooks/useEnabledExtensions.ts'; +import { ActionTypes } from '~redux'; +import { type ClaimStreamingPaymentPayload } from '~redux/sagas/expenditures/claimStreamingPayment.ts'; +import Numeral from '~shared/Numeral/Numeral.tsx'; +import { type StreamingPaymentStatus } from '~types/streamingPayments.ts'; +import { formatText } from '~utils/intl.ts'; +import { + getStreamingPaymentAmountsLeft, + getStreamingPaymentStatus, +} from '~utils/streamingPayments.ts'; +import { getSelectedToken } from '~utils/tokens.ts'; +import { ACTION_TYPE_FIELD_NAME } from '~v5/common/ActionSidebar/consts.ts'; +import { useGetStreamingPaymentData } from '~v5/common/ActionSidebar/hooks/useGetStreamingPaymentData.ts'; +import ActionSidebarStatusPill from '~v5/common/ActionSidebar/partials/ActionSidebarStatusPill/ActionSidebarStatusPill.tsx'; +import MenuWithSections from '~v5/shared/MenuWithSections/MenuWithSections.tsx'; + +import AvailableToClaimCounter from '../AvailableToClaimCounter/AvailableToClaimCounter.tsx'; +import CreatedWithPermissionsInfo from '../CreatedWithPermissionsInfo/CreatedWithPermissionsInfo.tsx'; + +import { useHasEnoughTokensToClaim } from './hooks.ts'; +import { type StreamingPaymentWidgetProps } from './types.ts'; + +const displayName = + 'v5.common.CompletedAction.partials.StreamingPayment.partials.StreamingPaymentWidget'; + +const MSG = defineMessages({ + streamingStatus: { + id: `${displayName}.streamingStatus`, + defaultMessage: 'Streaming status', + }, + paidToDate: { + id: `${displayName}.paidToDate`, + defaultMessage: 'Paid to date', + }, + availableToClaim: { + id: `${displayName}.availableToClaim`, + defaultMessage: 'Available to claim', + }, + payFunds: { + id: `${displayName}.payFunds`, + defaultMessage: 'Pay funds', + }, + insufficientFunds: { + id: `${displayName}.insufficientFunds`, + defaultMessage: 'Insufficient funds in team. Ensure team is funded.', + }, + transferFunds: { + id: `${displayName}.transferFunds`, + defaultMessage: 'Transfer required funds', + }, +}); + +const StreamingPaymentWidget: FC = ({ + action, +}) => { + const { + loadingStreamingPayment, + refetchStreamingPayment, + streamingPaymentData, + } = useGetStreamingPaymentData(action?.streamingPaymentId); + const { + actionStatus, + setActionStatus, + isLoading: isLoadingStatus, + } = useActionStatusContext(); + + const { colony } = useColonyContext(); + const { streamingPaymentsAddress } = useEnabledExtensions(); + const { currentBlockTime: blockTime, fetchCurrentBlockTime } = + useCurrentBlockTime(); + const { + actionSidebarToggle: [, { toggleOn: toggleActionSidebarOn }], + } = useActionSidebarContext(); + const [isLoading, setIsLoading] = useState(false); + const [amountClaimedToDate, setAmountClaimedToDate] = useState('0'); + const [amountAvailableToClaim, setAmountAvailableToClaim] = useState('0'); + + const currentTime = useMemo( + () => Math.floor(blockTime ?? Date.now() / 1000), + [blockTime], + ); + + useEffect(() => { + setActionStatus( + getStreamingPaymentStatus({ + streamingPayment: streamingPaymentData, + currentTimestamp: currentTime, + }), + ); + + return () => { + setActionStatus(null); + }; + }, [currentTime, setActionStatus, streamingPaymentData]); + + useEffect(() => { + const { + amountClaimedToDate: amountClaimedToDateValue, + amountAvailableToClaim: amountAvailableToClaimValue, + } = getStreamingPaymentAmountsLeft(streamingPaymentData, currentTime); + + setAmountClaimedToDate(amountClaimedToDateValue); + setAmountAvailableToClaim(amountAvailableToClaimValue); + }, [currentTime, setActionStatus, streamingPaymentData]); + + useEffect(() => { + fetchCurrentBlockTime(); + }, [actionStatus, fetchCurrentBlockTime]); + + const checkIfHasEnoughFunds = useHasEnoughTokensToClaim( + streamingPaymentData?.nativeDomainId || Id.RootDomain, + streamingPaymentData?.tokenAddress || '', + amountAvailableToClaim, + ); + + const [hasEnoughFunds, setHasEnoughFunds] = useState(checkIfHasEnoughFunds()); + + useEffect(() => { + setHasEnoughFunds(checkIfHasEnoughFunds()); + }, [checkIfHasEnoughFunds]); + + const claim = useAsyncFunction({ + submit: ActionTypes.STREAMING_PAYMENT_CLAIM, + error: ActionTypes.STREAMING_PAYMENT_CLAIM_ERROR, + success: ActionTypes.STREAMING_PAYMENT_CLAIM_SUCCESS, + }); + + const handleClaim = async () => { + if (!streamingPaymentData) { + return; + } + + setIsLoading(true); + + try { + const hasEnoughFundsToMakeClaim = checkIfHasEnoughFunds(); + + if (!hasEnoughFundsToMakeClaim) { + setHasEnoughFunds(false); + return; + } + + const claimPayload: ClaimStreamingPaymentPayload = { + colonyAddress: colony.colonyAddress, + streamingPaymentsAddress: streamingPaymentsAddress ?? '', + streamingPayment: streamingPaymentData, + tokenAddress: streamingPaymentData.tokenAddress, + }; + + await claim(claimPayload); + + await fetchCurrentBlockTime(); + await refetchStreamingPayment(); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (!loadingStreamingPayment) { + fetchCurrentBlockTime(); + } + }, [fetchCurrentBlockTime, loadingStreamingPayment]); + + if (!streamingPaymentData) { + return null; + } + + const { tokenAddress, createdAt } = streamingPaymentData; + const selectedToken = getSelectedToken(colony, tokenAddress || ''); + + const ratePerSecond = BigNumber.from(streamingPaymentData.amount || '0') + .div(streamingPaymentData.interval || 1) + .toString(); + + return ( +
    + +

    + {formatText(MSG.insufficientFunds)} +

    + + + ), + }, + ]), + { + key: '1', + content: ( + <> +
    +

    {formatText(MSG.streamingStatus)}

    + + + +
    +
    + + {formatText(MSG.paidToDate)} + + + + +
    +
    + + {formatText(MSG.availableToClaim)} + + + { + const { + amountAvailableToClaim: amountAvailableToClaimValue, + } = getStreamingPaymentAmountsLeft( + streamingPaymentData, + currentTimeValue, + ); + + const streamingPaymentStatus = + getStreamingPaymentStatus({ + streamingPayment: streamingPaymentData, + currentTimestamp: currentTimeValue, + }); + + if (streamingPaymentStatus !== actionStatus) { + setActionStatus(streamingPaymentStatus); + } + + setAmountAvailableToClaim(amountAvailableToClaimValue); + }} + ratePerSecond={ratePerSecond} + currentTime={currentTime} + /> + +
    +
    + + {formatText(MSG.payFunds)} + +
    + + ), + }, + ]} + /> + {action.isMotion ? ( +

    Add motion steps here

    + ) : ( + + )} +
    + ); +}; + +StreamingPaymentWidget.displayName = displayName; +export default StreamingPaymentWidget; diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/hooks.ts b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/hooks.ts new file mode 100644 index 00000000000..3a5e7d5e2ff --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/hooks.ts @@ -0,0 +1,29 @@ +import { BigNumber } from 'ethers'; +import { useCallback } from 'react'; + +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { getBalanceForTokenAndDomain } from '~utils/tokens.ts'; + +export const useHasEnoughTokensToClaim = ( + domainId: number, + tokenAddress: string, + availableToClaim: string | null | undefined, +) => { + const { + colony: { balances }, + } = useColonyContext(); + + const checkIfHasEnoughFunds = useCallback(() => { + const selectedTokenBalance = getBalanceForTokenAndDomain( + balances, + tokenAddress, + domainId, + ); + + return BigNumber.from(selectedTokenBalance ?? '0').gte( + availableToClaim ?? '0', + ); + }, [availableToClaim, balances, domainId, tokenAddress]); + + return checkIfHasEnoughFunds; +}; diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/types.ts b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/types.ts new file mode 100644 index 00000000000..cb5589b5d4a --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/partials/StreamingPaymentWidget/types.ts @@ -0,0 +1,5 @@ +import { type ColonyAction } from '~types/graphql.ts'; + +export interface StreamingPaymentWidgetProps { + action: ColonyAction; +} diff --git a/src/components/v5/common/CompletedAction/partials/StreamingPayment/types.ts b/src/components/v5/common/CompletedAction/partials/StreamingPayment/types.ts new file mode 100644 index 00000000000..0e46390c167 --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/StreamingPayment/types.ts @@ -0,0 +1,5 @@ +import { type ColonyAction } from '~types/graphql.ts'; + +export interface StreamingPaymentProps { + action: ColonyAction; +} diff --git a/src/components/v5/common/CompletedAction/partials/rows/Amount.tsx b/src/components/v5/common/CompletedAction/partials/rows/Amount.tsx index e74aaa0cfd1..a6961fbab35 100644 --- a/src/components/v5/common/CompletedAction/partials/rows/Amount.tsx +++ b/src/components/v5/common/CompletedAction/partials/rows/Amount.tsx @@ -1,4 +1,4 @@ -import { Coins } from '@phosphor-icons/react'; +import { Coins, type Icon } from '@phosphor-icons/react'; import React from 'react'; import { type Token } from '~types/graphql.ts'; @@ -13,18 +13,30 @@ const displayName = 'v5.common.CompletedAction.partials.AmountRow'; interface AmountRowProps { amount: string; token?: Token; + rowLabel?: string; + tooltipContent?: string; + RowIcon?: Icon; } -const AmountRow = ({ amount, token }: AmountRowProps) => { +const AmountRow = ({ + amount, + token, + rowLabel, + tooltipContent, + RowIcon, +}: AmountRowProps) => { const formattedAmount = getNumeralTokenAmount(amount, token?.decimals); return ( {formattedAmount} diff --git a/src/components/v5/common/CompletedAction/partials/rows/TeamFrom.tsx b/src/components/v5/common/CompletedAction/partials/rows/TeamFrom.tsx index ca75d9eb2bd..0f92b2847b1 100644 --- a/src/components/v5/common/CompletedAction/partials/rows/TeamFrom.tsx +++ b/src/components/v5/common/CompletedAction/partials/rows/TeamFrom.tsx @@ -24,6 +24,10 @@ const TeamFromRow = ({ teamMetadata, actionType }: TeamFromRowProps) => { return formatText({ id: 'actionSidebar.tooltip.managePermissions.team', }); + case ColonyActionType.CreateStreamingPayment: + return formatText({ + id: 'actionSidebar.tooltip.streamingPayment.from', + }); default: return formatText({ id: 'actionSidebar.tooltip.simplePayment.from', @@ -39,6 +43,8 @@ const TeamFromRow = ({ teamMetadata, actionType }: TeamFromRowProps) => { return formatText({ id: 'actionSidebar.team' }); case ColonyActionType.CreateExpenditure: return formatText({ id: 'actionSidebar.fundFrom' }); + case ColonyActionType.CreateStreamingPayment: + return formatText({ id: 'actionSidebar.streamFrom' }); default: return formatText({ id: 'actionSidebar.from' }); } diff --git a/src/components/v5/common/Fields/CardSelect/CardSelect.tsx b/src/components/v5/common/Fields/CardSelect/CardSelect.tsx index 68402ab3e29..644e6f9baab 100644 --- a/src/components/v5/common/Fields/CardSelect/CardSelect.tsx +++ b/src/components/v5/common/Fields/CardSelect/CardSelect.tsx @@ -35,7 +35,8 @@ function CardSelect({ disabled, readonly, itemClassName = 'group flex text-md md:transition-colors md:hover:font-medium md:hover:bg-gray-50 rounded px-4 py-2 w-full cursor-pointer', - renderOptionWrapper = (props, children) => ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + renderOptionWrapper = ({ value, ...props }, children) => ( @@ -171,6 +172,7 @@ function CardSelect({ onChange(optionValue); toggleSelectOff(); }, + value: optionValue, }, label, )} diff --git a/src/components/v5/common/Fields/CardSelect/types.ts b/src/components/v5/common/Fields/CardSelect/types.ts index e9389a99e61..54079a8d924 100644 --- a/src/components/v5/common/Fields/CardSelect/types.ts +++ b/src/components/v5/common/Fields/CardSelect/types.ts @@ -40,6 +40,7 @@ export interface CardSelectProps extends BaseFieldProps { className?: string; onClick?: () => void; 'aria-label'?: string; + value?: TValue; }, children: React.ReactNode, ) => React.ReactElement | null | undefined; diff --git a/src/components/v5/common/Fields/InputBase/InputBase.tsx b/src/components/v5/common/Fields/InputBase/InputBase.tsx index 690a3040146..c8590c9f43a 100644 --- a/src/components/v5/common/Fields/InputBase/InputBase.tsx +++ b/src/components/v5/common/Fields/InputBase/InputBase.tsx @@ -86,15 +86,17 @@ const InputBase = React.forwardRef( return (
    - + {label && ( + + )} {prefix || suffix ? (
    = ({ ( = ({ + cancelButtonProps, + applyButtonProps, + popperModifiers, + onChange, + selected: selectedProp, + dateFormat = DEFAULT_DATE_TIME_FORMAT, + popperClassName, + minYear, + maxYear, + customInput, + inline, + withCloseButton, + onClose, + minDate = new Date('1970-01-01'), + maxDate = new Date('99999-01-01'), + ...rest +}) => { + const isMobile = useMobile(); + const calendarRef = useRef(null); + const [selectedDate, setSelectedDate] = useState( + selectedProp || null, + ); + const [selectedTime, setSelectedTime] = useState( + selectedProp || null, + ); + + const minDays = useMemo(() => daysFromRefDate(minDate), [minDate]); + const maxDays = useMemo(() => daysFromRefDate(maxDate), [maxDate]); + const selDays = useMemo(() => daysFromRefDate(selectedDate), [selectedDate]); + + const isMinDay = selDays === minDays; + const isMaxDay = selDays === maxDays; + + const maxDayMinTime = isMaxDay ? new Date(minTimeMaxDay) : null; + const maxDayMaxTime = isMinDay ? new Date(maxTimeMinDay) : null; + + const minTime = isMinDay ? minDate : maxDayMinTime; + const maxTime = isMaxDay ? maxDate : maxDayMaxTime; + + const resetValues = () => { + setSelectedDate(selectedProp || null); + setSelectedTime(selectedProp || null); + }; + + const { + onClick: applyButtonOnClick, + mode: applyButtonMode, + text: applyButtonText, + } = applyButtonProps || {}; + const { + onClick: cancelButtonOnClick, + mode: cancelButtonMode, + text: cancelButtonText, + } = cancelButtonProps || {}; + + return ( + ( + { + resetValues(); + calendarRef.current?.setOpen(false); + } + : undefined + } + {...props} + /> + )} + ref={calendarRef} + customInput={customInput || } + dateFormat={dateFormat} + popperClassName={clsx(popperClassName, '!z-top max-w-[20.5rem]')} + renderDayContents={(day) => ( +
    {day}
    + )} + shouldCloseOnSelect={false} + popperModifiers={[ + ...(popperModifiers || []), + { + name: 'offset', + options: { + offset: [10, -38], + }, + }, + ]} + selectsRange={false} + onChange={(date, event) => { + date?.setHours( + selectedTime?.getHours() || 0, + selectedTime?.getMinutes() || 0, + 0, + 0, + ); + setSelectedDate(date); + + if (isMobile) { + return; + } + + onChange(date, event); + }} + selected={selectedDate} + showTimeInput + onBlur={(event) => { + if (isMobile || !selectedDate) { + return; + } + + onChange(selectedDate, event); + onClose?.(); + }} + minDate={minDate} + maxDate={maxDate} + minTime={minTime ?? undefined} + maxTime={maxTime ?? undefined} + customTimeInput={ +
    + { + const hours = date?.getHours() || 0; + const minutes = date?.getMinutes() || 0; + + selectedDate?.setHours(hours, minutes, 0, 0); + + setSelectedTime(selectedDate ?? date); + setSelectedDate(selectedDate ?? date); + + if (event) { + return; + } + + if (!isMobile) { + onChange(selectedDate ?? date, event); + onClose?.(); + } + }} + minDate={minDate} + maxDate={maxDate} + minTime={minTime ?? undefined} + maxTime={maxTime ?? undefined} + onBlur={(event) => { + if (isMobile) { + return; + } + + onChange(selectedDate, event); + onClose?.(); + }} + selected={selectedTime} + /> +
    + } + inline={inline} + {...rest} + > + {selectedDate && isMobile && ( +
    +
    + )} +
    + ); +}; + +export default DatepickerWithTime; diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomHeader/DatepickerCustomHeader.tsx b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomHeader/DatepickerCustomHeader.tsx new file mode 100644 index 00000000000..7971b732972 --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomHeader/DatepickerCustomHeader.tsx @@ -0,0 +1,110 @@ +import { CaretLeft, CaretRight, X } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import format from 'date-fns/format'; +import getYear from 'date-fns/getYear'; +import React, { type FC, useState, useLayoutEffect } from 'react'; + +import { formatText } from '~utils/intl.ts'; +import { range } from '~utils/lodash.ts'; +import InputBase from '~v5/common/Fields/InputBase/index.ts'; + +import DatepickerYearDropdown from '../../../common/DatepickerYearDropdown/index.ts'; + +import { type DatepickerCustomHeaderProps } from './types.ts'; + +const DatepickerCustomHeader: FC = ({ + date, + changeYear, + monthDate, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + startDate, + dateFormat, + setStartDate, + onClose, + minYear = 1990, + maxYear = getYear(new Date()) + 1, + inline, +}) => { + const [startDateText, setStartDateText] = useState(''); + // @todo: check min and max + const years = range(minYear, maxYear); + const navigationButtonClassName = + 'flex justify-center items-center p-2 text-gray-500 transition sm:hover:text-blue-400'; + + useLayoutEffect(() => { + if (startDate) { + setStartDateText(format(startDate, dateFormat)); + } + }, [startDate, dateFormat]); + + return ( +
    + {onClose && ( +
    + +
    + )} +
    + + + +
    +
    + + +
    +
    + ); +}; + +export default DatepickerCustomHeader; diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomHeader/types.ts b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomHeader/types.ts new file mode 100644 index 00000000000..a3bb45b271c --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomHeader/types.ts @@ -0,0 +1,12 @@ +import { type ReactDatePickerCustomHeaderProps } from 'react-datepicker'; + +export interface DatepickerCustomHeaderProps + extends ReactDatePickerCustomHeaderProps { + dateFormat: string; + setStartDate: React.Dispatch>; + startDate?: Date | null; + onClose?: () => void; + minYear?: number; + maxYear?: number; + inline?: boolean; +} diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomInput/DatepickerCustomInput.tsx b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomInput/DatepickerCustomInput.tsx new file mode 100644 index 00000000000..f8855f48699 --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomInput/DatepickerCustomInput.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { formatText } from '~utils/intl.ts'; +import InputBase from '~v5/common/Fields/InputBase/index.ts'; +import { type InputBaseProps } from '~v5/common/Fields/InputBase/types.ts'; + +const DatepickerCustomInput = React.forwardRef< + HTMLInputElement, + InputBaseProps +>(({ value, onClick, ...rest }, ref) => { + return ( + + ); +}); +export default DatepickerCustomInput; diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomTimeInput/DatepickerCustomTimeInput.tsx b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomTimeInput/DatepickerCustomTimeInput.tsx new file mode 100644 index 00000000000..cba93538437 --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomTimeInput/DatepickerCustomTimeInput.tsx @@ -0,0 +1,31 @@ +import { CaretDown } from '@phosphor-icons/react'; +import clsx from 'clsx'; +import React from 'react'; + +import InputBase from '~v5/common/Fields/InputBase/index.ts'; +import { type InputBaseProps } from '~v5/common/Fields/InputBase/types.ts'; + +const DatepickerCustomTimeInput = React.forwardRef< + HTMLInputElement, + InputBaseProps +>(({ value, onClick, className, ...rest }, ref) => { + return ( + + } + {...rest} + /> + ); +}); + +export default DatepickerCustomTimeInput; diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomTimeInput/types.ts b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomTimeInput/types.ts new file mode 100644 index 00000000000..24897c8da38 --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerCustomTimeInput/types.ts @@ -0,0 +1,6 @@ +import { type ReactDatePickerProps } from 'react-datepicker'; + +export type DatepickerCustomTimeInputProps = Pick< + ReactDatePickerProps, + 'selected' | 'onChange' +>; diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/DatepickerTimePicker.module.css b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/DatepickerTimePicker.module.css new file mode 100644 index 00000000000..c1aaed0e2ec --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/DatepickerTimePicker.module.css @@ -0,0 +1,59 @@ +.wrapper { + @apply w-full; +} + +.wrapper :global(.react-datepicker__triangle), +.wrapper :global(.react-datepicker__header--time--only) { + @apply hidden; +} + +.wrapper :global(.react-datepicker--time-only) { + @apply w-full rounded border border-gray-300 font-inter shadow-select; +} + +.wrapper :global(.react-datepicker__time-container) { + @apply float-none !w-full; +} + +.wrapper :global(.react-datepicker__time-container .react-datepicker__time) { + @apply rounded-none bg-transparent; +} + +.wrapper + :global( + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ) { + @apply w-full rounded-none; +} + +.wrapper :global(.react-datepicker__time-list) { + @apply !box-border rounded-none !px-2 py-4; +} + +.wrapper + :global( + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item + ) { + @apply flex h-[2.1875rem] w-full items-center rounded px-4 py-1 text-left text-md text-gray-900 transition sm:enabled:hover:bg-gray-50; +} + +.wrapper + :global( + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--selected + ) { + @apply bg-gray-50 font-medium sm:enabled:hover:bg-gray-50; +} + +.wrapper :global(.react-datepicker__time-list-item--disabled) { + @apply opacity-50; +} diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/DatepickerTimePicker.tsx b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/DatepickerTimePicker.tsx new file mode 100644 index 00000000000..b4f810c075e --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/DatepickerTimePicker.tsx @@ -0,0 +1,60 @@ +import React, { type FC } from 'react'; +import DatePicker from 'react-datepicker'; + +import { useMobile } from '~hooks'; + +import DatepickerCustomTimeInput from '../DatepickerCustomTimeInput/DatepickerCustomTimeInput.tsx'; + +import { type DatepickerTimePickerProps } from './types.ts'; + +import styles from './DatepickerTimePicker.module.css'; + +const DatepickerTimePicker: FC = ({ + selected, + onChange, + onBlur, + minDate, + maxDate, + minTime, + maxTime, +}) => { + const isMobile = useMobile(); + + return ( + } + onBlur={onBlur} + popperProps={ + isMobile + ? { + placement: 'top-start', + } + : undefined + } + minDate={minDate} + maxDate={maxDate} + minTime={minTime} + maxTime={maxTime} + /> + ); +}; + +export default DatepickerTimePicker; diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/types.ts b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/types.ts new file mode 100644 index 00000000000..a76df4b352c --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/partials/DatepickerTimePicker/types.ts @@ -0,0 +1,12 @@ +import { type ReactDatePickerProps } from 'react-datepicker'; + +export type DatepickerTimePickerProps = Pick< + ReactDatePickerProps, + | 'selected' + | 'onChange' + | 'onBlur' + | 'minDate' + | 'maxDate' + | 'minTime' + | 'maxTime' +>; diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/types.ts b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/types.ts new file mode 100644 index 00000000000..8659526e5b2 --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/types.ts @@ -0,0 +1,19 @@ +import { type ReactDatePickerProps } from 'react-datepicker'; + +import { type DatepickerCommonProps } from '../common/types.ts'; + +export interface DatepickerWithTimeProps + extends Omit< + ReactDatePickerProps, + | 'renderCustomHeader' + | 'calendarContainer' + | 'calendarClassName' + | 'dateFormat' + | 'selectsRange' + | 'startDate' + | 'endDate' + >, + DatepickerCommonProps { + withCloseButton?: boolean; + onClose?: () => void; +} diff --git a/src/components/v5/common/Fields/datepickers/DatepickerWithTime/utils.ts b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/utils.ts new file mode 100644 index 00000000000..0808d7d4173 --- /dev/null +++ b/src/components/v5/common/Fields/datepickers/DatepickerWithTime/utils.ts @@ -0,0 +1,3 @@ +export const daysFromRefDate = (date: Date | null) => + date && + Math.floor((date.getTime() - date.getTimezoneOffset() * 60000) / 86400000); diff --git a/src/components/v5/common/Fields/datepickers/RangeDatepicker/RangeDatepicker.tsx b/src/components/v5/common/Fields/datepickers/RangeDatepicker/RangeDatepicker.tsx index 6b52d0943ee..04c383be8dc 100644 --- a/src/components/v5/common/Fields/datepickers/RangeDatepicker/RangeDatepicker.tsx +++ b/src/components/v5/common/Fields/datepickers/RangeDatepicker/RangeDatepicker.tsx @@ -31,6 +31,7 @@ const RangeDatepicker: FC = ({ popperClassName, minYear, maxYear, + withoutButtons, ...rest }) => { const isMobile = useMobile(); @@ -118,13 +119,18 @@ const RangeDatepicker: FC = ({ const formattedEndDate = end ? addDays(subSeconds(end, 1), 1) : null; setStartDate(start); setEndDate(formattedEndDate); + + if (withoutButtons && start && formattedEndDate) { + onChange([start, formattedEndDate], undefined); + calendarRef.current?.setOpen(false); + } }} startDate={startDate} endDate={endDate} onClickOutside={resetValues} {...rest} > - {startDate && endDate && ( + {startDate && endDate && !withoutButtons && (