diff --git a/.github/workflows/ci-run.yml b/.github/workflows/ci-run.yml index ae27e6db48..861018613c 100644 --- a/.github/workflows/ci-run.yml +++ b/.github/workflows/ci-run.yml @@ -9,9 +9,11 @@ on: - synchronize branches: - main + - dev push: branches: - main + - dev # manual trigger from Github UI - Action tab workflow_dispatch: diff --git a/.github/workflows/sepolia.yml b/.github/workflows/sepolia.yml index 74d1fa86b2..d71b89ff0b 100644 --- a/.github/workflows/sepolia.yml +++ b/.github/workflows/sepolia.yml @@ -3,7 +3,7 @@ name: Deploy Migrations to Sepolia on: push: branches: - - main + - dev workflow_dispatch: jobs: diff --git a/package.json b/package.json index cd8676e25b..2a94065fae 100644 --- a/package.json +++ b/package.json @@ -108,8 +108,8 @@ "graphql": "^16.8.1", "he": "^1.2.0", "jsonwebtoken": "^9.0.0", - "juice-sdk-core": "^9.1.3-alpha", - "juice-sdk-react": "^9.2.3-alpha", + "juice-sdk-core": "^10.0.3-alpha", + "juice-sdk-react": "^10.0.1-alpha", "juicebox-metadata-helper": "0.1.7", "less": "4.1.2", "lodash": "^4.17.21", diff --git a/src/components/ActivityList.tsx b/src/components/ActivityList.tsx index 422ea0fe94..3539a197e9 100644 --- a/src/components/ActivityList.tsx +++ b/src/components/ActivityList.tsx @@ -12,12 +12,12 @@ import { import { useMemo, useState } from 'react' import { AnyProjectEvent } from './activityEventElems/AnyProjectEvent' -interface ActivityOption { +export interface ActivityOption { label: string value: ProjectEventFilter } -const ALL_OPT = (): ActivityOption => ({ label: t`All activity`, value: 'all' }) +export const ALL_OPT = (): ActivityOption => ({ label: t`All activity`, value: 'all' }) const PV1_OPTS = (): ActivityOption[] => [ ALL_OPT(), diff --git a/src/packages/v2v3/components/shared/Allocation/AllocationItem.tsx b/src/components/Allocation/AllocationItem.tsx similarity index 100% rename from src/packages/v2v3/components/shared/Allocation/AllocationItem.tsx rename to src/components/Allocation/AllocationItem.tsx diff --git a/src/packages/v2v3/components/shared/Allocation/types.ts b/src/components/Allocation/types.ts similarity index 100% rename from src/packages/v2v3/components/shared/Allocation/types.ts rename to src/components/Allocation/types.ts diff --git a/src/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx b/src/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx index 0e87fffee1..1cafe219a4 100644 --- a/src/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx +++ b/src/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx @@ -1,16 +1,16 @@ import { Form } from 'antd' import { CURRENCY_METADATA, CurrencyName } from 'constants/currency' -import { Split } from 'models/splits' import { PayoutsTable } from 'packages/v2v3/components/shared/PayoutsTable/PayoutsTable' +import { Split } from 'packages/v2v3/models/splits' import { V2V3CurrencyName, getV2V3CurrencyOption, } from 'packages/v2v3/utils/currency' import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' import { ReactNode } from 'react' import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' import { fromWad, parseWad } from 'utils/format/formatNumber' -import { allocationToSplit, splitToAllocation } from 'utils/splitToAllocation' import { usePayoutsForm } from '../hooks/usePayoutsForm' const DEFAULT_CURRENCY_NAME = CURRENCY_METADATA.ETH.name diff --git a/src/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx b/src/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx index 02d4c3b212..0389eae6df 100644 --- a/src/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx +++ b/src/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx @@ -3,16 +3,16 @@ import { RadioGroup } from '@headlessui/react' import { t } from '@lingui/macro' import { Callout } from 'components/Callout/Callout' import { DeleteConfirmationModal } from 'components/modals/DeleteConfirmationModal' +import { SwitchToUnlimitedModal } from 'components/PayoutsTable/SwitchToUnlimitedModal' import { useModal } from 'hooks/useModal' import { TreasurySelection } from 'models/treasurySelection' +import { ConvertAmountsModal } from 'packages/v2v3/components/shared/PayoutsTable/ConvertAmountsModal' import { usePayoutsTable } from 'packages/v2v3/components/shared/PayoutsTable/hooks/usePayoutsTable' -import { SwitchToUnlimitedModal } from 'packages/v2v3/components/shared/PayoutsTable/modals/SwitchToUnlimitedModal' import { useCallback, useEffect, useMemo, useState } from 'react' import { useAppSelector } from 'redux/hooks/useAppSelector' import { ReduxDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' import { fromWad } from 'utils/format/formatNumber' import { Icons } from '../../../Icons' -import { ConvertAmountsModal } from './ConvertAmountsModal' import { RadioCard } from './RadioCard' const treasuryOptions = () => [ diff --git a/src/components/Create/components/pages/PayoutsPage/hooks/usePayoutsForm.ts b/src/components/Create/components/pages/PayoutsPage/hooks/usePayoutsForm.ts index 4b7554d0d5..8a7b6c80fe 100644 --- a/src/components/Create/components/pages/PayoutsPage/hooks/usePayoutsForm.ts +++ b/src/components/Create/components/pages/PayoutsPage/hooks/usePayoutsForm.ts @@ -1,11 +1,11 @@ import { Form } from 'antd' import { TreasurySelection } from 'models/treasurySelection' import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' import { useDebugValue, useEffect, useMemo } from 'react' import { useAppDispatch } from 'redux/hooks/useAppDispatch' import { useAppSelector } from 'redux/hooks/useAppSelector' import { useEditingPayoutSplits } from 'redux/hooks/useEditingPayoutSplits' -import { allocationToSplit, splitToAllocation } from 'utils/splitToAllocation' type PayoutsFormProps = Partial<{ selection: TreasurySelection diff --git a/src/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts b/src/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts index ee1022dd92..9e743f07aa 100644 --- a/src/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts +++ b/src/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts @@ -14,13 +14,13 @@ import { redemptionRateFrom, reservedRateFrom, } from 'packages/v2v3/utils/math' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' import { useDebugValue, useEffect, useMemo } from 'react' import { useAppDispatch } from 'redux/hooks/useAppDispatch' import { useAppSelector } from 'redux/hooks/useAppSelector' import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' import { useEditingReservedTokensSplits } from 'redux/hooks/useEditingReservedTokensSplits' import { editingV2ProjectActions } from 'redux/slices/editingV2Project' -import { allocationToSplit, splitToAllocation } from 'utils/splitToAllocation' import { useFormDispatchWatch } from '../../hooks/useFormDispatchWatch' export type ProjectTokensFormProps = Partial<{ diff --git a/src/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx b/src/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx index 512a7cb798..c7132a21d9 100644 --- a/src/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx +++ b/src/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx @@ -7,10 +7,10 @@ import ExternalLink from 'components/ExternalLink' import TransactionModal from 'components/modals/TransactionModal' import { TERMS_OF_SERVICE_URL } from 'constants/links' import { useWallet } from 'hooks/Wallet' +import { emitConfirmationDeletionModal } from 'hooks/emitConfirmationDeletionModal' import useMobile from 'hooks/useMobile' import { useModal } from 'hooks/useModal' import { useRouter } from 'next/router' -import { emitConfirmationDeletionModal } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/utils/modals' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useDispatch } from 'react-redux' import { useAppSelector } from 'redux/hooks/useAppSelector' diff --git a/src/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts b/src/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts index b6d77e1196..99ccac1c6d 100644 --- a/src/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts +++ b/src/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts @@ -3,13 +3,13 @@ import { useAvailablePayoutsSelections } from 'components/Create/components/page import { formatFundingCycleDuration } from 'components/Create/utils/formatFundingCycleDuration' import moment from 'moment' import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' import { useCallback, useMemo } from 'react' import { useAppSelector } from 'redux/hooks/useAppSelector' import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' import { useEditingPayoutSplits } from 'redux/hooks/useEditingPayoutSplits' import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' import { formatFundingTarget } from 'utils/format/formatFundingTarget' -import { allocationToSplit, splitToAllocation } from 'utils/splitToAllocation' export const useFundingConfigurationReview = () => { const { fundingCycleData, payoutsSelection, mustStartAtOrAfter } = diff --git a/src/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts b/src/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts index 4e1e8058b1..c5f4b5da21 100644 --- a/src/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts +++ b/src/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts @@ -1,9 +1,9 @@ import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' import { useCallback, useMemo } from 'react' import { useAppSelector } from 'redux/hooks/useAppSelector' import { useEditingReservedTokensSplits } from 'redux/hooks/useEditingReservedTokensSplits' import { formatEnabled, formatPaused } from 'utils/format/formatBoolean' -import { allocationToSplit, splitToAllocation } from 'utils/splitToAllocation' export const useProjectTokenReview = () => { const { diff --git a/src/components/Create/utils/projectTokenSettingsToReduxFormat.ts b/src/components/Create/utils/projectTokenSettingsToReduxFormat.ts index 5f9255582f..de6f55ff2d 100644 --- a/src/components/Create/utils/projectTokenSettingsToReduxFormat.ts +++ b/src/components/Create/utils/projectTokenSettingsToReduxFormat.ts @@ -4,8 +4,8 @@ import { redemptionRateFrom, reservedRateFrom, } from 'packages/v2v3/utils/math' +import { allocationToSplit } from 'packages/v2v3/utils/splitToAllocation' import { EMPTY_RESERVED_TOKENS_GROUPED_SPLITS } from 'redux/slices/editingV2Project' -import { allocationToSplit } from 'utils/splitToAllocation' import { ProjectTokensFormProps } from '../components/pages/ProjectToken/hooks/useProjectTokenForm' export const projectTokenSettingsToReduxFormat = ( diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon.tsx b/src/components/ExternalLinkWithIcon.tsx similarity index 100% rename from src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon.tsx rename to src/components/ExternalLinkWithIcon.tsx diff --git a/src/components/Navbar/components/TransactionList/TransactionsList.tsx b/src/components/Navbar/components/TransactionList/TransactionsList.tsx index f1377ec1fc..1234d12ca9 100644 --- a/src/components/Navbar/components/TransactionList/TransactionsList.tsx +++ b/src/components/Navbar/components/TransactionList/TransactionsList.tsx @@ -5,8 +5,8 @@ import BadgeIcon from 'components/BadgeIcon' import ExternalLink from 'components/ExternalLink' import Loading from 'components/Loading' import { + TransactionLog, TxHistoryContext, - timestampForTxLog, } from 'contexts/Transaction/TxHistoryContext' import { TxStatus } from 'models/transaction' import { useContext, useEffect, useMemo, useState } from 'react' @@ -15,6 +15,11 @@ import { etherscanLink } from 'utils/etherscan' import { formatHistoricalDate } from 'utils/format/formatDate' import TxStatusIcon from './TxStatusIcon' +// Prefer using tx.timestamp if tx has been mined. Otherwise use createdAt timestamp +export const timestampForTxLog = (txLog: TransactionLog) => { + return txLog.tx?.timestamp ?? txLog.createdAt +} + export function TransactionsList({ listClassName, }: { diff --git a/src/packages/v2v3/components/shared/PayoutsTable/PayoutsTableCell.tsx b/src/components/PayoutsTable/PayoutsTableCell.tsx similarity index 100% rename from src/packages/v2v3/components/shared/PayoutsTable/PayoutsTableCell.tsx rename to src/components/PayoutsTable/PayoutsTableCell.tsx diff --git a/src/packages/v2v3/components/shared/PayoutsTable/PayoutsTableRow.tsx b/src/components/PayoutsTable/PayoutsTableRow.tsx similarity index 100% rename from src/packages/v2v3/components/shared/PayoutsTable/PayoutsTableRow.tsx rename to src/components/PayoutsTable/PayoutsTableRow.tsx diff --git a/src/packages/v2v3/components/shared/PayoutsTable/modals/SwitchToUnlimitedModal.tsx b/src/components/PayoutsTable/SwitchToUnlimitedModal.tsx similarity index 86% rename from src/packages/v2v3/components/shared/PayoutsTable/modals/SwitchToUnlimitedModal.tsx rename to src/components/PayoutsTable/SwitchToUnlimitedModal.tsx index 9ae5677d03..c01782ee70 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/modals/SwitchToUnlimitedModal.tsx +++ b/src/components/PayoutsTable/SwitchToUnlimitedModal.tsx @@ -1,6 +1,6 @@ import { Trans } from '@lingui/macro' import { Modal } from 'antd' -import { ExternalLinkWithIcon } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' import { helpPagePath } from 'utils/routes' export function SwitchToUnlimitedModal({ diff --git a/src/components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel.tsx b/src/components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel.tsx index 7c22abd2eb..b23135cc07 100644 --- a/src/components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel.tsx +++ b/src/components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel.tsx @@ -16,9 +16,9 @@ export type ConfigurationPanelTableData = { type ConfigurationPanelProps = { cycle: ConfigurationPanelTableData - token?: ConfigurationPanelTableData // V4TODO: don't make token optional - otherRules?: ConfigurationPanelTableData // V4TODO: don't make otherRules optional - extension?: ConfigurationPanelTableData | null // V4TODO: don't make extension optional + token: ConfigurationPanelTableData + otherRules: ConfigurationPanelTableData + extension: ConfigurationPanelTableData | null } export const ConfigurationPanel: React.FC = ({ @@ -30,12 +30,8 @@ export const ConfigurationPanel: React.FC = ({ return (
- {token && ( // V4TODO: don't make token optional - - )} - {otherRules && ( // V4TODO: don't make otherRules optional - - )} + + {extension && ( )} diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/TokensPanelTooltips.tsx b/src/components/Project/ProjectTabs/TokensPanelTooltips.tsx similarity index 100% rename from src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/TokensPanelTooltips.tsx rename to src/components/Project/ProjectTabs/TokensPanelTooltips.tsx diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/utils/flagPairToDatum.test.ts b/src/components/Project/ProjectTabs/utils/flagPairToDatum.test.ts similarity index 100% rename from src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/utils/flagPairToDatum.test.ts rename to src/components/Project/ProjectTabs/utils/flagPairToDatum.test.ts diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/utils/flagPairToDatum.ts b/src/components/Project/ProjectTabs/utils/flagPairToDatum.ts similarity index 90% rename from src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/utils/flagPairToDatum.ts rename to src/components/Project/ProjectTabs/utils/flagPairToDatum.ts index 0c6cb04da5..b7efd803bc 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/utils/flagPairToDatum.ts +++ b/src/components/Project/ProjectTabs/utils/flagPairToDatum.ts @@ -1,6 +1,6 @@ import { t } from '@lingui/macro' -import { pairToDatum } from 'components/Project/ProjectHeader/utils/pairToDatum' import { ConfigurationPanelDatum } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' export const flagPairToDatum = ( name: string, diff --git a/src/components/Project/ProjectHeader/utils/pairToDatum.test.ts b/src/components/Project/ProjectTabs/utils/pairToDatum.test.ts similarity index 100% rename from src/components/Project/ProjectHeader/utils/pairToDatum.test.ts rename to src/components/Project/ProjectTabs/utils/pairToDatum.test.ts diff --git a/src/components/Project/ProjectHeader/utils/pairToDatum.ts b/src/components/Project/ProjectTabs/utils/pairToDatum.ts similarity index 100% rename from src/components/Project/ProjectHeader/utils/pairToDatum.ts rename to src/components/Project/ProjectTabs/utils/pairToDatum.ts diff --git a/src/components/ProjectSafeDashboard/juiceboxTransactions/reconfigureFundingCyclesOf/ReconfigurationRichPreview.tsx b/src/components/ProjectSafeDashboard/juiceboxTransactions/reconfigureFundingCyclesOf/ReconfigurationRichPreview.tsx index ca5b6a10e3..c0ed95e809 100644 --- a/src/components/ProjectSafeDashboard/juiceboxTransactions/reconfigureFundingCyclesOf/ReconfigurationRichPreview.tsx +++ b/src/components/ProjectSafeDashboard/juiceboxTransactions/reconfigureFundingCyclesOf/ReconfigurationRichPreview.tsx @@ -7,8 +7,8 @@ import FundingCycleDetails from 'packages/v2v3/components/V2V3Project/V2V3Fundin import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { deriveNextIssuanceRate } from 'packages/v2v3/utils/fundingCycle' import { formatReservedRate } from 'packages/v2v3/utils/math' +import { toSplit } from 'packages/v2v3/utils/v2v3Splits' import { useContext } from 'react' -import { toSplit } from 'utils/splits' import { LinkToSafeButton } from '../../LinkToSafeButton' import { useTransactionJBController } from './hooks/useTransactionJBController' diff --git a/src/components/VolumeChart/hooks/useProjectTimeline.ts b/src/components/VolumeChart/hooks/useProjectTimeline.ts index 8118da562a..835bd277ed 100644 --- a/src/components/VolumeChart/hooks/useProjectTimeline.ts +++ b/src/components/VolumeChart/hooks/useProjectTimeline.ts @@ -67,16 +67,24 @@ export function useProjectTimeline({ return { blocks, timestamps } }, [blockData]) - const { data: queryResult, loading: isLoadingQuery } = useProjectTlQuery({ + const { data: v1v2v3QueryResult, loading: isLoadingQuery } = useProjectTlQuery({ client, variables: { id: blocks ? getSubgraphIdForProject(pv, projectId) : '', ...blocks, }, }) + // TODO: const { data: v4QueryResult } = useSubgraphQuery(ProjectTlDocument, { + // where: { + // projectId, + // }, + // }) const points = useMemo(() => { - if (!queryResult || !timestamps) return + // TODO: if (!(v1v2v3QueryResult || v4QueryResult) || !timestamps) return + if (!v1v2v3QueryResult || !timestamps) return + // TODO: const queryResult = pv === PV_V4 ? v4QueryResult : v1v2v3QueryResult + const queryResult = v1v2v3QueryResult const points: ProjectTimelinePoint[] = [] @@ -94,7 +102,7 @@ export function useProjectTimeline({ } return points - }, [timestamps, queryResult]) + }, [timestamps, v1v2v3QueryResult]) return { points, diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/AddTokenToMetamaskButton.tsx b/src/components/buttons/AddTokenToMetamaskButton.tsx similarity index 75% rename from src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/AddTokenToMetamaskButton.tsx rename to src/components/buttons/AddTokenToMetamaskButton.tsx index 4944f49247..8643167f31 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/AddTokenToMetamaskButton.tsx +++ b/src/components/buttons/AddTokenToMetamaskButton.tsx @@ -2,9 +2,9 @@ import { Trans } from '@lingui/macro' import type { MetaMaskInpageProvider } from '@metamask/providers' import { Button } from 'antd' import { providers } from 'ethers' -import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' -import { useContext } from 'react' +import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' import { twMerge } from 'tailwind-merge' +import { Hash } from 'viem' declare global { interface Window { @@ -26,9 +26,13 @@ const useMetamask = () => { return ethereum as unknown as MetaMaskInpageProvider } -function useAddTokenToWalletRequest() { +function useAddTokenToWalletRequest({ + tokenAddress, +}:{ + tokenAddress: Hash +}) { const ethereum = useMetamask() - const { tokenAddress, tokenSymbol } = useContext(V2V3ProjectContext) + const { data: tokenSymbol } = useNameOfERC20(tokenAddress) if (!ethereum) { return @@ -49,8 +53,16 @@ function useAddTokenToWalletRequest() { } } -export function AddTokenToMetamaskButton({ className }: { className: string }) { - const addToken = useAddTokenToWalletRequest() +export function AddTokenToMetamaskButton({ + className, + tokenAddress +}: { + className: string, + tokenAddress: Hash +}) { + const addToken = useAddTokenToWalletRequest({ + tokenAddress + }) if (!addToken) return null return ( diff --git a/src/components/formItems/ProjectDiscord.tsx b/src/components/formItems/ProjectDiscord.tsx index 1068186e50..e9d234fd94 100644 --- a/src/components/formItems/ProjectDiscord.tsx +++ b/src/components/formItems/ProjectDiscord.tsx @@ -14,7 +14,7 @@ export default function ProjectDiscord({ label={hideLabel ? undefined : t`Discord link`} {...formItemProps} > - + ) } diff --git a/src/components/formItems/ProjectTelegram.tsx b/src/components/formItems/ProjectTelegram.tsx index 821c7211de..c2ff1b2993 100644 --- a/src/components/formItems/ProjectTelegram.tsx +++ b/src/components/formItems/ProjectTelegram.tsx @@ -14,7 +14,7 @@ export default function ProjectTelegam({ label={hideLabel ? undefined : t`Telegram link`} {...formItemProps} > - + ) } diff --git a/src/components/formItems/formHelpers.tsx b/src/components/formItems/formHelpers.tsx index 70864f8a95..93ec60e734 100644 --- a/src/components/formItems/formHelpers.tsx +++ b/src/components/formItems/formHelpers.tsx @@ -2,7 +2,7 @@ import { isAddress } from 'ethers/lib/utils' import { PayoutMod } from 'packages/v1/models/mods' import { permyriadToPercent } from 'utils/format/formatNumber' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { isEqualAddress, isZeroAddress } from 'utils/address' import { percentToPermyriad } from 'utils/format/formatNumber' diff --git a/src/constants/splits.ts b/src/constants/splits.ts index c0084a5c0c..edb6bde1bb 100644 --- a/src/constants/splits.ts +++ b/src/constants/splits.ts @@ -1,4 +1,4 @@ -import { ETHPayoutSplitGroup, ReservedTokensSplitGroup } from 'models/splits' +import { ETHPayoutSplitGroup, ReservedTokensSplitGroup } from 'packages/v2v3/models/splits' export const ETH_PAYOUT_SPLIT_GROUP: ETHPayoutSplitGroup = 1 export const RESERVED_TOKEN_SPLIT_GROUP: ReservedTokensSplitGroup = 2 diff --git a/src/contexts/Transaction/EthersTxHistoryProvider.tsx b/src/contexts/Transaction/EthersTxHistoryProvider.tsx index 73b6f16a80..4c2b9a9656 100644 --- a/src/contexts/Transaction/EthersTxHistoryProvider.tsx +++ b/src/contexts/Transaction/EthersTxHistoryProvider.tsx @@ -1,7 +1,8 @@ import { readProvider } from 'constants/readProvider' -import { TransactionLog, TxStatus } from 'models/transaction' +import { TxStatus } from 'models/transaction' import { ReactNode, useEffect } from 'react' -import { TxHistoryContext } from './TxHistoryContext' +import { Hash } from 'viem' +import { TransactionLog, TxHistoryContext } from './TxHistoryContext' import { useTransactions } from './useTransactions' const nowSeconds = () => Math.round(new Date().valueOf() / 1000) @@ -45,7 +46,10 @@ const pollTransaction = async ( txLog.callbacks?.onConfirmed?.(response) return { ...txLog, - tx: response, + tx: { + hash: response.hash as Hash, + timestamp: response.timestamp, + }, status: TxStatus.success, } } diff --git a/src/contexts/Transaction/TxHistoryContext.ts b/src/contexts/Transaction/TxHistoryContext.ts index 21bf495374..ac93e5a0ea 100644 --- a/src/contexts/Transaction/TxHistoryContext.ts +++ b/src/contexts/Transaction/TxHistoryContext.ts @@ -1,17 +1,24 @@ -import { providers } from 'ethers' -import { TransactionCallbacks, TransactionLog } from 'models/transaction' +import { TransactionCallbacks, TxStatus } from 'models/transaction' import { createContext } from 'react' +import { Hash } from 'viem' -// Prefer using tx.timestamp if tx has been mined. Otherwise use createdAt timestamp -export const timestampForTxLog = (txLog: TransactionLog) => { - return ( - (txLog.tx as providers.TransactionResponse)?.timestamp ?? txLog.createdAt - ) +export type TransactionType = { + hash: Hash + timestamp?: number +} + +export type TransactionLog = { + id: number + title: string + createdAt: number + tx: TransactionType | null + status: TxStatus.pending | TxStatus.success | TxStatus.failed + callbacks?: TransactionCallbacks } export type AddTransactionFunction = ( title: string, - tx: providers.TransactionResponse, + tx: TransactionType, callbacks?: Omit, ) => void diff --git a/src/contexts/Transaction/WagmiTxHistoryProvider.tsx b/src/contexts/Transaction/WagmiTxHistoryProvider.tsx index d707751d01..2691c56f78 100644 --- a/src/contexts/Transaction/WagmiTxHistoryProvider.tsx +++ b/src/contexts/Transaction/WagmiTxHistoryProvider.tsx @@ -1,14 +1,94 @@ -import { ReactNode } from 'react' -import { TxHistoryContext } from './TxHistoryContext' +import { getTransactionReceipt } from '@wagmi/core' +import { TxStatus } from 'models/transaction' +import { wagmiConfig } from 'packages/v4/wagmiConfig' +import { ReactNode, useEffect } from 'react' +import { TransactionLog, TxHistoryContext } from './TxHistoryContext' import { useTransactions } from './useTransactions' +const nowSeconds = () => Math.round(new Date().valueOf() / 1000) + +const SHORT_POLL_INTERVAL_MILLISECONDS = 3 * 1000 // 3 sec +const LONG_POLL_INTERVAL_MILLISECONDS = 12 * 1000 // 12 sec + +const pollTransaction = async ( + txLog: TransactionLog, +): Promise => { + // Only do refresh logic for pending txs + // tx.hash shouldn't ever be undefined but it's optional typed :shrug: + if (!txLog.tx?.hash) { + return txLog + } + + try { + // this throws if the tx hasnt been mined + const response = await getTransactionReceipt(wagmiConfig, { + hash: txLog.tx.hash, + }) + + return { + ...txLog, + status: + response.status === 'success' ? TxStatus.success : TxStatus.failed, + } + } catch { + return txLog + } + + return txLog +} + export default function WagmiTxHistoryProvider({ children, }: { children: ReactNode }) { - const { transactions, addTransaction, removeTransaction } = useTransactions() - // TODO implement polling/wait logic using Wagmi + const { transactions, addTransaction, removeTransaction, setTransactions } = + useTransactions() + + // Setup poller for refreshing transactions + useEffect(() => { + // Only set new poller if there are pending transactions + // Succeeded/failed txs don't need to be refreshed + if (!transactions.some(tx => tx.status === TxStatus.pending)) { + return + } + + // If any pending txs were created less than 3 min ago, use short poll time + // Otherwise use longer poll time + // (Assume no need for quick UX if user has already waited 3 min) + const threeMinutesAgo = nowSeconds() - 3 * 60 + const pollInterval = transactions.some( + tx => tx.status === TxStatus.pending && threeMinutesAgo < tx.createdAt, + ) + ? SHORT_POLL_INTERVAL_MILLISECONDS + : LONG_POLL_INTERVAL_MILLISECONDS + + console.info('WagmiTxHistoryProvider::Setting poller', pollInterval) + + const poller = setInterval(async () => { + console.info('WagmiTxHistoryProvider::poller::polling for tx updates...') + + const transactionLogs = await Promise.all( + transactions.map(txLog => { + return pollTransaction(txLog) + }), + ) + + console.info( + 'WagmiTxHistoryProvider::poller::updating transactions state', + transactionLogs, + ) + setTransactions(transactionLogs) + }, pollInterval) + + // Clean up + return () => { + console.info('WagmiTxHistoryProvider::poller::removing poller') + + clearInterval(poller) + } + }, [transactions, setTransactions]) + return ( Math.round(new Date().valueOf() / 1000) export function useTransactions() { - const { userAddress, chain } = useWallet() const [transactions, setTransactionsState] = useState([]) + const { userAddress, chain } = useWallet() - const localStorageKey = useMemo( - () => - chain && userAddress - ? `transactions_${chain?.id}_${userAddress}` - : undefined, - [chain, userAddress], - ) + const localStorageKey = + chain && userAddress + ? `transactions_${chain?.id}_${userAddress}` + : undefined // Sets TransactionLogs in both localStorage and state // Ensures localStorage is always up to date, so we can persist good data on refresh @@ -52,7 +49,9 @@ export function useTransactions() { ) const removeTransaction = useCallback( - (id: number) => setTransactions(transactions.filter(tx => tx.id !== id)), + (id: number) => { + setTransactions(transactions.filter(tx => tx.id !== id)) + }, [transactions, setTransactions], ) @@ -62,16 +61,14 @@ export function useTransactions() { return } - setTransactions( - JSON.parse(localStorage.getItem(localStorageKey) || '[]') - // Only persist txs that are failed/pending - // or were created within history window - .filter( - (tx: TransactionLog) => - tx.status !== TxStatus.success || - nowSeconds() - TX_HISTORY_TIME_SECS < tx.createdAt, - ) as TransactionLog[], - ) + const txs = JSON.parse(localStorage.getItem(localStorageKey) || '[]') + const pendingOrFailedTxs = txs.filter( + (tx: TransactionLog) => + tx.status !== TxStatus.success || + nowSeconds() - TX_HISTORY_TIME_SECS < tx.createdAt, + ) as TransactionLog[] + + setTransactions(pendingOrFailedTxs) }, [setTransactions, localStorageKey]) return { diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/utils/modals.tsx b/src/hooks/emitConfirmationDeletionModal.tsx similarity index 93% rename from src/packages/v2v3/components/V2V3Project/ProjectDashboard/utils/modals.tsx rename to src/hooks/emitConfirmationDeletionModal.tsx index 50c7926d22..3be15ca0d0 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/utils/modals.tsx +++ b/src/hooks/emitConfirmationDeletionModal.tsx @@ -3,7 +3,7 @@ import { ModalOnCancelFn, ModalOnOkFn } from 'components/modals/JuiceModal' import { LanguageProvider } from 'contexts/Language/LanguageProvider' import { ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { ConfirmationDeletionModal } from '../components/ui/ConfirmationDeletionModal' +import { ConfirmationDeletionModal } from '../packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ConfirmationDeletionModal' type EmitConfirmationDeletionModalProps = { description?: ReactNode diff --git a/src/hooks/useSubtitle.ts b/src/hooks/useSubtitle.ts index 4d38cb484d..49af09d05e 100644 --- a/src/hooks/useSubtitle.ts +++ b/src/hooks/useSubtitle.ts @@ -1,4 +1,4 @@ -import { ProjectMetadata } from 'models/projectMetadata' +import { JBProjectMetadata } from 'juice-sdk-core' import { useMemo } from 'react' import { stripHtmlTags } from 'utils/string' @@ -6,7 +6,7 @@ export type SubtitleType = 'tagline' | 'description' export const useSubtitle = ( projectMetadata: - | Pick + | Pick | undefined, ) => { const subtitle = useMemo(() => { diff --git a/src/hooks/useTransactor.ts b/src/hooks/useTransactor.ts index 7a9b97e8b9..801d7e66a9 100644 --- a/src/hooks/useTransactor.ts +++ b/src/hooks/useTransactor.ts @@ -8,6 +8,7 @@ import { CV2V3 } from 'packages/v2v3/models/cv' import { useCallback, useContext } from 'react' import { featureFlagEnabled } from 'utils/featureFlags' import { emitErrorNotification } from 'utils/notifications' +import { Hash } from 'viem' import { useWallet } from './Wallet' type TxOpts = Omit @@ -134,10 +135,17 @@ export function useTransactor(): Transactor | undefined { // add transaction to the history UI const txTitle = options?.title ?? functionName - addTransaction?.(txTitle, result as providers.TransactionResponse, { - onConfirmed: options?.onConfirmed, - onCancelled: options?.onCancelled, - }) + addTransaction?.( + txTitle, + { + hash: result.hash as Hash, + timestamp: result.timestamp, + }, + { + onConfirmed: options?.onConfirmed, + onCancelled: options?.onCancelled, + }, + ) return true } catch (e) { diff --git a/src/locales/messages.pot b/src/locales/messages.pot index 828bdb844f..cf317d3fc8 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -77,6 +77,9 @@ msgstr "" msgid "per {currencyText} paid" msgstr "" +msgid "No surplus" +msgstr "" + msgid "Trust" msgstr "" @@ -206,6 +209,9 @@ msgstr "" msgid "Built by the best" msgstr "" +msgid "{0} tokens" +msgstr "" + msgid "Staked balance" msgstr "" @@ -254,6 +260,9 @@ msgstr "" msgid "Safe transactions" msgstr "" +msgid "Surplus" +msgstr "" + msgid "See example" msgstr "" @@ -353,6 +362,9 @@ msgstr "" msgid "Basic details" msgstr "" +msgid "{surplus} is available for future payouts." +msgstr "" + msgid "Your project can receive payments through the juicebox.money app." msgstr "" @@ -1031,6 +1043,9 @@ msgstr "" msgid "1. Set ENS name" msgstr "" +msgid "{surplus} is available for token redemptions or future payouts." +msgstr "" + msgid "No overflow" msgstr "" @@ -3224,6 +3239,9 @@ msgstr "" msgid "Start over" msgstr "" +msgid "<0/>fee" +msgstr "" + msgid "Juicebox provides trustless payroll capabilities to run automated payouts completely on-chain. <0>Learn more about payouts" msgstr "" @@ -3647,6 +3665,9 @@ msgstr "" msgid "(0%)" msgstr "" +msgid "Decay rate" +msgstr "" + msgid "Tokens per ETH contributed" msgstr "" @@ -4013,6 +4034,9 @@ msgstr "" msgid "Status" msgstr "" +msgid "{reservedPercent} of token issuance is set aside for the recipients below." +msgstr "" + msgid "Redemption rate:" msgstr "" @@ -4280,9 +4304,6 @@ msgstr "" msgid "Terms of Service" msgstr "" -msgid "Ruleset length" -msgstr "" - msgid "Payment notice" msgstr "" @@ -4469,6 +4490,9 @@ msgstr "" msgid "Edit handle of {projectTitle}" msgstr "" +msgid "Ruleset duration" +msgstr "" + msgid "Other assets in this project's owner's wallet." msgstr "" diff --git a/src/models/outgoingProject.ts b/src/models/outgoingProject.ts index 3e6f6abd0b..60c3a0e137 100644 --- a/src/models/outgoingProject.ts +++ b/src/models/outgoingProject.ts @@ -4,7 +4,7 @@ import { V2V3FundAccessConstraint, V2V3FundingCycle, } from 'packages/v2v3/models/fundingCycle' -import { SplitParams } from './splits' +import { SplitParams } from 'packages/v2v3/models/splits' type OutgoingGroupedSplit = { splits: SplitParams[] diff --git a/src/models/transaction.ts b/src/models/transaction.ts index afb01484c3..e1dd9ca9db 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -1,4 +1,4 @@ -import { BigNumberish, Signer, Transaction, providers } from 'ethers' +import { BigNumberish, Signer, Transaction } from 'ethers' export enum TxStatus { pending = 'PENDING', @@ -20,20 +20,3 @@ export interface TransactionOptions extends TransactionCallbacks { value?: BigNumberish } -export type TransactionLog = { - id: number - title: string - createdAt: number - callbacks?: TransactionCallbacks -} & ( - | { - // Only pending txs have not been mined - status: TxStatus.pending - tx: Transaction | null - } - | { - // Once mined, tx will be a TransactionResponse - status: TxStatus.success | TxStatus.failed - tx: providers.TransactionResponse | null - } -) diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/ExportPayoutsCsvItem.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/ExportPayoutsCsvItem.tsx index 3a0d37113b..7b8dfddd5e 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/ExportPayoutsCsvItem.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/ExportPayoutsCsvItem.tsx @@ -1,7 +1,7 @@ import { ArrowUpTrayIcon } from '@heroicons/react/24/outline' import { Trans, t } from '@lingui/macro' import { Button, Tooltip } from 'antd' -import { useExportSplitsToCsv } from 'hooks/useExportSplitsToCsv' +import { useExportSplitsToCsv } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useExportSplitsToCsv' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { useContext } from 'react' import { twMerge } from 'tailwind-merge' diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistoricalPayoutsData.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistoricalPayoutsData.tsx index a7b96a776f..12434c8112 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistoricalPayoutsData.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistoricalPayoutsData.tsx @@ -10,8 +10,8 @@ import useProjectSplits from 'packages/v2v3/hooks/contractReader/useProjectSplit import { V2V3_CURRENCY_ETH } from 'packages/v2v3/utils/currency' import { derivePayoutAmount } from 'packages/v2v3/utils/distributions' import { formatCurrencyAmount } from 'packages/v2v3/utils/formatCurrencyAmount' +import { isProjectSplit } from 'packages/v2v3/utils/v2v3Splits' import React, { useContext } from 'react' -import { isProjectSplit } from 'utils/splits' import { HistoricalConfigurationPanelProps } from './HistoricalConfigurationPanel' export const HistoricalPayoutsData: React.FC< diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationCyclesSection.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationCyclesSection.ts index fc9b0d1bd1..c4c7522822 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationCyclesSection.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationCyclesSection.ts @@ -1,6 +1,6 @@ import { t } from '@lingui/macro' -import { pairToDatum } from 'components/Project/ProjectHeader/utils/pairToDatum' import { ConfigurationPanelDatum } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' import { BigNumber } from 'ethers' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' import { V2V3FundingCycle } from 'packages/v2v3/models/fundingCycle' @@ -49,10 +49,10 @@ export const useFormatConfigurationCyclesSection = ({ const startTimeDatum: ConfigurationPanelDatum = useMemo(() => { const formattedTime = upcomingFundingCycle === null - ? formatTime(fundingCycle?.start.toBigInt()) + ? formatTime(fundingCycle?.start.toNumber()) : fundingCycle?.duration.isZero() ? t`Any time` - : formatTime(fundingCycle?.start.add(fundingCycle?.duration).toBigInt()) + : formatTime(fundingCycle?.start.add(fundingCycle?.duration).toNumber()) const formatTimeDatum: ConfigurationPanelDatum = { name: t`Start time`, diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationExtensionSection.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationExtensionSection.ts index fea2248e1d..e0f12e715b 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationExtensionSection.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationExtensionSection.ts @@ -1,14 +1,14 @@ import { t } from '@lingui/macro' -import { pairToDatum } from 'components/Project/ProjectHeader/utils/pairToDatum' import { ConfigurationPanelDatum, ConfigurationPanelTableData, } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairToDatum' +import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' import { V2V3FundingCycleMetadata } from 'packages/v2v3/models/fundingCycle' import { useMemo } from 'react' import { isZeroAddress } from 'utils/address' import { etherscanLink } from 'utils/etherscan' -import { flagPairToDatum } from '../../utils/flagPairToDatum' export const useFormatConfigurationExtensionSection = ({ fundingCycleMetadata, diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationOtherRulesSection.test.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationOtherRulesSection.test.ts index ea839238a0..63edb7f0d5 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationOtherRulesSection.test.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationOtherRulesSection.test.ts @@ -4,10 +4,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { renderHook } from '@testing-library/react-hooks' -import { flagPairToDatum } from '../../utils/flagPairToDatum' +import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairToDatum' import { useFormatConfigurationOtherRulesSection } from './useFormatConfigurationOtherRulesSection' -jest.mock('../../utils/flagPairToDatum') +jest.mock('components/Project/ProjectTabs/utils/flagPairToDatum') describe('useFormatConfigurationOtherRulesSection', () => { const mockFundingCycleMetadata = { diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationOtherRulesSection.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationOtherRulesSection.ts index b58c216275..00e07823cc 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationOtherRulesSection.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationOtherRulesSection.ts @@ -1,11 +1,11 @@ import { t } from '@lingui/macro' import { - ConfigurationPanelDatum, - ConfigurationPanelTableData, + ConfigurationPanelDatum, + ConfigurationPanelTableData, } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairToDatum' import { V2V3FundingCycleMetadata } from 'packages/v2v3/models/fundingCycle' import { useMemo } from 'react' -import { flagPairToDatum } from '../../utils/flagPairToDatum' export const useFormatConfigurationOtherRulesSection = ({ fundingCycleMetadata, diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationTokenSection.test.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationTokenSection.test.ts index 751b970813..12150fd353 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationTokenSection.test.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationTokenSection.test.ts @@ -3,7 +3,8 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { renderHook } from '@testing-library/react-hooks' -import { pairToDatum } from 'components/Project/ProjectHeader/utils/pairToDatum' +import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairToDatum' +import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' import { BigNumber } from 'ethers' import { computeIssuanceRate, @@ -13,11 +14,10 @@ import { formatReservedRate, } from 'packages/v2v3/utils/math' import { formattedNum } from 'utils/format/formatNumber' -import { flagPairToDatum } from '../../utils/flagPairToDatum' import { useFormatConfigurationTokenSection } from './useFormatConfigurationTokenSection' -jest.mock('../../utils/flagPairToDatum') -jest.mock('components/Project/ProjectHeader/utils/pairToDatum') +jest.mock('components/Project/ProjectTabs/utils/flagPairToDatum') +jest.mock('components/Project/ProjectTabs/utils/pairToDatum') jest.mock('packages/v2v3/utils/math') jest.mock('utils/format/formatNumber') diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationTokenSection.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationTokenSection.ts index c29c6b8ff0..f21d20107a 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationTokenSection.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/useConfigurationPanel/useFormatConfigurationTokenSection.ts @@ -1,9 +1,10 @@ import { t } from '@lingui/macro' -import { pairToDatum } from 'components/Project/ProjectHeader/utils/pairToDatum' import { ConfigurationPanelDatum, ConfigurationPanelTableData, } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairToDatum' +import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' import { V2V3FundingCycle, V2V3FundingCycleMetadata, @@ -18,7 +19,6 @@ import { import { useMemo } from 'react' import { formattedNum } from 'utils/format/formatNumber' import { tokenSymbolText } from 'utils/tokenSymbolText' -import { flagPairToDatum } from '../../utils/flagPairToDatum' export const useFormatConfigurationTokenSection = ({ fundingCycle, diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/usePayoutsSubPanel.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/usePayoutsSubPanel.tsx index 1fb8aa2def..f87ac8d8df 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/usePayoutsSubPanel.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/hooks/usePayoutsSubPanel.tsx @@ -1,19 +1,19 @@ import { AmountInCurrency } from 'components/currency/AmountInCurrency' import { BigNumber } from 'ethers' -import { Split } from 'models/splits' import { useProjectContext } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectContext' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' +import { Split } from 'packages/v2v3/models/splits' import { V2V3CurrencyName } from 'packages/v2v3/utils/currency' import { isJuiceboxProjectSplit } from 'packages/v2v3/utils/distributions' import { MAX_DISTRIBUTION_LIMIT, SPLITS_TOTAL_PERCENT, - feeForAmount, formatSplitPercent, } from 'packages/v2v3/utils/math' +import { getProjectOwnerRemainderSplit } from 'packages/v2v3/utils/v2v3Splits' import { useCallback, useMemo } from 'react' import assert from 'utils/assert' -import { getProjectOwnerRemainderSplit } from 'utils/splits' +import { feeForAmount } from 'utils/math' import { useCurrentUpcomingDistributionLimit } from './useCurrentUpcomingDistributionLimit' import { useCurrentUpcomingPayoutSplits } from './useCurrentUpcomingPayoutSplits' import { useDistributableAmount } from './useDistributableAmount' @@ -31,7 +31,7 @@ const calculateSplitAmountWad = ( ?.mul(split.percent) .div(SPLITS_TOTAL_PERCENT) const feeAmount = splitHasFee(split) - ? feeForAmount(splitValue, primaryETHTerminalFee) ?? BigNumber.from(0) + ? feeForAmount(splitValue?.toBigInt(), primaryETHTerminalFee?.toBigInt()) ?? 0n : BigNumber.from(0) return splitValue?.sub(feeAmount) } diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/NftRewardsPanel/NftReward/PreviewAddRemoveNftButton.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/NftRewardsPanel/NftReward/PreviewAddRemoveNftButton.tsx index 5967da2666..dd3d5a37c9 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/NftRewardsPanel/NftReward/PreviewAddRemoveNftButton.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/NftRewardsPanel/NftReward/PreviewAddRemoveNftButton.tsx @@ -1,9 +1,9 @@ import { MinusIcon, PlusIcon } from '@heroicons/react/24/solid' import { Trans, t } from '@lingui/macro' import { Button } from 'antd' +import { emitConfirmationDeletionModal } from 'hooks/emitConfirmationDeletionModal' import { useCallback } from 'react' import { twMerge } from 'tailwind-merge' -import { emitConfirmationDeletionModal } from '../../../utils/modals' const iconClasses = 'mr-1 h-6 w-6' const containerClasses = 'flex items-center justify-center' diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/NftRewardsPanel/NftReward/RemoveNftButton.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/NftRewardsPanel/NftReward/RemoveNftButton.tsx index 691a70b757..b218c69098 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/NftRewardsPanel/NftReward/RemoveNftButton.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/NftRewardsPanel/NftReward/RemoveNftButton.tsx @@ -1,9 +1,9 @@ import { TrashIcon } from '@heroicons/react/24/solid' import { Trans, t } from '@lingui/macro' +import { emitConfirmationDeletionModal } from 'hooks/emitConfirmationDeletionModal' import { useCallback } from 'react' import { stopPropagation } from 'react-stop-propagation' import { twMerge } from 'tailwind-merge' -import { emitConfirmationDeletionModal } from '../../../utils/modals' import { nftHoverButtonClasses } from './AddNftButton' // Button that appears when hovering an NFT reward card diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard.tsx index e3e4180c0f..09557026ec 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard.tsx @@ -1,10 +1,10 @@ import { - ArrowDownIcon, - CheckCircleIcon, - InformationCircleIcon, - MinusIcon, - PlusIcon, - TrashIcon, + ArrowDownIcon, + CheckCircleIcon, + InformationCircleIcon, + MinusIcon, + PlusIcon, + TrashIcon, } from '@heroicons/react/24/outline' import { Trans, t } from '@lingui/macro' import { Button, Tooltip } from 'antd' @@ -16,6 +16,7 @@ import { JuiceModal, JuiceModalProps } from 'components/modals/JuiceModal' import { PV_V2 } from 'constants/pv' import { useProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { useWallet } from 'hooks/Wallet' +import { emitConfirmationDeletionModal } from 'hooks/emitConfirmationDeletionModal' import { useCurrencyConverter } from 'hooks/useCurrencyConverter' import { useProjectLogoSrc } from 'hooks/useProjectLogoSrc' import { useHasNftRewards } from 'packages/v2v3/hooks/JB721Delegate/useHasNftRewards' @@ -23,8 +24,8 @@ import { useETHReceivedFromTokens } from 'packages/v2v3/hooks/contractReader/use import { useRedeemTokensTx } from 'packages/v2v3/hooks/transactor/useRedeemTokensTx' import { usePayProjectDisabled } from 'packages/v2v3/hooks/usePayProjectDisabled' import { - V2V3_CURRENCY_ETH, - V2V3_CURRENCY_USD, + V2V3_CURRENCY_ETH, + V2V3_CURRENCY_USD, } from 'packages/v2v3/utils/currency' import { formatCurrencyAmount } from 'packages/v2v3/utils/formatCurrencyAmount' import { isInfiniteDistributionLimit } from 'packages/v2v3/utils/fundingCycle' @@ -43,13 +44,12 @@ import { useTokensPanel } from '../hooks/useTokensPanel' import { useTokensPerEth } from '../hooks/useTokensPerEth' import { useUnclaimedTokenBalance } from '../hooks/useUnclaimedTokenBalance' import { - useProjectDispatch, - useProjectSelector, - useProjectStore, + useProjectDispatch, + useProjectSelector, + useProjectStore, } from '../redux/hooks' import { payRedeemActions } from '../redux/payRedeemSlice' import { projectCartActions } from '../redux/projectCartSlice' -import { emitConfirmationDeletionModal } from '../utils/modals' import { CartItemBadge } from './CartItemBadge' import { ClaimErc20Callout } from './ClaimErc20Callout' import { EthPerTokenAccordion } from './EthPerTokenAccordion' diff --git a/src/components/Project/ProjectHeader/ProjectHeaderStats.test.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderStats.test.tsx similarity index 100% rename from src/components/Project/ProjectHeader/ProjectHeaderStats.test.tsx rename to src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderStats.test.tsx diff --git a/src/components/Project/ProjectHeader/ProjectHeaderStats.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderStats.tsx similarity index 96% rename from src/components/Project/ProjectHeader/ProjectHeaderStats.tsx rename to src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderStats.tsx index 22e78a378a..fdd529b0d7 100644 --- a/src/components/Project/ProjectHeader/ProjectHeaderStats.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderStats.tsx @@ -2,12 +2,12 @@ import { BigNumber } from '@ethersproject/bignumber' import { ArrowTrendingUpIcon } from '@heroicons/react/24/outline' import { t, Trans } from '@lingui/macro' import ETHAmount from 'components/currency/ETHAmount' +import { ProjectHeaderStat } from 'components/Project/ProjectHeader/ProjectHeaderStat' import { TRENDING_WINDOW_DAYS } from 'components/Projects/RankingExplanation' import { useProjectPageQueries } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectPageQueries' import { useV2V3ProjectHeader } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useV2V3ProjectHeader' import { PropsWithChildren, useCallback } from 'react' import { twMerge } from 'tailwind-merge' -import { ProjectHeaderStat } from './ProjectHeaderStat' export function ProjectHeaderStats() { const { payments, totalVolume, last7DaysPercent } = useV2V3ProjectHeader() diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/TokensPanel.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/TokensPanel.tsx index 1f451d969f..8f773394b1 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/TokensPanel.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/TokensPanel.tsx @@ -3,6 +3,7 @@ import { Button } from 'antd' import EthereumAddress from 'components/EthereumAddress' import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/TitleDescriptionDisplayCard' import { TokenAmount } from 'components/TokenAmount' +import { AddTokenToMetamaskButton } from 'components/buttons/AddTokenToMetamaskButton' import { IssueErc20TokenButton } from 'components/buttons/IssueErc20TokenButton' import { useTokensPanel } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useTokensPanel' import { useYourBalanceMenuItems } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useYourBalanceMenuItems/useYourBalanceMenuItems' @@ -11,8 +12,8 @@ import { V2V3ClaimTokensModal } from 'packages/v2v3/components/V2V3Project/V2V3M import { V2V3MintModal } from 'packages/v2v3/components/V2V3Project/V2V3ManageTokensSection/AccountBalanceDescription/V2V3MintModal' import { useCallback, useState } from 'react' import { reloadWindow } from 'utils/windowUtils' +import { Hash } from 'viem' import { TokenHoldersModal } from '../TokenHoldersModal/TokenHoldersModal' -import { AddTokenToMetamaskButton } from './components/AddTokenToMetamaskButton' import { MigrateTokensButton } from './components/MigrateTokensButton' import { RedeemTokensButton } from './components/RedeemTokensButton' import { ReservedTokensSubPanel } from './components/ReservedTokensSubPanel' @@ -187,8 +188,11 @@ const ProjectTokenCard = () => { )}
- {projectHasErc20Token && ( - + {projectTokenAddress && projectHasErc20Token && ( + )} {canCreateErc20Token && ( diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/ExportTokensCsvItem.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/ExportTokensCsvItem.tsx index de4be5c84b..1221edb6b5 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/ExportTokensCsvItem.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/ExportTokensCsvItem.tsx @@ -1,6 +1,6 @@ import { ArrowUpTrayIcon } from '@heroicons/react/24/outline' import { Trans } from '@lingui/macro' -import { useExportSplitsToCsv } from 'hooks/useExportSplitsToCsv' +import { useExportSplitsToCsv } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useExportSplitsToCsv' import { useProjectContext } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectContext' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { useContext } from 'react' diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/ReservedTokensSubPanel.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/ReservedTokensSubPanel.tsx index 42a22e2173..edef93cc31 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/ReservedTokensSubPanel.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/components/ReservedTokensSubPanel.tsx @@ -1,8 +1,8 @@ import { Trans, t } from '@lingui/macro' import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/TitleDescriptionDisplayCard' +import { reservedTokensTooltip } from 'components/Project/ProjectTabs/TokensPanelTooltips' import { twMerge } from 'tailwind-merge' import { ProjectAllocationRow } from '../../ProjectAllocationRow/ProjectAllocationRow' -import { reservedTokensTooltip } from '../TokensPanelTooltips' import { useReservedTokensSubPanel } from '../hooks/useReservedTokensSubPanel' import { ExportTokensCsvItem } from './ExportTokensCsvItem' import { SendReservedTokensButton } from './SendReservedTokensButton' diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/V2V3ProjectHeader.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/V2V3ProjectHeader.tsx index 265e987243..6cd2a4d221 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/V2V3ProjectHeader.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/V2V3ProjectHeader.tsx @@ -7,13 +7,13 @@ import EthereumAddress from 'components/EthereumAddress' import { GnosisSafeBadge } from 'components/Project/ProjectHeader/GnosisSafeBadge' import { ProjectHeaderLogo } from 'components/Project/ProjectHeader/ProjectHeaderLogo' import { ProjectHeaderPopupMenu } from 'components/Project/ProjectHeader/ProjectHeaderPopupMenu' -import { ProjectHeaderStats } from 'components/Project/ProjectHeader/ProjectHeaderStats' import { SocialLinkButton } from 'components/Project/ProjectHeader/SocialLinkButton' import { Subtitle } from 'components/Project/ProjectHeader/Subtitle' import { useSocialLinks } from 'components/Project/ProjectHeader/hooks/useSocialLinks' import { TruncatedText } from 'components/TruncatedText' import useMobile from 'hooks/useMobile' import Link from 'next/link' +import { ProjectHeaderStats } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderStats' import { useV2V3ProjectHeader } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useV2V3ProjectHeader' import V2V3ProjectHandleLink from 'packages/v2v3/components/shared/V2V3ProjectHandleLink' import { useV2V3WalletHasPermission } from 'packages/v2v3/hooks/contractReader/useV2V3WalletHasPermission' diff --git a/src/hooks/useExportSplitsToCsv.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useExportSplitsToCsv.ts similarity index 94% rename from src/hooks/useExportSplitsToCsv.ts rename to src/packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useExportSplitsToCsv.ts index f07098032e..ee14e02c38 100644 --- a/src/hooks/useExportSplitsToCsv.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useExportSplitsToCsv.ts @@ -1,13 +1,13 @@ import { t } from '@lingui/macro' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { BigNumber } from 'ethers' -import { Split } from 'models/splits' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' +import { Split } from 'packages/v2v3/models/splits' import { formatSplitPercent } from 'packages/v2v3/utils/math' +import { getProjectOwnerRemainderSplit } from 'packages/v2v3/utils/v2v3Splits' import { useContext, useState } from 'react' import { downloadCsvFile } from 'utils/csv' import { emitErrorNotification } from 'utils/notifications' -import { getProjectOwnerRemainderSplit } from 'utils/splits' const CSV_HEADER = [ 'beneficiary', diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useEditingFundingCycleConfig.ts b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useEditingFundingCycleConfig.ts index 3a27869a11..35b19119a5 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useEditingFundingCycleConfig.ts +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useEditingFundingCycleConfig.ts @@ -1,12 +1,12 @@ -import { - ETHPayoutGroupedSplits, - ReservedTokensGroupedSplits, -} from 'models/splits' import { V2V3FundAccessConstraint, V2V3FundingCycleData, V2V3FundingCycleMetadata, } from 'packages/v2v3/models/fundingCycle' +import { + ETHPayoutGroupedSplits, + ReservedTokensGroupedSplits, +} from 'packages/v2v3/models/splits' import { useAppSelector, useEditingV2V3FundAccessConstraintsSelector, diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts index 75bcb2105b..e5a7cb07bc 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts @@ -3,7 +3,6 @@ import { RESERVED_TOKEN_SPLIT_GROUP, } from 'constants/splits' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' -import { Split } from 'models/splits' import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ContractsContext } from 'packages/v2v3/contexts/Contracts/V2V3ContractsContext' import { NftRewardsContext } from 'packages/v2v3/contexts/NftRewards/NftRewardsContext' @@ -11,6 +10,7 @@ import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectCo import useProjectDistributionLimit from 'packages/v2v3/hooks/contractReader/useProjectDistributionLimit' import useProjectQueuedFundingCycle from 'packages/v2v3/hooks/contractReader/useProjectQueuedFundingCycle' import useProjectSplits from 'packages/v2v3/hooks/contractReader/useProjectSplits' +import { Split } from 'packages/v2v3/models/splits' import { NO_CURRENCY, V2V3_CURRENCY_ETH } from 'packages/v2v3/utils/currency' import { SerializedV2V3FundAccessConstraint, diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCycleFormFields.ts b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCycleFormFields.ts index a614fd0cce..3d7940503d 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCycleFormFields.ts +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCycleFormFields.ts @@ -1,6 +1,6 @@ import { DurationOption } from 'components/inputs/DurationInput' import { CurrencyName } from 'constants/currency' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { NftRewardsData } from 'redux/slices/editingV2Project/types' type DetailsSectionFields = { diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCyclePage.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCyclePage.tsx index 3c894b567d..3b4e67276e 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCyclePage.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCyclePage.tsx @@ -1,10 +1,10 @@ import { Trans } from '@lingui/macro' import { Button, Form, Tooltip } from 'antd' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' import Loading from 'components/Loading' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import Link from 'next/link' import { useRouter } from 'next/router' -import { ExternalLinkWithIcon } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { useContext, useEffect, useRef, useState } from 'react' import { helpPagePath, settingsPagePath } from 'utils/routes' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/PayoutsSection/PayoutsSection.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/PayoutsSection/PayoutsSection.tsx index d158a721d0..2815f10631 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/PayoutsSection/PayoutsSection.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/PayoutsSection/PayoutsSection.tsx @@ -3,8 +3,8 @@ import { Form } from 'antd' import { useWatch } from 'antd/lib/form/Form' import { JuiceSwitch } from 'components/inputs/JuiceSwitch' import { CurrencyName } from 'constants/currency' -import { Split } from 'models/splits' import { PayoutsTable } from 'packages/v2v3/components/shared/PayoutsTable/PayoutsTable' +import { Split } from 'packages/v2v3/models/splits' import { AdvancedDropdown } from '../AdvancedDropdown' import { useEditCycleFormContext } from '../EditCycleFormContext' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts index 560be5887f..1dd15686e1 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts @@ -1,13 +1,13 @@ import { CurrencyName } from 'constants/currency' -import { Split } from 'models/splits' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' +import { Split } from 'packages/v2v3/models/splits' import { V2V3CurrencyName } from 'packages/v2v3/utils/currency' import { distributionLimitsEqual } from 'packages/v2v3/utils/distributions' import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' +import { splitsListsHaveDiff } from 'packages/v2v3/utils/v2v3Splits' import { useContext } from 'react' import { parseWad } from 'utils/format/formatNumber' -import { splitsListsHaveDiff } from 'utils/splits' import { useEditCycleFormContext } from '../../EditCycleFormContext' export const usePayoutsSectionValues = () => { diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts index 8a7e5c4948..3d4501932e 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts @@ -9,8 +9,8 @@ import { issuanceRateFrom, reservedRateFrom, } from 'packages/v2v3/utils/math' +import { splitsListsHaveDiff } from 'packages/v2v3/utils/v2v3Splits' import { useContext } from 'react' -import { splitsListsHaveDiff } from 'utils/splits' import { tokenSymbolText } from 'utils/tokenSymbolText' import { useEditCycleFormContext } from '../../EditCycleFormContext' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/IssuanceRateReductionField.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/IssuanceRateReductionField.tsx index 5c820d05b7..9110cfd87b 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/IssuanceRateReductionField.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/IssuanceRateReductionField.tsx @@ -1,7 +1,7 @@ import { Trans } from '@lingui/macro' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' import { JuiceSwitch } from 'components/inputs/JuiceSwitch' import NumberSlider from 'components/inputs/NumberSlider' -import { ExternalLinkWithIcon } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon' import { useState } from 'react' import { helpPagePath } from 'utils/routes' import { useEditCycleFormContext } from '../EditCycleFormContext' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/RedemptionRateField.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/RedemptionRateField.tsx index 6ba1e203fe..4fbd5367e2 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/RedemptionRateField.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/RedemptionRateField.tsx @@ -1,9 +1,9 @@ import { Trans } from '@lingui/macro' import { useWatch } from 'antd/lib/form/Form' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' import { TokenRedemptionRateGraph } from 'components/TokenRedemptionRateGraph/TokenRedemptionRateGraph' import { JuiceSwitch } from 'components/inputs/JuiceSwitch' import NumberSlider from 'components/inputs/NumberSlider' -import { ExternalLinkWithIcon } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon' import { useState } from 'react' import { helpPagePath } from 'utils/routes' import { useEditCycleFormContext } from '../EditCycleFormContext' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/ReservedTokensField.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/ReservedTokensField.tsx index 9b8c237014..a430826b70 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/ReservedTokensField.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/ReservedTokensField.tsx @@ -1,14 +1,14 @@ import { Trans } from '@lingui/macro' import { useWatch } from 'antd/lib/form/Form' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' import { FormItems } from 'components/formItems' import { ItemNoInput } from 'components/formItems/ItemNoInput' import { JuiceSwitch } from 'components/inputs/JuiceSwitch' -import { Split } from 'models/splits' -import { ExternalLinkWithIcon } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon' +import { Split } from 'packages/v2v3/models/splits' import { SPLITS_TOTAL_PERCENT } from 'packages/v2v3/utils/math' +import { totalSplitsPercent } from 'packages/v2v3/utils/v2v3Splits' import { useState } from 'react' import { helpPagePath } from 'utils/routes' -import { totalSplitsPercent } from 'utils/splits' import { V2V3EditReservedTokens } from '../../ReservedTokensSettingsPage/V2V3EditReservedTokens' import { AdvancedDropdown } from '../AdvancedDropdown' import { useEditCycleFormContext } from '../EditCycleFormContext' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/useEditCycleFormHasError.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/useEditCycleFormHasError.tsx index c59c80432b..52e1f5df0c 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/useEditCycleFormHasError.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/useEditCycleFormHasError.tsx @@ -1,7 +1,7 @@ import { Trans } from '@lingui/macro' import { useWatch } from 'antd/lib/form/Form' import { SPLITS_TOTAL_PERCENT } from 'packages/v2v3/utils/math' -import { totalSplitsPercent } from 'utils/splits' +import { totalSplitsPercent } from 'packages/v2v3/utils/v2v3Splits' import { useEditCycleFormContext } from '../EditCycleFormContext' export function useEditCycleFormHasError() { diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/usePrepareSaveEditCycleData.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/usePrepareSaveEditCycleData.tsx index 1674471a65..54a83857fc 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/usePrepareSaveEditCycleData.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/usePrepareSaveEditCycleData.tsx @@ -1,8 +1,4 @@ import { BigNumber } from '@ethersproject/bignumber' -import { - ETHPayoutGroupedSplits, - ReservedTokensGroupedSplits, -} from 'models/splits' import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { @@ -10,6 +6,10 @@ import { V2V3FundingCycleData, V2V3FundingCycleMetadata, } from 'packages/v2v3/models/fundingCycle' +import { + ETHPayoutGroupedSplits, + ReservedTokensGroupedSplits, +} from 'packages/v2v3/models/splits' import { getV2V3CurrencyOption } from 'packages/v2v3/utils/currency' import { MAX_DISTRIBUTION_LIMIT, diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditNftsPage/LaunchNftCollection/hooks/useLaunchNftsForm.ts b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditNftsPage/LaunchNftCollection/hooks/useLaunchNftsForm.ts index c0ab706e54..d2b9d00777 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditNftsPage/LaunchNftCollection/hooks/useLaunchNftsForm.ts +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditNftsPage/LaunchNftCollection/hooks/useLaunchNftsForm.ts @@ -5,6 +5,7 @@ import { EditingFundingCycleConfig, useEditingFundingCycleConfig, } from 'packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useEditingFundingCycleConfig' +import { useReconfigureFundingCycle } from 'packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useReconfigureFundingCycle' import { useState } from 'react' import { useAppSelector } from 'redux/hooks/useAppSelector' import { @@ -17,7 +18,6 @@ import { pinNftCollectionMetadata, pinNftRewards, } from 'utils/nftRewards' -import { useReconfigureFundingCycle } from '../../../../hooks/useReconfigureFundingCycle' export const useLaunchNftsForm = () => { const [form] = useForm() diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/PayoutsSettingsPage/PayoutsSettingsPage.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/PayoutsSettingsPage/PayoutsSettingsPage.tsx index c827227c67..9c21fe57e5 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/PayoutsSettingsPage/PayoutsSettingsPage.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/PayoutsSettingsPage/PayoutsSettingsPage.tsx @@ -1,9 +1,9 @@ import { Trans } from '@lingui/macro' import { Button } from 'antd' -import { Split } from 'models/splits' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { useSetProjectSplits } from 'packages/v2v3/hooks/transactor/useSetProjectSplitsTx' +import { Split } from 'packages/v2v3/models/splits' import { getTotalSplitsPercentage } from 'packages/v2v3/utils/distributions' import { useCallback, useContext, useMemo, useState } from 'react' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/PayoutsSettingsPage/V2V3EditPayouts.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/PayoutsSettingsPage/V2V3EditPayouts.tsx index b024d1cf08..0e19051f9b 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/PayoutsSettingsPage/V2V3EditPayouts.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/PayoutsSettingsPage/V2V3EditPayouts.tsx @@ -1,7 +1,7 @@ import { t, Trans } from '@lingui/macro' import { CsvUpload } from 'components/inputs/CsvUpload' -import { Split } from 'models/splits' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' +import { Split } from 'packages/v2v3/models/splits' import { getTotalSplitsPercentage } from 'packages/v2v3/utils/distributions' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -18,12 +18,12 @@ import { OwnerPayoutCard } from 'packages/v2v3/components/shared/PayoutCard/Owne import { PayoutCard } from 'packages/v2v3/components/shared/PayoutCard/PayoutCard' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' import { isInfiniteDistributionLimit } from 'packages/v2v3/utils/fundingCycle' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' import { twMerge } from 'tailwind-merge' import { parseV2SplitsCsv } from 'utils/csv' import { formatFundingTarget } from 'utils/format/formatFundingTarget' import { formatPercent } from 'utils/format/formatPercent' import { settingsPagePath } from 'utils/routes' -import { allocationToSplit, splitToAllocation } from 'utils/splitToAllocation' export const V2V3EditPayouts = ({ editingSplits, diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/ReservedTokensSettingsPage/ReservedTokensSettingsPage.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/ReservedTokensSettingsPage/ReservedTokensSettingsPage.tsx index bd1eebe01b..ef62450bf9 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/ReservedTokensSettingsPage/ReservedTokensSettingsPage.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/ReservedTokensSettingsPage/ReservedTokensSettingsPage.tsx @@ -1,9 +1,9 @@ import { Trans } from '@lingui/macro' import { Button } from 'antd' import { RESERVED_TOKEN_SPLIT_GROUP } from 'constants/splits' -import { Split } from 'models/splits' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { useSetProjectSplits } from 'packages/v2v3/hooks/transactor/useSetProjectSplitsTx' +import { Split } from 'packages/v2v3/models/splits' import { preciseFormatSplitPercent } from 'packages/v2v3/utils/math' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { emitErrorNotification } from 'utils/notifications' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/ReservedTokensSettingsPage/V2V3EditReservedTokens.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/ReservedTokensSettingsPage/V2V3EditReservedTokens.tsx index 5440c75aeb..1209a124ea 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/ReservedTokensSettingsPage/V2V3EditReservedTokens.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/ReservedTokensSettingsPage/V2V3EditReservedTokens.tsx @@ -1,12 +1,12 @@ import { Trans } from '@lingui/macro' import { Callout } from 'components/Callout/Callout' import { CsvUpload } from 'components/inputs/CsvUpload' -import { Split } from 'models/splits' import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' import { ReservedTokensList } from 'packages/v2v3/components/shared/ReservedTokensList' +import { Split } from 'packages/v2v3/models/splits' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' import { useCallback } from 'react' import { parseV2SplitsCsv } from 'utils/csv' -import { allocationToSplit, splitToAllocation } from 'utils/splitToAllocation' export function V2V3EditReservedTokens({ editingReservedTokensSplits, diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/ExportSplitsButton.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/ExportSplitsButton.tsx index ade123c870..6f67f1fca1 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/ExportSplitsButton.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/ExportSplitsButton.tsx @@ -4,13 +4,13 @@ import { Button } from 'antd' import { ETH_PAYOUT_SPLIT_GROUP } from 'constants/splits' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { BigNumber } from 'ethers' -import { GroupedSplits, Split, SplitGroup } from 'models/splits' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' +import { GroupedSplits, Split, SplitGroup } from 'packages/v2v3/models/splits' import { formatSplitPercent } from 'packages/v2v3/utils/math' +import { getProjectOwnerRemainderSplit } from 'packages/v2v3/utils/v2v3Splits' import { PropsWithChildren, useContext, useState } from 'react' import { downloadCsvFile } from 'utils/csv' import { emitErrorNotification } from 'utils/notifications' -import { getProjectOwnerRemainderSplit } from 'utils/splits' const CSV_HEADER = [ 'beneficiary', diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/V2V3ProjectToolsDrawer.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/V2V3ProjectToolsDrawer.tsx index 05bfed3444..1b728af9f6 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/V2V3ProjectToolsDrawer.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/V2V3ProjectToolsDrawer.tsx @@ -8,11 +8,11 @@ import { } from 'constants/splits' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import useMobile from 'hooks/useMobile' -import { ETHPayoutSplitGroup, ReservedTokensSplitGroup } from 'models/splits' import Link from 'next/link' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { useAddToBalanceTx } from 'packages/v2v3/hooks/transactor/AddToBalanceTx' import { useDeployProjectPayerTx } from 'packages/v2v3/hooks/transactor/useDeployProjectPayerTx' +import { ETHPayoutSplitGroup, ReservedTokensSplitGroup } from 'packages/v2v3/models/splits' import { useContext } from 'react' import { v2v3ProjectRoute } from 'utils/routes' import { ExportSplitsButton } from './ExportSplitsButton' diff --git a/src/packages/v2v3/components/shared/Allocation/AddEditAllocationModal.tsx b/src/packages/v2v3/components/shared/Allocation/AddEditAllocationModal.tsx index a913db5e07..66e279d275 100644 --- a/src/packages/v2v3/components/shared/Allocation/AddEditAllocationModal.tsx +++ b/src/packages/v2v3/components/shared/Allocation/AddEditAllocationModal.tsx @@ -1,14 +1,16 @@ import { t, Trans } from '@lingui/macro' import { Form, Modal, Radio } from 'antd' -import { FeeTooltipLabel } from 'components/FeeTooltipLabel' +import { AmountPercentageInput } from 'components/Allocation/types' import { EthAddressInput } from 'components/inputs/EthAddressInput' import { JuiceDatePicker } from 'components/inputs/JuiceDatePicker' import { JuiceInputNumber } from 'components/inputs/JuiceInputNumber' import { LOCKED_PAYOUT_EXPLANATION } from 'components/strings' import { BigNumber } from 'ethers' import moment, * as Moment from 'moment' +import { FeeTooltipLabel } from 'packages/v2v3/components/shared/FeeTooltipLabel' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { isInfiniteDistributionLimit } from 'packages/v2v3/utils/fundingCycle' +import { projectIdToHex } from 'packages/v2v3/utils/v2v3Splits' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { allocationInputAlreadyExistsRule, @@ -18,12 +20,10 @@ import { } from 'utils/antdRules' import { hexToInt, parseWad, stripCommas } from 'utils/format/formatNumber' import { ceilIfCloseToNextInteger } from 'utils/math' -import { projectIdToHex } from 'utils/splits' import { Allocation } from './Allocation' import { allocationId } from './AllocationList' import { AmountInput } from './components/AmountInput' import { PercentageInput } from './components/PercentageInput' -import { AmountPercentageInput } from './types' interface AddEditAllocationModalFormProps { juiceboxProjectId?: string | undefined diff --git a/src/packages/v2v3/components/shared/Allocation/Allocation.tsx b/src/packages/v2v3/components/shared/Allocation/Allocation.tsx index 1311548d88..f8df7277a7 100644 --- a/src/packages/v2v3/components/shared/Allocation/Allocation.tsx +++ b/src/packages/v2v3/components/shared/Allocation/Allocation.tsx @@ -1,9 +1,9 @@ +import { AllocationItem } from 'components/Allocation/AllocationItem' import { BigNumber } from 'ethers' import { FormItemInput } from 'models/formItemInput' -import { Split } from 'models/splits' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' +import { Split } from 'packages/v2v3/models/splits' import { createContext, useContext } from 'react' -import { AllocationItem } from './AllocationItem' import { AllocationList } from './AllocationList' import { useAllocation } from './hooks/useAllocation' diff --git a/src/packages/v2v3/components/shared/Allocation/AllocationList.tsx b/src/packages/v2v3/components/shared/Allocation/AllocationList.tsx index 0ae3e42b60..597c68960f 100644 --- a/src/packages/v2v3/components/shared/Allocation/AllocationList.tsx +++ b/src/packages/v2v3/components/shared/Allocation/AllocationList.tsx @@ -5,11 +5,11 @@ import { BigNumber } from 'ethers' import { useModal } from 'hooks/useModal' import { amountFromPercent } from 'packages/v2v3/utils/distributions' import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' +import { projectIdToHex } from 'packages/v2v3/utils/v2v3Splits' import { ReactNode, useCallback, useState } from 'react' import { twMerge } from 'tailwind-merge' import { fromWad, parseWad } from 'utils/format/formatNumber' import { roundIfCloseToNextInteger } from 'utils/math' -import { projectIdToHex } from 'utils/splits' import { AddEditAllocationModal, AddEditAllocationModalEntity, diff --git a/src/packages/v2v3/components/shared/Allocation/components/AllocationItemTitle.tsx b/src/packages/v2v3/components/shared/Allocation/components/AllocationItemTitle.tsx index 1ad359e934..1603b3e43c 100644 --- a/src/packages/v2v3/components/shared/Allocation/components/AllocationItemTitle.tsx +++ b/src/packages/v2v3/components/shared/Allocation/components/AllocationItemTitle.tsx @@ -3,8 +3,8 @@ import { t } from '@lingui/macro' import { Tooltip } from 'antd' import EthereumAddress from 'components/EthereumAddress' import V2V3ProjectHandleLink from 'packages/v2v3/components/shared/V2V3ProjectHandleLink' +import { isProjectSplit } from 'packages/v2v3/utils/v2v3Splits' import { formatDate } from 'utils/format/formatDate' -import { isProjectSplit } from 'utils/splits' import { AllocationSplit } from '../Allocation' export function AllocationItemTitle({ diff --git a/src/packages/v2v3/components/shared/Allocation/components/AmountInput.tsx b/src/packages/v2v3/components/shared/Allocation/components/AmountInput.tsx index 430b287823..d4984317e6 100644 --- a/src/packages/v2v3/components/shared/Allocation/components/AmountInput.tsx +++ b/src/packages/v2v3/components/shared/Allocation/components/AmountInput.tsx @@ -1,3 +1,4 @@ +import { AmountPercentageInput } from 'components/Allocation/types' import CurrencySwitch from 'components/currency/CurrencySwitch' import FormattedNumberInput from 'components/inputs/FormattedNumberInput' import { @@ -6,7 +7,6 @@ import { } from 'packages/v2v3/utils/currency' import { useCallback, useState } from 'react' import { Allocation } from '../Allocation' -import { AmountPercentageInput } from '../types' export const AmountInput = ({ value, diff --git a/src/packages/v2v3/components/shared/Allocation/components/PercentageInput.tsx b/src/packages/v2v3/components/shared/Allocation/components/PercentageInput.tsx index d92570d4ce..d144d45e6d 100644 --- a/src/packages/v2v3/components/shared/Allocation/components/PercentageInput.tsx +++ b/src/packages/v2v3/components/shared/Allocation/components/PercentageInput.tsx @@ -1,3 +1,4 @@ +import { AmountPercentageInput } from 'components/Allocation/types' import CurrencySymbol from 'components/currency/CurrencySymbol' import NumberSlider from 'components/inputs/NumberSlider' import round from 'lodash/round' @@ -6,7 +7,6 @@ import { isFiniteDistributionLimit } from 'packages/v2v3/utils/fundingCycle' import { useCallback, useMemo, useState } from 'react' import { formatWad, stripCommas } from 'utils/format/formatNumber' import { Allocation } from '../Allocation' -import { AmountPercentageInput } from '../types' export const PercentageInput = ({ value, diff --git a/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitFields/DiffedJBProjectBeneficiary.tsx b/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitFields/DiffedJBProjectBeneficiary.tsx index e517bd11ed..a35b46c54d 100644 --- a/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitFields/DiffedJBProjectBeneficiary.tsx +++ b/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitFields/DiffedJBProjectBeneficiary.tsx @@ -1,5 +1,5 @@ import EthereumAddress from 'components/EthereumAddress' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { DiffedItem } from '../../DiffedItem' import { JuiceboxProjectBeneficiary } from '../../SplitItem/JuiceboxProjectBeneficiary' diff --git a/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitFields/DiffedSplitValue.tsx b/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitFields/DiffedSplitValue.tsx index 49c3723b05..d81a2d4d80 100644 --- a/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitFields/DiffedSplitValue.tsx +++ b/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitFields/DiffedSplitValue.tsx @@ -1,11 +1,11 @@ import { BigNumber } from 'ethers' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { isFiniteDistributionLimit, isInfiniteDistributionLimit, } from 'packages/v2v3/utils/fundingCycle' import { formatSplitPercent } from 'packages/v2v3/utils/math' -import { splitAmountsAreEqual } from 'utils/splits' +import { splitAmountsAreEqual } from 'packages/v2v3/utils/v2v3Splits' import { DiffedItem } from '../../DiffedItem' import { SplitProps } from '../../SplitItem' import { SplitAmountValue } from '../../SplitItem/SplitAmountValue' diff --git a/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitItem.tsx b/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitItem.tsx index f5ecc06d2b..59185189ec 100644 --- a/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitItem.tsx +++ b/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitItem.tsx @@ -5,8 +5,8 @@ import { DiffPlus, } from 'packages/v2v3/components/shared/DiffedItem' import { isJuiceboxProjectSplit } from 'packages/v2v3/utils/distributions' +import { SplitWithDiff } from 'packages/v2v3/utils/v2v3Splits' import { twMerge } from 'tailwind-merge' -import { SplitWithDiff } from 'utils/splits' import { SplitProps } from '../SplitItem' import { ETHAddressBeneficiary } from '../SplitItem/EthAddressBeneficiary' import { ReservedTokensValue } from '../SplitItem/ReservedTokensValue' diff --git a/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitList.tsx b/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitList.tsx index 3ee39b6983..ddb94ba041 100644 --- a/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitList.tsx +++ b/src/packages/v2v3/components/shared/DiffedSplits/DiffedSplitList.tsx @@ -1,13 +1,13 @@ import { BigNumber } from 'ethers' import round from 'lodash/round' -import { Split } from 'models/splits' import { useProjectContext } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectContext' +import { Split } from 'packages/v2v3/models/splits' import { formatSplitPercent } from 'packages/v2v3/utils/math' -import { useMemo } from 'react' import { getProjectOwnerRemainderSplit, processUniqueSplits, -} from 'utils/splits' +} from 'packages/v2v3/utils/v2v3Splits' +import { useMemo } from 'react' import { SplitProps } from '../SplitItem' import { DiffedSplitItem } from './DiffedSplitItem' diff --git a/src/components/FeeTooltipLabel.tsx b/src/packages/v2v3/components/shared/FeeTooltipLabel.tsx similarity index 87% rename from src/components/FeeTooltipLabel.tsx rename to src/packages/v2v3/components/shared/FeeTooltipLabel.tsx index ce47f92e4f..56134f3b71 100644 --- a/src/components/FeeTooltipLabel.tsx +++ b/src/packages/v2v3/components/shared/FeeTooltipLabel.tsx @@ -1,14 +1,14 @@ import { Trans } from '@lingui/macro' +import ExternalLink from 'components/ExternalLink' +import TooltipLabel from 'components/TooltipLabel' +import CurrencySymbol from 'components/currency/CurrencySymbol' +import ETHAmount from 'components/currency/ETHAmount' import { BigNumber } from 'ethers' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' import { V2V3_CURRENCY_ETH } from 'packages/v2v3/utils/currency' import { amountSubFee, formatFee } from 'packages/v2v3/utils/math' import { formatWad } from 'utils/format/formatNumber' import { helpPagePath } from 'utils/routes' -import ExternalLink from './ExternalLink' -import TooltipLabel from './TooltipLabel' -import CurrencySymbol from './currency/CurrencySymbol' -import ETHAmount from './currency/ETHAmount' export const FeeTooltipLabel = ({ currency, diff --git a/src/components/Create/components/pages/PayoutsPage/components/ConvertAmountsModal.tsx b/src/packages/v2v3/components/shared/PayoutsTable/ConvertAmountsModal.tsx similarity index 95% rename from src/components/Create/components/pages/PayoutsPage/components/ConvertAmountsModal.tsx rename to src/packages/v2v3/components/shared/PayoutsTable/ConvertAmountsModal.tsx index 37b82e035c..6f1eb9f189 100644 --- a/src/components/Create/components/pages/PayoutsPage/components/ConvertAmountsModal.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/ConvertAmountsModal.tsx @@ -2,12 +2,12 @@ import { t, Trans } from '@lingui/macro' import { Divider, Modal } from 'antd' import CurrencySwitch from 'components/currency/CurrencySwitch' import EthereumAddress from 'components/EthereumAddress' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' import FormattedNumberInput from 'components/inputs/FormattedNumberInput' import { Parenthesis } from 'components/Parenthesis' -import { Split } from 'models/splits' import V2V3ProjectHandleLink from 'packages/v2v3/components/shared/V2V3ProjectHandleLink' -import { ExternalLinkWithIcon } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' +import { Split } from 'packages/v2v3/models/splits' import { V2V3_CURRENCY_ETH, V2V3_CURRENCY_USD, @@ -18,6 +18,8 @@ import { } from 'packages/v2v3/utils/distributions' import { formatCurrencyAmount } from 'packages/v2v3/utils/formatCurrencyAmount' import { SPLITS_TOTAL_PERCENT } from 'packages/v2v3/utils/math' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' +import { isProjectSplit } from 'packages/v2v3/utils/v2v3Splits' import { ReactNode, useCallback, useMemo, useState } from 'react' import { ReduxDistributionLimit, @@ -26,8 +28,6 @@ import { import { parseWad } from 'utils/format/formatNumber' import { formatPercent } from 'utils/format/formatPercent' import { helpPagePath } from 'utils/routes' -import { isProjectSplit } from 'utils/splits' -import { allocationToSplit, splitToAllocation } from 'utils/splitToAllocation' export const ConvertAmountsModal = ({ open, diff --git a/src/packages/v2v3/components/shared/PayoutsTable/HeaderRows.tsx b/src/packages/v2v3/components/shared/PayoutsTable/HeaderRows.tsx index b5c6e70a65..4b3e50e921 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/HeaderRows.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/HeaderRows.tsx @@ -1,17 +1,17 @@ import { PlusOutlined } from '@ant-design/icons' import { Trans } from '@lingui/macro' import { Button } from 'antd' -import { ExternalLinkWithIcon } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/components/ui/ExternalLinkWithIcon' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' +import { PayoutsTableCell } from 'components/PayoutsTable/PayoutsTableCell' import { AddEditAllocationModal, AddEditAllocationModalEntity, } from 'packages/v2v3/components/shared/Allocation/AddEditAllocationModal' import { useState } from 'react' import { helpPagePath } from 'utils/routes' -import { PayoutTableSettings } from './PayoutTableSettings' -import { PayoutsTableCell } from './PayoutsTableCell' import { usePayoutsTableContext } from './context/PayoutsTableContext' import { usePayoutsTable } from './hooks/usePayoutsTable' +import { PayoutTableSettings } from './PayoutTableSettings' export function HeaderRows() { const [addRecipientModalOpen, setAddRecipientModalOpen] = useState() diff --git a/src/packages/v2v3/components/shared/PayoutsTable/PayoutSplitRow.tsx b/src/packages/v2v3/components/shared/PayoutsTable/PayoutSplitRow.tsx index 9c7864c1e2..be17157b54 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/PayoutSplitRow.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/PayoutSplitRow.tsx @@ -1,15 +1,15 @@ +import { PayoutsTableCell } from 'components/PayoutsTable/PayoutsTableCell' +import { PayoutsTableRow } from 'components/PayoutsTable/PayoutsTableRow' import FormattedNumberInput from 'components/inputs/FormattedNumberInput' import round from 'lodash/round' -import { Split } from 'models/splits' import { AddEditAllocationModal, AddEditAllocationModalEntity, } from 'packages/v2v3/components/shared/Allocation/AddEditAllocationModal' +import { Split } from 'packages/v2v3/models/splits' import { useState } from 'react' import { PayoutSplitRowMenu } from './PayoutSplitRowMenu' import { PayoutTitle } from './PayoutTitle' -import { PayoutsTableCell } from './PayoutsTableCell' -import { PayoutsTableRow } from './PayoutsTableRow' import { usePayoutsTableContext } from './context/PayoutsTableContext' import { usePayoutsTable } from './hooks/usePayoutsTable' diff --git a/src/packages/v2v3/components/shared/PayoutsTable/PayoutTableSettings.tsx b/src/packages/v2v3/components/shared/PayoutsTable/PayoutTableSettings.tsx index cf89385e0b..91f21f5d1d 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/PayoutTableSettings.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/PayoutTableSettings.tsx @@ -1,13 +1,13 @@ import { ReceiptPercentIcon, TrashIcon } from '@heroicons/react/24/outline' import { Trans } from '@lingui/macro' -import { ConvertAmountsModal } from 'components/Create/components/pages/PayoutsPage/components/ConvertAmountsModal' +import { SwitchToUnlimitedModal } from 'components/PayoutsTable/SwitchToUnlimitedModal' import { PopupMenu, PopupMenuItem } from 'components/ui/PopupMenu' -import { handleConfirmationDeletion } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/utils/modals' +import { handleConfirmationDeletion } from 'hooks/emitConfirmationDeletionModal' +import { ConvertAmountsModal } from 'packages/v2v3/components/shared/PayoutsTable/ConvertAmountsModal' import { useState } from 'react' import { ReduxDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' import { fromWad } from 'utils/format/formatNumber' import { usePayoutsTable } from './hooks/usePayoutsTable' -import { SwitchToUnlimitedModal } from './modals/SwitchToUnlimitedModal' export const payoutsTableMenuItemsLabelClass = 'flex gap-2 items-center text-sm' export const payoutsTableMenuItemsIconClass = 'h-5 w-5' diff --git a/src/packages/v2v3/components/shared/PayoutsTable/PayoutTitle.tsx b/src/packages/v2v3/components/shared/PayoutsTable/PayoutTitle.tsx index 64813d2c96..0f705ee5eb 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/PayoutTitle.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/PayoutTitle.tsx @@ -2,8 +2,8 @@ import { LockFilled } from '@ant-design/icons' import { t } from '@lingui/macro' import { Tooltip } from 'antd' import EthereumAddress from 'components/EthereumAddress' -import { Split } from 'models/splits' import V2V3ProjectHandleLink from 'packages/v2v3/components/shared/V2V3ProjectHandleLink' +import { Split } from 'packages/v2v3/models/splits' import { formatDate } from 'utils/format/formatDate' import { usePayoutsTableContext } from './context/PayoutsTableContext' diff --git a/src/packages/v2v3/components/shared/PayoutsTable/PayoutsTableBody.tsx b/src/packages/v2v3/components/shared/PayoutsTable/PayoutsTableBody.tsx index 15fb2a8c92..d9a80f6916 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/PayoutsTableBody.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/PayoutsTableBody.tsx @@ -1,13 +1,13 @@ import { Trans } from '@lingui/macro' import { Form } from 'antd' +import { PayoutsTableCell } from 'components/PayoutsTable/PayoutsTableCell' +import { PayoutsTableRow } from 'components/PayoutsTable/PayoutsTableRow' import { Allocation } from 'packages/v2v3/components/shared/Allocation/Allocation' import { getV2V3CurrencyOption } from 'packages/v2v3/utils/currency' import { twMerge } from 'tailwind-merge' import { CurrencySwitcher } from './CurrencySwitcher' import { HeaderRows } from './HeaderRows' import { PayoutSplitRow } from './PayoutSplitRow' -import { PayoutsTableCell } from './PayoutsTableCell' -import { PayoutsTableRow } from './PayoutsTableRow' import { TotalRows } from './TotalRows' import { usePayoutsTableContext } from './context/PayoutsTableContext' import { usePayoutsTable } from './hooks/usePayoutsTable' diff --git a/src/packages/v2v3/components/shared/PayoutsTable/TotalRows.tsx b/src/packages/v2v3/components/shared/PayoutsTable/TotalRows.tsx index bf985126ee..e9f454053d 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/TotalRows.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/TotalRows.tsx @@ -1,11 +1,11 @@ import { Trans, t } from '@lingui/macro' import { Tooltip } from 'antd' import EthereumAddress from 'components/EthereumAddress' +import { PayoutsTableCell } from 'components/PayoutsTable/PayoutsTableCell' +import { PayoutsTableRow } from 'components/PayoutsTable/PayoutsTableRow' import TooltipLabel from 'components/TooltipLabel' import round from 'lodash/round' import { useProjectContext } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectContext' -import { PayoutsTableCell } from './PayoutsTableCell' -import { PayoutsTableRow } from './PayoutsTableRow' import { usePayoutsTable } from './hooks/usePayoutsTable' const Row = PayoutsTableRow diff --git a/src/packages/v2v3/components/shared/PayoutsTable/context/PayoutsTableContext.tsx b/src/packages/v2v3/components/shared/PayoutsTable/context/PayoutsTableContext.tsx index 6e03ff46ff..3ee6000eba 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/context/PayoutsTableContext.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/context/PayoutsTableContext.tsx @@ -1,5 +1,5 @@ import { CurrencyName } from 'constants/currency' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { ReactNode, createContext, useContext } from 'react' export interface PayoutsTableContextProps { diff --git a/src/packages/v2v3/components/shared/PayoutsTable/hooks/usePayoutsTable.tsx b/src/packages/v2v3/components/shared/PayoutsTable/hooks/usePayoutsTable.tsx index a883dcc578..801b4ddacb 100644 --- a/src/packages/v2v3/components/shared/PayoutsTable/hooks/usePayoutsTable.tsx +++ b/src/packages/v2v3/components/shared/PayoutsTable/hooks/usePayoutsTable.tsx @@ -2,9 +2,9 @@ import { NULL_ALLOCATOR_ADDRESS } from 'constants/contracts/mainnet/Allocators' import { ONE_BILLION, WAD_DECIMALS } from 'constants/numbers' import isEqual from 'lodash/isEqual' import round from 'lodash/round' -import { Split } from 'models/splits' import { AddEditAllocationModalEntity } from 'packages/v2v3/components/shared/Allocation/AddEditAllocationModal' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' +import { Split } from 'packages/v2v3/models/splits' import { V2V3CurrencyName, V2V3_CURRENCY_METADATA, @@ -23,14 +23,14 @@ import { MAX_DISTRIBUTION_LIMIT, SPLITS_TOTAL_PERCENT, } from 'packages/v2v3/utils/math' -import { useMemo } from 'react' -import { parseWad } from 'utils/format/formatNumber' import { getProjectOwnerRemainderSplit, hasEqualRecipient, isProjectSplit, totalSplitsPercent, -} from 'utils/splits' +} from 'packages/v2v3/utils/v2v3Splits' +import { useMemo } from 'react' +import { parseWad } from 'utils/format/formatNumber' import { useEditCycleFormContext } from '../../../V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/EditCycleFormContext' import { usePayoutsTableContext } from '../context/PayoutsTableContext' diff --git a/src/packages/v2v3/components/shared/ReservedTokensList.tsx b/src/packages/v2v3/components/shared/ReservedTokensList.tsx index 61b4430ec5..cd0a301880 100644 --- a/src/packages/v2v3/components/shared/ReservedTokensList.tsx +++ b/src/packages/v2v3/components/shared/ReservedTokensList.tsx @@ -8,10 +8,10 @@ import { } from 'packages/v2v3/components/shared/Allocation/Allocation' import { AllocationItemTitle } from 'packages/v2v3/components/shared/Allocation/components/AllocationItemTitle' import { OwnerPayoutCard } from 'packages/v2v3/components/shared/PayoutCard/OwnerPayoutCard' +import { totalSplitsPercent } from 'packages/v2v3/utils/v2v3Splits' import { useMemo } from 'react' import { formatPercent } from 'utils/format/formatPercent' import { ceilIfCloseToNextInteger } from 'utils/math' -import { totalSplitsPercent } from 'utils/splits' export const ReservedTokensList: React.FC< React.PropsWithChildren< diff --git a/src/packages/v2v3/components/shared/SplitItem/JuiceboxProjectBeneficiary.tsx b/src/packages/v2v3/components/shared/SplitItem/JuiceboxProjectBeneficiary.tsx index bce98c2e78..a98eebc246 100644 --- a/src/packages/v2v3/components/shared/SplitItem/JuiceboxProjectBeneficiary.tsx +++ b/src/packages/v2v3/components/shared/SplitItem/JuiceboxProjectBeneficiary.tsx @@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro' import { Tooltip } from 'antd' import { AllocatorBadge } from 'components/AllocatorBadge' import { NULL_ALLOCATOR_ADDRESS } from 'constants/contracts/mainnet/Allocators' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import V2V3ProjectHandleLink from '../V2V3ProjectHandleLink' export function JuiceboxProjectBeneficiary({ diff --git a/src/packages/v2v3/components/shared/SplitItem/SplitAmountValue.tsx b/src/packages/v2v3/components/shared/SplitItem/SplitAmountValue.tsx index 0959a9fbf2..ce6669c3b4 100644 --- a/src/packages/v2v3/components/shared/SplitItem/SplitAmountValue.tsx +++ b/src/packages/v2v3/components/shared/SplitItem/SplitAmountValue.tsx @@ -9,9 +9,10 @@ import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectCo import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' import { V2V3CurrencyName } from 'packages/v2v3/utils/currency' import { isJuiceboxProjectSplit } from 'packages/v2v3/utils/distributions' -import { feeForAmount, SPLITS_TOTAL_PERCENT } from 'packages/v2v3/utils/math' +import { SPLITS_TOTAL_PERCENT } from 'packages/v2v3/utils/math' import { useContext } from 'react' import { formatWad } from 'utils/format/formatNumber' +import { feeForAmount } from 'utils/math' import { SplitProps } from './SplitItem' export function SplitAmountValue({ @@ -29,9 +30,10 @@ export function SplitAmountValue({ const isJuiceboxProject = isJuiceboxProjectSplit(props.split) const hasFee = !isJuiceboxProject && !props.dontApplyFeeToAmount - const feeAmount = hasFee - ? feeForAmount(splitValue, primaryETHTerminalFee) ?? BigNumber.from(0) - : BigNumber.from(0) + const _feeAmount = hasFee + ? feeForAmount(splitValue?.toBigInt(), primaryETHTerminalFee?.toBigInt()) ?? 0n + : 0n + const feeAmount = BigNumber.from(_feeAmount) const valueAfterFees = splitValue ? splitValue.sub(feeAmount) : 0 const currencyName = V2V3CurrencyName( diff --git a/src/packages/v2v3/components/shared/SplitItem/SplitItem.tsx b/src/packages/v2v3/components/shared/SplitItem/SplitItem.tsx index df3bd0be04..43bc15a78f 100644 --- a/src/packages/v2v3/components/shared/SplitItem/SplitItem.tsx +++ b/src/packages/v2v3/components/shared/SplitItem/SplitItem.tsx @@ -1,6 +1,6 @@ import { BigNumber } from 'ethers' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { isJuiceboxProjectSplit } from 'packages/v2v3/utils/distributions' import { ETHAddressBeneficiary } from './EthAddressBeneficiary' diff --git a/src/packages/v2v3/components/shared/SplitList.tsx b/src/packages/v2v3/components/shared/SplitList.tsx index 4d4d54bc6a..cd94b179be 100644 --- a/src/packages/v2v3/components/shared/SplitList.tsx +++ b/src/packages/v2v3/components/shared/SplitList.tsx @@ -1,9 +1,22 @@ import { BigNumber } from 'ethers' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' +import { getProjectOwnerRemainderSplit, sortSplits } from 'packages/v2v3/utils/v2v3Splits' import { useMemo } from 'react' -import { getProjectOwnerRemainderSplit, sortSplits } from 'utils/splits' import { SplitItem, SplitProps } from './SplitItem' +export type V2V3SplitListProps = { + splits: Split[] + currency?: BigNumber + totalValue: BigNumber | undefined + projectOwnerAddress: string | undefined + showAmounts?: boolean + showFees?: boolean + valueSuffix?: string | JSX.Element + valueFormatProps?: { precision?: number } + reservedRate?: number + dontApplyFeeToAmounts?: boolean +} + export default function SplitList({ splits, showAmounts = false, @@ -15,18 +28,7 @@ export default function SplitList({ valueFormatProps, reservedRate, dontApplyFeeToAmounts, -}: { - splits: Split[] - currency?: BigNumber - totalValue: BigNumber | undefined - projectOwnerAddress: string | undefined - showAmounts?: boolean - showFees?: boolean - valueSuffix?: string | JSX.Element - valueFormatProps?: { precision?: number } - reservedRate?: number - dontApplyFeeToAmounts?: boolean -}) { +}: V2V3SplitListProps) { const ownerSplit = useMemo(() => { if (!projectOwnerAddress) return return getProjectOwnerRemainderSplit(projectOwnerAddress, splits) diff --git a/src/packages/v2v3/contexts/Project/V2V3ProjectContext.ts b/src/packages/v2v3/contexts/Project/V2V3ProjectContext.ts index db9719bf4c..146897f735 100644 --- a/src/packages/v2v3/contexts/Project/V2V3ProjectContext.ts +++ b/src/packages/v2v3/contexts/Project/V2V3ProjectContext.ts @@ -1,10 +1,10 @@ import { BigNumber } from 'ethers' import { V2BallotState } from 'models/ballot' -import { Split } from 'models/splits' import { V2V3FundingCycle, V2V3FundingCycleMetadata, } from 'packages/v2v3/models/fundingCycle' +import { Split } from 'packages/v2v3/models/splits' import { createContext } from 'react' interface V2V3ProjectLoadingStates { diff --git a/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts b/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts index 0f658b1d0d..c28f7ef2e0 100644 --- a/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts +++ b/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts @@ -15,7 +15,6 @@ import { JB_721_TIER_PARAMS_V3_2, JB_DEPLOY_TIERED_721_DELEGATE_DATA_V3_1, } from 'models/nftRewards' -import { GroupedSplits, SplitGroup } from 'models/splits' import { V2V3ContractsContext } from 'packages/v2v3/contexts/Contracts/V2V3ContractsContext' import { useJBPrices } from 'packages/v2v3/hooks/JBPrices' import { DEFAULT_JB_721_DELEGATE_VERSION } from 'packages/v2v3/hooks/defaultContracts/useDefaultJB721Delegate' @@ -29,6 +28,7 @@ import { V2V3FundAccessConstraint, V2V3FundingCycleData, } from 'packages/v2v3/models/fundingCycle' +import { GroupedSplits, SplitGroup } from 'packages/v2v3/models/splits' import { getTerminalsFromFundAccessConstraints, isValidMustStartAtOrAfter, diff --git a/src/packages/v2v3/hooks/JB721Delegate/transactor/useReconfigureV2V3FundingCycleWithNftsTx.ts b/src/packages/v2v3/hooks/JB721Delegate/transactor/useReconfigureV2V3FundingCycleWithNftsTx.ts index f8ff95402d..de53a7fbdd 100644 --- a/src/packages/v2v3/hooks/JB721Delegate/transactor/useReconfigureV2V3FundingCycleWithNftsTx.ts +++ b/src/packages/v2v3/hooks/JB721Delegate/transactor/useReconfigureV2V3FundingCycleWithNftsTx.ts @@ -8,7 +8,6 @@ import { JBDeployTiered721DelegateData, JB_DEPLOY_TIERED_721_DELEGATE_DATA_V3_1, } from 'models/nftRewards' -import { GroupedSplits, SplitGroup } from 'models/splits' import { V2V3ContractsContext } from 'packages/v2v3/contexts/Contracts/V2V3ContractsContext' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' @@ -21,6 +20,7 @@ import { V2V3FundAccessConstraint, V2V3FundingCycleData, } from 'packages/v2v3/models/fundingCycle' +import { GroupedSplits, SplitGroup } from 'packages/v2v3/models/splits' import { V2V3_CURRENCY_ETH } from 'packages/v2v3/utils/currency' import { isValidMustStartAtOrAfter } from 'packages/v2v3/utils/fundingCycle' import { useContext } from 'react' diff --git a/src/packages/v2v3/hooks/contractReader/useProjectSplits.ts b/src/packages/v2v3/hooks/contractReader/useProjectSplits.ts index 7dae81151f..28430dfb00 100644 --- a/src/packages/v2v3/hooks/contractReader/useProjectSplits.ts +++ b/src/packages/v2v3/hooks/contractReader/useProjectSplits.ts @@ -1,7 +1,7 @@ import { BigNumber } from 'ethers' import isEqual from 'lodash/isEqual' -import { Split, SplitGroup } from 'models/splits' import { V2V3ContractName } from 'packages/v2v3/models/contracts' +import { Split, SplitGroup } from 'packages/v2v3/models/splits' import { useCallback } from 'react' diff --git a/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts b/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts index 773f825089..4a427fc026 100644 --- a/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts +++ b/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts @@ -4,7 +4,6 @@ import { DEFAULT_MEMO } from 'constants/transactionDefaults' import { TransactionContext } from 'contexts/Transaction/TransactionContext' import { useWallet } from 'hooks/Wallet' import { TransactorInstance } from 'hooks/useTransactor' -import { GroupedSplits, SplitGroup } from 'models/splits' import { V2V3ContractsContext } from 'packages/v2v3/contexts/Contracts/V2V3ContractsContext' import { useDefaultJBController } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBController' import { useDefaultJBETHPaymentTerminal } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBETHPaymentTerminal' @@ -13,6 +12,7 @@ import { V2V3FundingCycleData, V2V3FundingCycleMetadata, } from 'packages/v2v3/models/fundingCycle' +import { GroupedSplits, SplitGroup } from 'packages/v2v3/models/splits' import { getTerminalsFromFundAccessConstraints, isValidMustStartAtOrAfter, diff --git a/src/packages/v2v3/hooks/transactor/useSetProjectSplitsTx.ts b/src/packages/v2v3/hooks/transactor/useSetProjectSplitsTx.ts index f9d3e8af8d..6a4112002e 100644 --- a/src/packages/v2v3/hooks/transactor/useSetProjectSplitsTx.ts +++ b/src/packages/v2v3/hooks/transactor/useSetProjectSplitsTx.ts @@ -2,10 +2,10 @@ import { t } from '@lingui/macro' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { TransactionContext } from 'contexts/Transaction/TransactionContext' import { TransactorInstance } from 'hooks/useTransactor' -import { GroupedSplits } from 'models/splits' import { V2V3ContractsContext } from 'packages/v2v3/contexts/Contracts/V2V3ContractsContext' +import { GroupedSplits } from 'packages/v2v3/models/splits' +import { sanitizeSplit } from 'packages/v2v3/utils/v2v3Splits' import { useContext } from 'react' -import { sanitizeSplit } from 'utils/splits' import { useV2ProjectTitle } from '../useProjectTitle' export const useSetProjectSplits = ({ diff --git a/src/models/splits.ts b/src/packages/v2v3/models/splits.ts similarity index 100% rename from src/models/splits.ts rename to src/packages/v2v3/models/splits.ts diff --git a/src/packages/v2v3/utils/__tests__/distributions.test.ts b/src/packages/v2v3/utils/__tests__/distributions.test.ts index c155143ec3..1f517a12ae 100644 --- a/src/packages/v2v3/utils/__tests__/distributions.test.ts +++ b/src/packages/v2v3/utils/__tests__/distributions.test.ts @@ -1,4 +1,4 @@ -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { amountFromPercent, deriveAmountAfterFee, diff --git a/src/packages/v2v3/utils/distributions.ts b/src/packages/v2v3/utils/distributions.ts index 0a31b10ead..f39a1168ec 100644 --- a/src/packages/v2v3/utils/distributions.ts +++ b/src/packages/v2v3/utils/distributions.ts @@ -1,9 +1,9 @@ import { BigNumber } from 'ethers' -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { ONE_BILLION } from 'constants/numbers' +import { isProjectSplit } from 'packages/v2v3/utils/v2v3Splits' import { fromWad, parseWad } from 'utils/format/formatNumber' -import { isProjectSplit } from 'utils/splits' import { isInfiniteDistributionLimit } from './fundingCycle' import { MAX_DISTRIBUTION_LIMIT, diff --git a/src/packages/v2v3/utils/math.ts b/src/packages/v2v3/utils/math.ts index c029855f97..0eda137b51 100644 --- a/src/packages/v2v3/utils/math.ts +++ b/src/packages/v2v3/utils/math.ts @@ -20,7 +20,7 @@ import { fromWad, percentToPermyriad, } from 'utils/format/formatNumber' -import { WeightFunction } from 'utils/math' +import { feeForAmount, WeightFunction } from 'utils/math' export const MAX_RESERVED_RATE = TEN_THOUSAND export const MAX_REDEMPTION_RATE = TEN_THOUSAND @@ -233,20 +233,12 @@ export const weightAmountPermyriad: WeightFunction = ( ) } -export const feeForAmount = ( - amountWad: BigNumber | undefined, - feePerBillion: BigNumber | undefined, -): BigNumber | undefined => { - if (!feePerBillion || !amountWad) return - return amountWad.mul(feePerBillion).div(ONE_BILLION) -} - export const amountSubFee = ( amountWad: BigNumber | undefined, feePerBillion: BigNumber | undefined, ): BigNumber | undefined => { if (!feePerBillion || !amountWad) return - const feeAmount = feeForAmount(amountWad, feePerBillion) ?? 0 + const feeAmount = feeForAmount(amountWad.toBigInt(), feePerBillion.toBigInt()) ?? 0 return amountWad.sub(feeAmount) } @@ -255,7 +247,7 @@ export const amountSubFee = ( // and return the sum of them all export function sumHeldFees(fees: JBFee[]) { return fees.reduce((sum, heldFee) => { - const amountWad = feeForAmount(heldFee.amount, BigNumber.from(heldFee.fee)) + const amountWad = feeForAmount(heldFee.amount.toBigInt(), BigInt(heldFee.fee)) const amountNum = parseFloat(fromWad(amountWad)) return sum + (amountNum ?? 0) }, 0) diff --git a/src/utils/splitToAllocation.ts b/src/packages/v2v3/utils/splitToAllocation.ts similarity index 83% rename from src/utils/splitToAllocation.ts rename to src/packages/v2v3/utils/splitToAllocation.ts index 4b6734e9c2..b6cfc0e014 100644 --- a/src/utils/splitToAllocation.ts +++ b/src/packages/v2v3/utils/splitToAllocation.ts @@ -1,10 +1,10 @@ -import { defaultSplit, Split } from 'models/splits' import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' +import { defaultSplit, Split } from 'packages/v2v3/models/splits' import { preciseFormatSplitPercent, splitPercentFrom, } from 'packages/v2v3/utils/math' -import { sanitizeSplit } from 'utils/splits' +import { sanitizeSplit } from 'packages/v2v3/utils/v2v3Splits' export const splitToAllocation = (split: Split): AllocationSplit => { return { diff --git a/src/utils/splits.ts b/src/packages/v2v3/utils/v2v3Splits.ts similarity index 98% rename from src/utils/splits.ts rename to src/packages/v2v3/utils/v2v3Splits.ts index 54aab5d8f1..f58025e0ac 100644 --- a/src/utils/splits.ts +++ b/src/packages/v2v3/utils/v2v3Splits.ts @@ -1,9 +1,9 @@ import { BigNumber, constants } from 'ethers' import isEqual from 'lodash/isEqual' -import { Split, SplitParams } from 'models/splits' import { isFiniteDistributionLimit } from 'packages/v2v3/utils/fundingCycle' import { SPLITS_TOTAL_PERCENT } from 'packages/v2v3/utils/math' -import { formatWad } from './format/formatNumber' +import { formatWad } from '../../../utils/format/formatNumber' +import { Split, SplitParams } from '../models/splits' // - true if the split has been removed (exists in old but not new), // - false if new (exists in new but not old) diff --git a/src/packages/v4/components/ActivityList/ActivitiyList.tsx b/src/packages/v4/components/ActivityList/ActivitiyList.tsx deleted file mode 100644 index 9c33a90ed9..0000000000 --- a/src/packages/v4/components/ActivityList/ActivitiyList.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import EthereumAddress from 'components/EthereumAddress' -import EtherscanLink from 'components/EtherscanLink' -import { formatDistance } from 'date-fns' -import { Ether, JBProjectToken } from 'juice-sdk-core' -import { useJBContractContext, useJBTokenContext } from 'juice-sdk-react' -import { - OrderDirection, - PayEvent_OrderBy, - PayEventsDocument, - PayEventsQuery, -} from 'packages/v4/graphql/client/graphql' -import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' -import { Address } from 'viem' - -type PayEvent = { - id: string - amount: Ether - beneficiary: Address - beneficiaryTokenCount?: JBProjectToken - timestamp: number - txHash: string -} - -function ActivityItem(ev: PayEvent) { - const { token } = useJBTokenContext() - if (!token?.data) return null - - const formattedDate = formatDistance(ev.timestamp * 1000, new Date(), { - addSuffix: true, - }) - - return ( -
-
- -
- bought {ev.beneficiaryTokenCount?.format(6)} {token.data.symbol} -
-
-
- Paid {ev.amount.format(6)} ETH •{' '} - - {formattedDate} - -
-
- ) -} - -function transformPayEventsRes( - data: PayEventsQuery | undefined, -): PayEvent[] | undefined { - return data?.payEvents.map(event => { - return { - id: event.id, - amount: new Ether(BigInt(event.amount)), - beneficiary: event.beneficiary, - beneficiaryTokenCount: new JBProjectToken( - // BigInt(0) - BigInt(event.beneficiaryTokenCount), - ), - timestamp: event.timestamp, - txHash: event.txHash, - } - }) -} - -export function ActivityList() { - const { projectId } = useJBContractContext() - const { data } = useSubgraphQuery(PayEventsDocument, { - orderBy: PayEvent_OrderBy.timestamp, - orderDirection: OrderDirection.desc, - where: { - // pv: PV2, - projectId: Number(projectId), - }, - }) - - const payEvents = transformPayEventsRes(data) - - return ( -
-
Activity
-
- {payEvents && payEvents.length > 0 ? ( - payEvents?.map(event => { - return - }) - ) : ( - No activity yet. - )} -
-
- ) -} diff --git a/src/packages/v4/components/Allocation/AddEditAllocationModal.tsx b/src/packages/v4/components/Allocation/AddEditAllocationModal.tsx new file mode 100644 index 0000000000..05ec3ccd79 --- /dev/null +++ b/src/packages/v4/components/Allocation/AddEditAllocationModal.tsx @@ -0,0 +1,373 @@ +import { t, Trans } from '@lingui/macro' +import { Form, Modal, Radio } from 'antd' +import { AmountPercentageInput } from 'components/Allocation/types' +import { EthAddressInput } from 'components/inputs/EthAddressInput' +import { JuiceDatePicker } from 'components/inputs/JuiceDatePicker' +import { JuiceInputNumber } from 'components/inputs/JuiceInputNumber' +import { LOCKED_PAYOUT_EXPLANATION } from 'components/strings' +import { BigNumber } from 'ethers' +import { useReadJbMultiTerminalFee } from 'juice-sdk-react' +import moment, * as Moment from 'moment' +import { isInfinitePayoutLimit } from 'packages/v4/utils/fundingCycle' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + allocationInputAlreadyExistsRule, + inputIsIntegerRule, + inputMustBeEthAddressRule, + inputMustExistRule, +} from 'utils/antdRules' +import { hexToInt, stripCommas } from 'utils/format/formatNumber' +import { ceilIfCloseToNextInteger } from 'utils/math' +import { Hash } from 'viem' +import { FeeTooltipLabel } from '../FeeTooltipLabel' +import { Allocation } from './Allocation' +import { AmountInput } from './components/AmountInput' +import { PercentageInput } from './components/PercentageInput' + + +export const allocationId = ( + beneficiary: string, + projectId: string | undefined, +) => { + const hasProjectId = Boolean(projectId && projectId !== '0') + return `${beneficiary}${hasProjectId ? `-${projectId}` : ''}` +} + +interface AddEditAllocationModalFormProps { + juiceboxProjectId?: string | undefined + address?: Hash | undefined + amount?: AmountPercentageInput | undefined + lockedUntil?: moment.Moment | null | undefined +} + +export type AddEditAllocationModalEntity = + | { + projectOwner: false + beneficiary: Hash | undefined + projectId: string | undefined + amount: AmountPercentageInput + lockedUntil: number | undefined + previousId?: string + } + | { + projectOwner: true + amount: string + } + +export const AddEditAllocationModal = ({ + className, + allocationName, + editingData, + availableModes, + open, + onOk, + onCancel, + hideProjectOwnerOption, + hideFee, +}: { + className?: string + allocationName: string + editingData?: AddEditAllocationModalEntity | undefined + availableModes: Set<'amount' | 'percentage'> + open?: boolean + onOk: (split: AddEditAllocationModalEntity) => void + onCancel: VoidFunction + hideProjectOwnerOption?: boolean + hideFee?: boolean +}) => { + const { data: primaryNativeTerminalFee } = useReadJbMultiTerminalFee() + + const { totalAllocationAmount, allocations, allocationCurrency } = + Allocation.useAllocationInstance() + const [form] = Form.useForm() + const [amountType, setAmountType] = useState<'amount' | 'percentage'>() + const [recipient, setRecipient] = useState< + 'walletAddress' | 'juiceboxProject' | 'projectOwner' + >('walletAddress') + + const amount = Form.useWatch('amount', form) + + const showFee = + amountType === 'amount' && recipient === 'walletAddress' && !hideFee + + const isValidJuiceboxProject = useMemo( + () => + !editingData?.projectOwner && + editingData?.projectId && + editingData.projectId !== BigNumber.from(0).toHexString(), + [editingData], + ) + + const totalAllocationPercent = allocations + .map(a => a.percent) + .reduce((acc, curr) => acc + curr.toFloat(), 0) + + const hasInfiniteTotalAllocationAmount: boolean = useMemo( + () => + Boolean( + totalAllocationAmount && + isInfinitePayoutLimit(totalAllocationAmount), + ), + [totalAllocationAmount], + ) + + const isEditing = !!editingData + + useEffect(() => { + setAmountType(() => { + if (availableModes.has('amount') && !hasInfiniteTotalAllocationAmount) { + return 'amount' + } + return 'percentage' + }) + }, [availableModes, hasInfiniteTotalAllocationAmount]) + + useEffect(() => { + if (!open) return + + if (!editingData) { + setRecipient('walletAddress') + return + } + + if (editingData.projectOwner) { + setRecipient('projectOwner') + return + } + + setRecipient(isValidJuiceboxProject ? 'juiceboxProject' : 'walletAddress') + + setTimeout(() => { + form.setFieldsValue({ + juiceboxProjectId: isValidJuiceboxProject + ? hexToInt(editingData?.projectId).toString() + : undefined, + address: editingData.beneficiary, + amount: editingData.amount, + lockedUntil: editingData.lockedUntil + ? Moment.default(editingData.lockedUntil * 1000) + : undefined, + }) + }, 0) + }, [editingData, form, open, totalAllocationAmount, isValidJuiceboxProject]) + + const onModalOk = useCallback(async () => { + const fields = await form.validateFields() + if (!fields.amount) throw new Error('Missing amount') + let result: AddEditAllocationModalEntity + if (recipient === 'projectOwner') { + result = { projectOwner: true, amount: fields.amount.value } + } else { + const hasEditingBeneficiary = editingData && !editingData?.projectOwner + result = { + projectOwner: false, + beneficiary: fields.address, + projectId: fields.juiceboxProjectId, + amount: fields.amount, + lockedUntil: fields.lockedUntil + ? Math.round(fields.lockedUntil.valueOf() / 1000) + : undefined, + previousId: hasEditingBeneficiary + ? allocationId( + editingData?.beneficiary ?? '', + fields.juiceboxProjectId, + ) + : undefined, + } + } + onOk(result) + form.resetFields() + }, [form, onOk, recipient, editingData]) + + const onModalCancel = useCallback(() => { + onCancel() + form.resetFields() + }, [form, onCancel]) + + const addressLabel = + recipient === 'juiceboxProject' + ? t`Project token beneficiary address` + : t`Address` + const addressExtra = + recipient === 'juiceboxProject' ? ( + + Paying another Juicebox project may mint its tokens. Select an address + to receive these tokens. + + ) : undefined + + const showProjectOwnerRecipientOption = + amountType !== 'percentage' && + (!allocations.length || + ceilIfCloseToNextInteger(totalAllocationPercent) === 100) && + !hideProjectOwnerOption + + const projectId = Form.useWatch('juiceboxProjectId', form) + + const titleCasedAllocationName = useMemo( + () => + allocationName + .toLowerCase() + .split(' ') + .map(s => s.charAt(0).toUpperCase() + s.slice(1), '') + .join(' '), + [allocationName], + ) + + if (availableModes.size === 0) { + console.error('AddEditAllocationModal: no available modes') + return null + } + + return ( + + {isEditing ? t`Edit ${allocationName}` : t`Add new ${allocationName}`} + + } + okText={isEditing ? t`Save ${allocationName}` : t`Add ${allocationName}`} + open={open} + onOk={onModalOk} + onCancel={onModalCancel} + destroyOnClose + > +
+ {availableModes.size > 1 && ( + setAmountType(e.target.value)} + > + + Amounts + + + Percentages + + + )} + + setRecipient(e.target.value)} + > + + Wallet Address + + + Juicebox Project + + {showProjectOwnerRecipientOption && ( + + Project Owner + + )} + + + + {recipient === 'juiceboxProject' && ( + + + + )} + {recipient !== 'projectOwner' && ( + ({ + beneficiary, + projectId: projectId?.toString(), + })) + .filter( + ( + a, + ): a is { + beneficiary: Hash + projectId: string + } => !!a.beneficiary, + ), + inputProjectId: projectId, + editingAddressBeneficiary: !editingData?.projectOwner + ? editingData?.beneficiary + : undefined, + }), + ]} + > + + + )} + + + ) + } + rules={[ + inputMustExistRule({ + label: + amountType === 'amount' + ? t`${titleCasedAllocationName} Amount` + : t`${titleCasedAllocationName} Percentage`, + }), + ]} + > + {amountType === 'percentage' ? : } + + {recipient !== 'projectOwner' && ( + + current < moment().endOf('day')} + /> + + )} +
+
+ ) +} diff --git a/src/packages/v4/components/Allocation/Allocation.tsx b/src/packages/v4/components/Allocation/Allocation.tsx new file mode 100644 index 0000000000..243ba8b1a0 --- /dev/null +++ b/src/packages/v4/components/Allocation/Allocation.tsx @@ -0,0 +1,88 @@ +import { JBSplit as Split } from 'juice-sdk-core' +import { FormItemInput } from 'models/formItemInput' +import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption' +import { createContext, useContext } from 'react' +import { AllocationItem } from './AllocationItem' +import { useAllocation } from './hooks/useAllocation' + +export type AllocationSplit = Split & { id: string } + +const DEFAULT_SET_CURRENCY_FN = () => { + console.error('AllocationContext.setCurrency called but no provider set') +} + +const AllocationContext = createContext<{ + allocations: AllocationSplit[] + totalAllocationAmount?: bigint + setTotalAllocationAmount?: (total: bigint) => void + allocationCurrency?: V4CurrencyOption + addAllocation: (allocation: AllocationSplit) => void + removeAllocation: (id: string) => void + upsertAllocation: (allocation: AllocationSplit) => void + setAllocations: (allocations: AllocationSplit[]) => void + setCurrency: (currency: V4CurrencyOption) => void +}>({ + allocations: [], + addAllocation: () => { + console.error('AllocationContext.addAllocation called but no provider set') + }, + removeAllocation: () => { + console.error( + 'AllocationContext.removeAllocation called but no provider set', + ) + }, + upsertAllocation: () => { + console.error( + 'AllocationContext.upsertAllocation called but no provider set', + ) + }, + setAllocations: () => { + console.error('AllocationContext.setAllocations called but no provider set') + }, + setCurrency: DEFAULT_SET_CURRENCY_FN, +}) + +const useAllocationInstance = () => { + return useContext(AllocationContext) +} + +interface AllocationProps { + totalAllocationAmount?: bigint + setTotalAllocationAmount?: (total: bigint) => void + allocationCurrency?: V4CurrencyOption + setAllocationCurrency?: (currency: V4CurrencyOption) => void +} + +export const Allocation: React.FC< + React.PropsWithChildren> +> & { + Item: typeof AllocationItem + useAllocationInstance: typeof useAllocationInstance +} = ({ + totalAllocationAmount, + setTotalAllocationAmount, + allocationCurrency, + value, + onChange, + setAllocationCurrency, + children, +}) => { + const allocationHook = useAllocation({ value, onChange }) + + return ( + + {children} + + ) +} + +Allocation.useAllocationInstance = useAllocationInstance +Allocation.Item = AllocationItem diff --git a/src/packages/v4/components/Allocation/AllocationItem.tsx b/src/packages/v4/components/Allocation/AllocationItem.tsx new file mode 100644 index 0000000000..267e5be548 --- /dev/null +++ b/src/packages/v4/components/Allocation/AllocationItem.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from 'react' +import { twMerge } from 'tailwind-merge' + +export const AllocationItem = ({ + className, + title, + amount, + extra, + onClick, +}: { + className?: string + title: ReactNode + amount: ReactNode + extra?: ReactNode + onClick?: VoidFunction +}) => { + const isClickable = !!onClick + + return ( +
+
{title}
+
+ {extra} +
+
+ {amount} +
+
+ ) +} diff --git a/src/packages/v4/components/Allocation/components/AmountInput.tsx b/src/packages/v4/components/Allocation/components/AmountInput.tsx new file mode 100644 index 0000000000..128f251760 --- /dev/null +++ b/src/packages/v4/components/Allocation/components/AmountInput.tsx @@ -0,0 +1,51 @@ +import CurrencySwitch from 'components/currency/CurrencySwitch' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' + +import { AmountPercentageInput } from 'components/Allocation/types' +import { V4_CURRENCY_ETH, V4_CURRENCY_USD } from 'packages/v4/utils/currency' +import { useCallback, useState } from 'react' +import { Allocation } from '../Allocation' + +export const AmountInput = ({ + value, + onChange, +}: { + value?: AmountPercentageInput + onChange?: (input: AmountPercentageInput | undefined) => void +}) => { + const [_amount, _setAmount] = useState({ value: '' }) + const amount = value ?? _amount + const setAmount = onChange ?? _setAmount + + const { allocationCurrency, setCurrency } = Allocation.useAllocationInstance() + const currency = allocationCurrency ?? V4_CURRENCY_ETH + + const onAmountInputChange = useCallback( + (amount: AmountPercentageInput | undefined) => { + if (amount && !isNaN(parseFloat(amount.value))) { + setAmount(amount) + return + } + }, + [setAmount], + ) + + return ( +
+ onAmountInputChange(val ? { value: val } : undefined)} + accessory={ + + setCurrency(c === 'ETH' ? V4_CURRENCY_ETH : V4_CURRENCY_USD) + } + className="rounded" + /> + } + /> +
+ ) +} diff --git a/src/packages/v4/components/Allocation/components/PercentageInput.tsx b/src/packages/v4/components/Allocation/components/PercentageInput.tsx new file mode 100644 index 0000000000..de00133742 --- /dev/null +++ b/src/packages/v4/components/Allocation/components/PercentageInput.tsx @@ -0,0 +1,78 @@ +import { AmountPercentageInput } from 'components/Allocation/types' +import CurrencySymbol from 'components/currency/CurrencySymbol' +import NumberSlider from 'components/inputs/NumberSlider' +import round from 'lodash/round' +import { V4CurrencyName } from 'packages/v4/utils/currency' +import { isFinitePayoutLimit } from 'packages/v4/utils/fundingCycle' +import { useCallback, useMemo, useState } from 'react' +import { formatWad, stripCommas } from 'utils/format/formatNumber' +import { Allocation } from '../Allocation' + +export const PercentageInput = ({ + value, + onChange, +}: { + value?: AmountPercentageInput + onChange?: (input: AmountPercentageInput | undefined) => void +}) => { + const [_percentage, _setPercentage] = useState< + AmountPercentageInput | undefined + >({ + value: '', + isPercent: true, + }) + + const { totalAllocationAmount, allocationCurrency } = + Allocation.useAllocationInstance() + + const hasTotalAllocationAmount = useMemo( + () => isFinitePayoutLimit(totalAllocationAmount), + [totalAllocationAmount], + ) + + const percentage = value ?? _percentage + const setPercentage = onChange ?? _setPercentage + + const onAmountInputChange = useCallback( + (percentage: AmountPercentageInput | undefined) => { + setPercentage(percentage) + return + }, + [setPercentage], + ) + + const totalAllocationAmountNum = parseFloat( + stripCommas(formatWad(totalAllocationAmount) ?? '0'), + ) + const currencyName = V4CurrencyName(allocationCurrency) + const roundedAmount = round( + (percentage ? parseFloat(percentage.value) / 100 : 0) * + totalAllocationAmountNum, + currencyName === 'ETH' ? 4 : 2, + ) + return ( +
+
+ + onAmountInputChange({ + value: percentage?.toString() ?? '', + isPercent: true, + }) + } + step={0.01} + defaultValue={0} + suffix="%" + /> +
+ {/* Read-only amount if distribution limit is not infinite */} + {hasTotalAllocationAmount ? ( +
+ + {roundedAmount} +
+ ) : null} +
+ ) +} diff --git a/src/packages/v4/components/Allocation/hooks/useAllocation.ts b/src/packages/v4/components/Allocation/hooks/useAllocation.ts new file mode 100644 index 0000000000..f1ab54e763 --- /dev/null +++ b/src/packages/v4/components/Allocation/hooks/useAllocation.ts @@ -0,0 +1,24 @@ +import { useArray } from 'hooks/useArray' +import { FormItemInput } from 'models/formItemInput' +import { AllocationSplit } from '../Allocation' + +export const useAllocation = ({ + value, + onChange, +}: FormItemInput) => { + const { + values: allocations, + add: addAllocation, + remove: removeAllocation, + upsert: upsertAllocation, + set: setAllocations, + } = useArray([value, onChange]) + + return { + allocations, + addAllocation, + removeAllocation, + upsertAllocation, + setAllocations, + } +} diff --git a/src/packages/v4/components/FeeTooltipLabel.tsx b/src/packages/v4/components/FeeTooltipLabel.tsx new file mode 100644 index 0000000000..317edc5d1f --- /dev/null +++ b/src/packages/v4/components/FeeTooltipLabel.tsx @@ -0,0 +1,52 @@ +import { Trans } from '@lingui/macro' +import ExternalLink from 'components/ExternalLink' +import TooltipLabel from 'components/TooltipLabel' +import CurrencySymbol from 'components/currency/CurrencySymbol' +import { Ether } from 'juice-sdk-core' +import { NativeTokenValue } from 'juice-sdk-react' +import { formatWad } from 'utils/format/formatNumber' +import { helpPagePath } from 'utils/routes' +import { V4CurrencyOption } from '../models/v4CurrencyOption' +import { V4_CURRENCY_ETH } from '../utils/currency' +import { amountSubFee } from '../utils/math' + +export const FeeTooltipLabel = ({ + currency, + amount, + feePerBillion, +}: { + currency: V4CurrencyOption + amount: bigint | undefined + feePerBillion: bigint | undefined +}) => { + if (!amount || !currency || !feePerBillion) return null + const amountSubFeeValue = amountSubFee(amount, feePerBillion) ?? 0n + const feePercentage = new Ether(feePerBillion).format() + return ( + + {currency === V4_CURRENCY_ETH ? ( + + ) : ( + <> + + {formatWad(amountSubFeeValue, { precision: 4 })} + + )}{' '} + after {feePercentage}% JBX membership fee + + } + tip={ + + Payouts to Ethereum addresses incur a {feePercentage}% fee. Your + project will receive JBX in return.{' '} + + Learn more + + . + + } + /> + ) +} diff --git a/src/packages/v4/components/PayoutsTable/ConvertAmountsModal.tsx b/src/packages/v4/components/PayoutsTable/ConvertAmountsModal.tsx new file mode 100644 index 0000000000..e7ed6b0a6a --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/ConvertAmountsModal.tsx @@ -0,0 +1,190 @@ +import { t, Trans } from '@lingui/macro' +import { Divider, Modal } from 'antd' +import CurrencySwitch from 'components/currency/CurrencySwitch' +import EthereumAddress from 'components/EthereumAddress' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import { Parenthesis } from 'components/Parenthesis' +import { JBSplit as Split, SPLITS_TOTAL_PERCENT } from 'juice-sdk-core' +import { useRouter } from 'next/router' + +import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption' +import { V4_CURRENCY_ETH, V4_CURRENCY_USD } from 'packages/v4/utils/currency' +import { + deriveAmountAfterFee, + derivePayoutAmount, +} from 'packages/v4/utils/distributions' +import { formatCurrencyAmount } from 'packages/v4/utils/formatCurrencyAmount' +import { allocationToSplit, splitToAllocation } from 'packages/v4/utils/splitToAllocation' +import { isJuiceboxProjectSplit } from 'packages/v4/utils/v4Splits' +import { ReactNode, useCallback, useMemo, useState } from 'react' +import { + ReduxDistributionLimit, + useEditingDistributionLimit, +} from 'redux/hooks/useEditingDistributionLimit' +import { parseWad } from 'utils/format/formatNumber' +import { formatPercent } from 'utils/format/formatPercent' +import { helpPagePath } from 'utils/routes' +import V4ProjectHandleLink from '../V4ProjectHandleLink' + +export const ConvertAmountsModal = ({ + open, + onOk, + onCancel, + splits, +}: { + open: boolean + onOk: (d: ReduxDistributionLimit) => void + onCancel: VoidFunction + splits: Split[] +}) => { + const [distributionLimit] = useEditingDistributionLimit() + const [newDistributionLimit, setNewDistributionLimit] = useState('') + const [currency, setCurrency] = useState( + distributionLimit?.currency ?? V4_CURRENCY_ETH, + ) + + const router = useRouter() + const { chainName } = router.query + + const totalPayoutsPercent = useMemo( + () => + splits + .map(s => (s.percent.toFloat() / SPLITS_TOTAL_PERCENT) * 100) + .reduce((acc, curr) => acc + curr, 0), + [splits], + ) + + const ownerPercent = useMemo( + () => Math.max(0, 100 - totalPayoutsPercent), + [totalPayoutsPercent], + ) + + const onModalOk = useCallback(() => { + onOk({ + amount: parseWad(parseFloat(newDistributionLimit)), + currency, + }) + }, [currency, newDistributionLimit, onOk]) + + const hasOwnerPayout = ownerPercent > 0 + + const okButtonDisabled = !newDistributionLimit.length + + return ( + + Switch to limited payouts + + } + open={open} + okButtonProps={{ disabled: okButtonDisabled }} + onOk={onModalOk} + onCancel={onCancel} + okText={t`Convert to amounts`} + > +
+ + To switch to 'Limited' payouts, enter a total amount to pay out of the + treasury to split between your recipients. + {' '} + + Learn more about payout limits + +
+ + +
+ setNewDistributionLimit(val ? val : '')} + accessory={ + + setCurrency(c === 'ETH' ? V4_CURRENCY_ETH : V4_CURRENCY_USD) + } + /> + } + /> +
+ +
+ + Current payouts + + {hasOwnerPayout ? ( + + {newDistributionLimit && + formatCurrencyAmount({ + amount: deriveAmountAfterFee( + (ownerPercent / 100) * parseFloat(newDistributionLimit), + ), + currency, + })}{' '} + {formatPercent(ownerPercent)} + + } + /> + ) : null} + {splits.map(split => { + const allocation = splitToAllocation(split) + return ( + + {isJuiceboxProjectSplit(split) && allocation.projectId ? ( + + ) : ( + + )} + + } + amountLabel={ + <> + {newDistributionLimit && + formatCurrencyAmount({ + amount: derivePayoutAmount({ + payoutSplit: allocationToSplit(allocation), + distributionLimit: parseFloat(newDistributionLimit), + }), + currency, + })}{' '} + {allocation.percent.formatPercentage()}% + + } + /> + ) + })} +
+
+ ) +} + +const PayoutChangeSplitInfo = ({ + descriptionLabel, + amountLabel, +}: { + descriptionLabel: ReactNode + amountLabel: ReactNode +}) => { + return ( + <> + +
+ {descriptionLabel} + {amountLabel} +
+ + ) +} diff --git a/src/packages/v4/components/PayoutsTable/CurrencySwitcher.tsx b/src/packages/v4/components/PayoutsTable/CurrencySwitcher.tsx new file mode 100644 index 0000000000..44b3c643c6 --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/CurrencySwitcher.tsx @@ -0,0 +1,90 @@ +import { ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline' +import { Trans } from '@lingui/macro' +import { PopupMenu } from 'components/ui/PopupMenu' +import { useCurrencyConverter } from 'hooks/useCurrencyConverter' +import round from 'lodash/round' + +import { V4_CURRENCY_ETH, V4_CURRENCY_USD } from 'packages/v4/utils/currency' +import { fromWad, parseWad } from 'utils/format/formatNumber' +import { + payoutsTableMenuItemsIconClass, + payoutsTableMenuItemsLabelClass, +} from './PayoutTableSettings' +import { usePayoutsTableContext } from './context/PayoutsTableContext' +import { usePayoutsTable } from './hooks/usePayoutsTable' + +export function CurrencySwitcher() { + const { setCurrency: setCurrencyName } = usePayoutsTableContext() + const { currency, setCurrency, distributionLimit, setDistributionLimit } = + usePayoutsTable() + const converter = useCurrencyConverter() + + const button = ( +
+ {currency === 'ETH' ? ( + Amount (ETH) + ) : ( + Amount (USD) + )} + {setCurrencyName ? : null} +
+ ) + + if (!setCurrencyName) { + return button + } + + const itemsClassName = `${payoutsTableMenuItemsLabelClass} text-primary` + const items = + currency === 'ETH' + ? [ + { + id: 'switchToUsd', + label: ( +
+ + Convert to USD +
+ ), + onClick: () => { + const usdAmount = converter.wadToCurrency( + parseWad(distributionLimit), + 'USD', + 'ETH', + ) + const formattedUsdAmount = round( + parseFloat(fromWad(usdAmount)), + 2, + ) + setDistributionLimit(formattedUsdAmount) + setCurrency(V4_CURRENCY_USD) + }, + }, + ] + : [ + { + id: 'switchToEth', + label: ( +
+ + Convert to ETH +
+ ), + onClick: () => { + const ethAmount = converter.wadToCurrency( + parseWad(distributionLimit), + 'ETH', + 'USD', + ) + const formattedEthAmount = round( + parseFloat(fromWad(ethAmount)), + 4, + ) + setDistributionLimit(formattedEthAmount) + setCurrency(V4_CURRENCY_ETH) + }, + }, + ] + + return +} diff --git a/src/packages/v4/components/PayoutsTable/HeaderRows.tsx b/src/packages/v4/components/PayoutsTable/HeaderRows.tsx new file mode 100644 index 0000000000..fc03affe96 --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/HeaderRows.tsx @@ -0,0 +1,87 @@ +import { PlusOutlined } from '@ant-design/icons' +import { Trans } from '@lingui/macro' +import { Button } from 'antd' +import { ExternalLinkWithIcon } from 'components/ExternalLinkWithIcon' + +import { PayoutsTableCell } from 'components/PayoutsTable/PayoutsTableCell' +import { useState } from 'react' +import { helpPagePath } from 'utils/routes' +import { AddEditAllocationModal, AddEditAllocationModalEntity } from '../Allocation/AddEditAllocationModal' +import { usePayoutsTableContext } from './context/PayoutsTableContext' +import { usePayoutsTable } from './hooks/usePayoutsTable' +import { PayoutTableSettings } from './PayoutTableSettings' + +export function HeaderRows() { + const [addRecipientModalOpen, setAddRecipientModalOpen] = useState() + + const { hideExplaination, hideSettings, addPayoutsDisabled } = + usePayoutsTableContext() + + const { distributionLimitIsInfinite, handleNewPayoutSplit } = + usePayoutsTable() + + const handleAddRecipientModalOk = ( + newSplit: AddEditAllocationModalEntity, + ) => { + if (newSplit.projectOwner) { + console.error( + 'Not supporting manually adding project owner splits in Edit cycle form', + ) + return + } + handleNewPayoutSplit({ newSplit }) + setAddRecipientModalOpen(false) + } + + return ( + <> + +
+
+ Payout recipients +
+ {hideExplaination ? null : ( +
+ + Juicebox provides trustless payroll capabilities to run + automated payouts completely on-chain.{' '} + + Learn more about payouts + + +
+ )} +
+
+
+ {addPayoutsDisabled ? null : ( + + )} + {hideSettings ? null : } +
+
+
+ setAddRecipientModalOpen(false)} + hideProjectOwnerOption + hideFee + /> + + ) +} diff --git a/src/packages/v4/components/PayoutsTable/PayoutSplitRow.tsx b/src/packages/v4/components/PayoutsTable/PayoutSplitRow.tsx new file mode 100644 index 0000000000..7deaac5aaa --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/PayoutSplitRow.tsx @@ -0,0 +1,149 @@ +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import { PayoutsTableCell } from 'components/PayoutsTable/PayoutsTableCell' +import { PayoutsTableRow } from 'components/PayoutsTable/PayoutsTableRow' +import { JBSplit as Split } from 'juice-sdk-core' +import round from 'lodash/round' +import { useState } from 'react' +import { AddEditAllocationModal, AddEditAllocationModalEntity } from '../Allocation/AddEditAllocationModal' +import { usePayoutsTableContext } from './context/PayoutsTableContext' +import { usePayoutsTable } from './hooks/usePayoutsTable' +import { PayoutSplitRowMenu } from './PayoutSplitRowMenu' +import { PayoutTitle } from './PayoutTitle' + +const Cell = PayoutsTableCell + +export function PayoutSplitRow({ + payoutSplit, + onDeleteClick, +}: { + payoutSplit: Split + onDeleteClick: VoidFunction +}) { + const [editModalOpen, setEditModalOpen] = useState(false) + const [ + amountPercentFieldHasEndingDecimal, + setAmountPercentFieldHasEndingDecimal, + ] = useState(false) + + const { setPayoutSplits } = usePayoutsTableContext() + const canEditSplits = Boolean(setPayoutSplits) + + const { + currencyOrPercentSymbol, + derivePayoutAmount, + formattedPayoutPercent, + roundingPrecision, + handlePayoutSplitChanged, + handlePayoutSplitAmountChanged, + distributionLimitIsInfinite, + } = usePayoutsTable() + const amount = derivePayoutAmount({ payoutSplit }) + const isPercent = distributionLimitIsInfinite + + let formattedAmountOrPercentage = isPercent + ? formattedPayoutPercent({ payoutSplitPercent: Number(payoutSplit.percent.value) }) + : round(amount, roundingPrecision).toString() + + if (!canEditSplits) { + formattedAmountOrPercentage = `${currencyOrPercentSymbol}${formattedAmountOrPercentage}` + } + + const onAmountPercentageInputChange = (val: string | undefined) => { + setAmountPercentFieldHasEndingDecimal(Boolean(val?.endsWith('.'))) + + const newAmount = parseFloat(val ?? '0') + if (isPercent) { + handlePayoutSplitChanged({ + editedPayoutSplit: payoutSplit, + newPayoutSplit: { + ...payoutSplit, + projectId: payoutSplit.projectId.toString(), + projectOwner: false, + amount: { + value: newAmount.toString(), + isPercent, + }, + }, + }) + } else { + handlePayoutSplitAmountChanged({ + editingPayoutSplit: payoutSplit, + newAmount, + }) + } + } + + const handleEditModalOk = (allocation: AddEditAllocationModalEntity) => { + if (allocation.projectOwner) { + console.error( + 'Not supporting manually adding project owner splits in Edit cycle form', + ) + return + } + handlePayoutSplitChanged({ + editedPayoutSplit: payoutSplit, + newPayoutSplit: allocation, + }) + setEditModalOpen(false) + } + + const addEditAllocationModalEntity = { + projectOwner: false, + beneficiary: payoutSplit.beneficiary, + projectId: payoutSplit.projectId.toString(), + amount: { + value: formattedAmountOrPercentage, + isPercent, + }, + lockedUntil: payoutSplit.lockedUntil, + } as AddEditAllocationModalEntity + + const _value = `${formattedAmountOrPercentage}${ + amountPercentFieldHasEndingDecimal ? '.' : '' + }` + + const paddingY = canEditSplits ? 'py-6' : 'py-3' + + return ( + <> + + + + + + {setPayoutSplits ? ( +
+ {currencyOrPercentSymbol} + } + accessoryPosition="left" + value={_value} + onChange={onAmountPercentageInputChange} + className="h-10 w-28 md:w-full" + /> + setEditModalOpen(true)} + onDeleteClick={onDeleteClick} + /> +
+ ) : ( + _value + )} +
+
+ setEditModalOpen(false)} + hideProjectOwnerOption + hideFee + /> + + ) +} diff --git a/src/packages/v4/components/PayoutsTable/PayoutSplitRowMenu.tsx b/src/packages/v4/components/PayoutsTable/PayoutSplitRowMenu.tsx new file mode 100644 index 0000000000..fa5e26610c --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/PayoutSplitRowMenu.tsx @@ -0,0 +1,39 @@ +import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline' +import { Trans } from '@lingui/macro' +import { PopupMenu } from 'components/ui/PopupMenu' + +export function PayoutSplitRowMenu({ + onEditClick, + onDeleteClick, +}: { + onEditClick: VoidFunction + onDeleteClick: VoidFunction +}) { + const menuItemsLabelClass = 'flex gap-2 items-center' + const menuItemsIconClass = 'h-5 w-5' + + const menuItems = [ + { + id: 'edit', + label: ( +
+ + Edit +
+ ), + onClick: onEditClick, + }, + { + id: 'delete', + label: ( +
+ + Delete +
+ ), + onClick: onDeleteClick, + }, + ] + + return +} diff --git a/src/packages/v4/components/PayoutsTable/PayoutTableSettings.tsx b/src/packages/v4/components/PayoutsTable/PayoutTableSettings.tsx new file mode 100644 index 0000000000..6789390223 --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/PayoutTableSettings.tsx @@ -0,0 +1,109 @@ +import { ReceiptPercentIcon, TrashIcon } from '@heroicons/react/24/outline' +import { Trans } from '@lingui/macro' +import { SwitchToUnlimitedModal } from 'components/PayoutsTable/SwitchToUnlimitedModal' +import { PopupMenu, PopupMenuItem } from 'components/ui/PopupMenu' +import { handleConfirmationDeletion } from 'hooks/emitConfirmationDeletionModal' +import { useState } from 'react' +import { ReduxDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' +import { fromWad } from 'utils/format/formatNumber' +import { ConvertAmountsModal } from './ConvertAmountsModal' +import { usePayoutsTable } from './hooks/usePayoutsTable' + +export const payoutsTableMenuItemsLabelClass = 'flex gap-2 items-center text-sm' +export const payoutsTableMenuItemsIconClass = 'h-5 w-5' + +export function PayoutTableSettings() { + const [switchToUnlimitedModalOpen, setSwitchToUnlimitedModalOpen] = + useState(false) + const [switchToLimitedModalOpen, setSwitchToLimitedModalOpen] = + useState(false) + + const { + payoutSplits, + distributionLimitIsInfinite, + handleDeleteAllPayoutSplits, + setDistributionLimit, + setCurrency, + setSplits100Percent, + } = usePayoutsTable() + + const handleSwitchToLimitedPayouts = (newLimit: ReduxDistributionLimit) => { + setDistributionLimit(parseFloat(fromWad(newLimit.amount))) + setCurrency(newLimit.currency) + setSwitchToLimitedModalOpen(false) + } + + const handleSwitchToUnlimitedPayouts = () => { + setDistributionLimit(undefined) + setSplits100Percent() + setSwitchToUnlimitedModalOpen(false) + } + + let menuItems: PopupMenuItem[] = [] + + if (distributionLimitIsInfinite) { + menuItems = [ + ...menuItems, + { + id: 'limited', + label: ( +
+ + Switch to limited +
+ ), + onClick: () => setSwitchToLimitedModalOpen(true), + }, + ] + } else { + menuItems = [ + ...menuItems, + { + id: 'unlimited', + label: ( +
+ + Switch to unlimited +
+ ), + onClick: () => setSwitchToUnlimitedModalOpen(true), + }, + ] + } + + if (payoutSplits.length > 0) { + menuItems = [ + ...menuItems, + { + id: 'delete', + label: ( +
+ + Delete all +
+ ), + onClick: handleConfirmationDeletion({ + type: 'all payout recipients', + onConfirm: handleDeleteAllPayoutSplits, + }), + }, + ] + } + + return ( + <> + + setSwitchToUnlimitedModalOpen(false)} + onOk={handleSwitchToUnlimitedPayouts} + /> + setSwitchToLimitedModalOpen(false)} + splits={payoutSplits} + /> + + ) +} diff --git a/src/packages/v4/components/PayoutsTable/PayoutTitle.tsx b/src/packages/v4/components/PayoutsTable/PayoutTitle.tsx new file mode 100644 index 0000000000..1c27df60b2 --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/PayoutTitle.tsx @@ -0,0 +1,46 @@ +import { LockFilled } from '@ant-design/icons' +import { t } from '@lingui/macro' +import { Tooltip } from 'antd' +import EthereumAddress from 'components/EthereumAddress' +import { JBSplit as Split } from 'juice-sdk-core' +import { useRouter } from 'next/router' +import { formatDate } from 'utils/format/formatDate' +import V4ProjectHandleLink from '../V4ProjectHandleLink' +import { usePayoutsTableContext } from './context/PayoutsTableContext' + +export function PayoutTitle({ payoutSplit }: { payoutSplit: Split }) { + const router = useRouter() + const { chainName } = router.query + + const { showAvatars } = usePayoutsTableContext() + + const isProject = + Boolean(payoutSplit.projectId) && payoutSplit.projectId !== 0n + + return ( +
+ {isProject ? ( + + ) : ( + + )} + {!!payoutSplit.lockedUntil && ( + + + + )} +
+ ) +} diff --git a/src/packages/v4/components/PayoutsTable/PayoutsTable.tsx b/src/packages/v4/components/PayoutsTable/PayoutsTable.tsx new file mode 100644 index 0000000000..e6a30722d9 --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/PayoutsTable.tsx @@ -0,0 +1,13 @@ +import { PayoutsTableBody } from './PayoutsTableBody' +import { + PayoutsTableContext, + PayoutsTableContextProps, +} from './context/PayoutsTableContext' + +export function PayoutsTable(props: PayoutsTableContextProps) { + return ( + + + + ) +} diff --git a/src/packages/v4/components/PayoutsTable/PayoutsTableBody.tsx b/src/packages/v4/components/PayoutsTable/PayoutsTableBody.tsx new file mode 100644 index 0000000000..8a5e02ef85 --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/PayoutsTableBody.tsx @@ -0,0 +1,89 @@ +import { Trans } from '@lingui/macro' +import { Form } from 'antd' +import { PayoutsTableCell } from 'components/PayoutsTable/PayoutsTableCell' +import { PayoutsTableRow } from 'components/PayoutsTable/PayoutsTableRow' +import { getV4CurrencyOption } from 'packages/v4/utils/currency' +import { twMerge } from 'tailwind-merge' +import { Allocation } from '../Allocation/Allocation' +import { CurrencySwitcher } from './CurrencySwitcher' +import { HeaderRows } from './HeaderRows' +import { PayoutSplitRow } from './PayoutSplitRow' +import { TotalRows } from './TotalRows' +import { usePayoutsTableContext } from './context/PayoutsTableContext' +import { usePayoutsTable } from './hooks/usePayoutsTable' + +const Row = PayoutsTableRow +const Cell = PayoutsTableCell + +export function PayoutsTableBody() { + const { topAccessory, hideHeader } = usePayoutsTableContext() + const { + payoutSplits, + currency, + handleDeletePayoutSplit, + setCurrency, + distributionLimit, + } = usePayoutsTable() + const emptyState = distributionLimit === 0 && !payoutSplits?.length + + const hasDistributionLimit = distributionLimit && distributionLimit > 0 + + return ( + <> + {topAccessory} +
+ +
+ {hideHeader ? null : } +
+ {emptyState ? ( + + + No payout recipients + + + ) : ( + <> + {/* `|| hasDistributionLimit` to account for old projects whose payout is only the "remaining project owner" split, but still have a distributionLimit. */} + {payoutSplits.length > 0 || hasDistributionLimit ? ( + + + Address or ID + + + + + + ) : null} + {payoutSplits.map((payoutSplit, index) => ( + + handleDeletePayoutSplit({ payoutSplit }) + } + /> + ))} + + + )} +
+
+
+
+ {/* Empty form items just to keep AntD useWatch happy */} + + + + + ) +} diff --git a/src/packages/v4/components/PayoutsTable/TotalRows.tsx b/src/packages/v4/components/PayoutsTable/TotalRows.tsx new file mode 100644 index 0000000000..b055dce230 --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/TotalRows.tsx @@ -0,0 +1,100 @@ +import { Trans, t } from '@lingui/macro' +import { Tooltip } from 'antd' +import EthereumAddress from 'components/EthereumAddress' +import { PayoutsTableCell } from 'components/PayoutsTable/PayoutsTableCell' +import { PayoutsTableRow } from 'components/PayoutsTable/PayoutsTableRow' +import TooltipLabel from 'components/TooltipLabel' +import round from 'lodash/round' +import useProjectOwnerOf from 'packages/v4/hooks/useV4ProjectOwnerOf' +import { usePayoutsTable } from './hooks/usePayoutsTable' + +const Row = PayoutsTableRow +const Cell = PayoutsTableCell + +const SMALL_FEE_PRECISION_BUFFER = 2 + +/* Bottom few rows of the payouts table which show total amounts and fees */ +export function TotalRows() { + const { + distributionLimit, + distributionLimitIsInfinite, + totalFeeAmount, + subTotal, + roundingPrecision, + payoutSplits, + ownerRemainderValue, + currencyOrPercentSymbol, + } = usePayoutsTable() + + const formattedDistributionLimit = + distributionLimit !== undefined && !distributionLimitIsInfinite + ? round(distributionLimit, roundingPrecision) + : t`Unlimited` + + const { data: projectOwnerAddress } = useProjectOwnerOf() + + const subTotalExceedsMax = distributionLimitIsInfinite && subTotal > 100 + + // Make fee more precise when it is very small + const feeRoundingPrecision = + totalFeeAmount >= 1 + ? roundingPrecision + : roundingPrecision + SMALL_FEE_PRECISION_BUFFER + + const wholePayoutToRemainingOwner = + distributionLimit && distributionLimit > 0 && payoutSplits.length === 0 + const remainingOwnerLabel = wholePayoutToRemainingOwner ? ( + + ) : ( + Remaining (to project owner)} + /> + ) + return ( + <> + {wholePayoutToRemainingOwner ? null : ( + + Sub-total + + Sub-total cannot exceed 100% + ) : undefined + } + > + {currencyOrPercentSymbol} {round(subTotal, roundingPrecision)} + + + + )} + {ownerRemainderValue > 0 ? ( + + {remainingOwnerLabel} + + {currencyOrPercentSymbol} {ownerRemainderValue} + + + ) : null} + + Fees + + {currencyOrPercentSymbol}{' '} + {round(totalFeeAmount, feeRoundingPrecision)} + + + + Total + + <> + {distributionLimitIsInfinite ? null : ( + <>{currencyOrPercentSymbol} + )} + {formattedDistributionLimit} + + + + + ) +} diff --git a/src/packages/v4/components/PayoutsTable/context/PayoutsTableContext.tsx b/src/packages/v4/components/PayoutsTable/context/PayoutsTableContext.tsx new file mode 100644 index 0000000000..401730b83f --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/context/PayoutsTableContext.tsx @@ -0,0 +1,32 @@ +import { CurrencyName } from 'constants/currency' +import { JBSplit as Split } from 'juice-sdk-core' +import { ReactNode, createContext, useContext } from 'react' + +export interface PayoutsTableContextProps { + payoutSplits: Split[] + setPayoutSplits?: (payoutSplits: Split[]) => void + currency: CurrencyName + setCurrency?: (currency: CurrencyName) => void + distributionLimit: number | undefined + setDistributionLimit?: (distributionLimit: number | undefined) => void + hideExplaination?: boolean + hideHeader?: boolean + showAvatars?: boolean + topAccessory?: ReactNode + hideSettings?: boolean + addPayoutsDisabled?: boolean +} + +export const PayoutsTableContext = createContext< + PayoutsTableContextProps | undefined +>(undefined) + +export const usePayoutsTableContext = () => { + const context = useContext(PayoutsTableContext) + if (!context) { + throw new Error( + 'usePayoutsTableContext must be used within a PayoutsTableProvider', + ) + } + return context +} diff --git a/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx b/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx new file mode 100644 index 0000000000..6b3971cce7 --- /dev/null +++ b/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx @@ -0,0 +1,412 @@ +import * as constants from '@ethersproject/constants' +import { NULL_ALLOCATOR_ADDRESS } from 'constants/contracts/mainnet/Allocators' +import { ONE_BILLION, WAD_DECIMALS } from 'constants/numbers' +import { SPLITS_TOTAL_PERCENT, JBSplit as Split, SplitPortion } from 'juice-sdk-core' +import isEqual from 'lodash/isEqual' +import round from 'lodash/round' +import { AddEditAllocationModalEntity } from 'packages/v4/components/Allocation/AddEditAllocationModal' +import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption' +import { V4CurrencyName, V4_CURRENCY_METADATA, getV4CurrencyOption } from 'packages/v4/utils/currency' +import { + JB_FEE, + adjustedSplitPercents, + deriveAmountAfterFee, + deriveAmountBeforeFee, + derivePayoutAmount, + ensureSplitsSumTo100Percent, + getNewDistributionLimit, +} from 'packages/v4/utils/distributions' +import { + MAX_PAYOUT_LIMIT, +} from 'packages/v4/utils/math' +import { + hasEqualRecipient, + isJuiceboxProjectSplit, + isProjectSplit, + totalSplitsPercent, v4GetProjectOwnerRemainderSplit, +} from 'packages/v4/utils/v4Splits' +import { useMemo } from 'react' +import { parseWad } from 'utils/format/formatNumber' +import { usePayoutsTableContext } from '../context/PayoutsTableContext' + +export const usePayoutsTable = () => { + const { + payoutSplits, + setPayoutSplits, + distributionLimit, + setDistributionLimit, + currency, + setCurrency: setCurrencyName, + } = usePayoutsTableContext() + // const { setFormHasUpdated } = useEditCycleFormContext() // TODO: Settings + const distributionLimitIsInfinite = useMemo( + () => + distributionLimit === undefined || + parseWad(distributionLimit).eq(MAX_PAYOUT_LIMIT), + [distributionLimit], + ) + + const amountOrPercentValue = ({ + payoutSplit, + dontApplyFee, + }: { + payoutSplit: Split + dontApplyFee?: boolean + }) => + distributionLimitIsInfinite + ? (payoutSplit.percent.toFloat() / ONE_BILLION) * 100 + : _derivePayoutAmount({ payoutSplit, dontApplyFee }) + + /* Total amount that leaves the treasury minus fees */ + const subTotal = payoutSplits.reduce((acc, payoutSplit) => { + const reducer = amountOrPercentValue({ payoutSplit }) + return acc + reducer + }, 0) + + let roundingPrecision = currency === 'ETH' ? 4 : 2 + // If subTotal exceeds 100%, set rounding precision to exceeding decimal amount + // e.g. subTotal = 100.00001, roundingPrecision = 5 + if (distributionLimitIsInfinite && subTotal > 100) { + const decimalPart = subTotal - Math.floor(subTotal) + if (decimalPart > 0) { + const decimalStr = decimalPart.toString() + const decimalPrecision = decimalStr.slice( + decimalStr.indexOf('.') + 1, + ).length + roundingPrecision = Math.max(roundingPrecision, decimalPrecision) + } + } + + const ownerRemainingPercentPPB = + SPLITS_TOTAL_PERCENT - totalSplitsPercent(payoutSplits) // parts-per-billion + const ownerRemainingAmount = + distributionLimit && !distributionLimitIsInfinite + ? deriveAmountAfterFee( + (ownerRemainingPercentPPB / ONE_BILLION) * distributionLimit, + ) + : undefined + + const ownerRemainderValue = round( + ownerRemainingAmount ?? (ownerRemainingPercentPPB / ONE_BILLION) * 100, + roundingPrecision, + ) + + const currencyOrPercentSymbol = distributionLimitIsInfinite + ? '%' + : V4_CURRENCY_METADATA[getV4CurrencyOption(currency)].symbol + + /* Payouts that don't go to Juicebox projects incur 2.5% fee */ + const nonJuiceboxProjectPayoutSplits = [ + ...payoutSplits.filter(payoutSplit => !isJuiceboxProjectSplit(payoutSplit)), + v4GetProjectOwnerRemainderSplit( + // remaining owner split also incurs fee + constants.AddressZero, + payoutSplits, + ) as Split, + ] + + /* Count the total fee amount. If % of payouts sums > 100, just set fees to 2.5% (maximum)*/ + const totalFeeAmount = + distributionLimitIsInfinite && round(subTotal, roundingPrecision) > 100 + ? 2.5 + : nonJuiceboxProjectPayoutSplits.reduce((acc, payoutSplit) => { + return ( + acc + + amountOrPercentValue({ payoutSplit, dontApplyFee: true }) * JB_FEE + ) + }, 0) + + /** + * Sets the currency for the distributionLimit + * @param currency - Currency as a V4CurrencyOption (1 | 2) + */ + function setCurrency(currency: V4CurrencyOption) { + setCurrencyName?.(V4CurrencyName(currency) ?? 'ETH') + // setFormHasUpdated(true) TODO: Settings + } + + function setSplits100Percent() { + setPayoutSplits?.(ensureSplitsSumTo100Percent({ splits: payoutSplits })) + } + + function _setPayoutSplits(splits: Split[]) { + if (distributionLimitIsInfinite) { + setPayoutSplits?.(splits) + } else { + setPayoutSplits?.(ensureSplitsSumTo100Percent({ splits })) + } + // setFormHasUpdated(true) + } + + function _setDistributionLimit(distributionLimit: number | undefined) { + const _distributionLimit = + distributionLimit !== undefined + ? round(distributionLimit, WAD_DECIMALS) + : undefined + setDistributionLimit?.(_distributionLimit) + } + + /** + * Derive payout amount from the % of the distributionLimit + * @param percent - Percent of distributionLimit in parts-per-billion (PPB) + * @returns Amount in the distributionLimitCurrency. + */ + function _derivePayoutAmount({ + payoutSplit, + dontApplyFee, + }: { + payoutSplit: Split + dontApplyFee?: boolean + }) { + return derivePayoutAmount({ + payoutSplit, + distributionLimit, + dontApplyFee, + }) + } + + /** + * Convert parts-per-billion percent to formatted percent + * @param percent - Percent of distributionLimit in parts-per-billion (PPB) + * @returns Percent in standard format + */ + function formattedPayoutPercent({ + payoutSplitPercent, + }: { + payoutSplitPercent: number + }) { + return round( + (payoutSplitPercent / ONE_BILLION) * 100, + roundingPrecision, + ).toString() + } + + /** + * Handle payoutSplit added: + * - Sets new distributionLimit (DL) based on sum of new payout amounts + * - Changed the % of other splits based on the new DL keep their amount the same + * @param newSplit - Just added split + * @param newAmount - The new amount of the @editingPayoutSplit + */ + function handleNewPayoutSplit({ + newSplit, + }: { + newSplit: AddEditAllocationModalEntity & { projectOwner: false } + }) { + const newSplitPercent = parseFloat(newSplit.amount.value) + let newSplitPercentPPB = (newSplitPercent * ONE_BILLION) / 100 + let adjustedSplits: Split[] = payoutSplits + let newDistributionLimit = distributionLimit + + const isProject = newSplit.projectId && newSplit.projectId !== '0x00' + + // If amounts (!distributionLimitIsInfinite), handle changing DL and split %s + if (!distributionLimitIsInfinite) { + const newAmount = isProject + ? newSplitPercent + : deriveAmountBeforeFee(newSplitPercent) + // Convert the newAmount to its percentage of the new DL in parts-per-bill + newDistributionLimit = distributionLimit + ? getNewDistributionLimit({ + currentDistributionLimit: distributionLimit.toString(), + newSplitAmount: newAmount, + editingSplitPercent: 0, + ownerRemainingAmount, + }) + : newAmount + + newSplitPercentPPB = round( + (newAmount / (newDistributionLimit ?? 0)) * ONE_BILLION, + ) + + // recalculate all split percents based on newly added split amount + if (newDistributionLimit && !distributionLimitIsInfinite) { + adjustedSplits = adjustedSplitPercents({ + splits: payoutSplits, + oldDistributionLimit: (distributionLimit as number).toString() ?? '0', + newDistributionLimit: newDistributionLimit.toString(), + }) + } + _setDistributionLimit(newDistributionLimit) + } + + const newPayoutSplit = { + beneficiary: newSplit.beneficiary, + percent: new SplitPortion(newSplitPercentPPB), + preferAddToBalance: false, + lockedUntil: newSplit.lockedUntil ?? 0, + projectId: newSplit.projectId ?? 0n, + hook: NULL_ALLOCATOR_ADDRESS, + } as Split + + const newPayoutSplits = [...adjustedSplits, newPayoutSplit] + + _setPayoutSplits(newPayoutSplits) + } + + /** + * Handle payoutSplit changed through the Edit modal: + * - Changes relevant split properties + * - If amount changed, calls handlePayoutSplitAmountChanged + * @param editedPayoutSplit - Split that has been edited + * @param newPayoutSplit - The new payout split to replace @editedPayoutSplit + */ + function handlePayoutSplitChanged({ + editedPayoutSplit, + newPayoutSplit, + }: { + editedPayoutSplit: Split + newPayoutSplit: AddEditAllocationModalEntity & { projectOwner: false } + }) { + let newSplit: Split = editedPayoutSplit + // Find editedPayoutSplit in payoutSplits and change it to newPayoutSplit + const newSplits = payoutSplits.map(m => { + if (hasEqualRecipient(m, editedPayoutSplit)) { + newSplit = { + ...newSplit, + beneficiary: newPayoutSplit.beneficiary ?? constants.AddressZero, + lockedUntil: newPayoutSplit.lockedUntil ?? 0, + projectId: BigInt(newPayoutSplit.projectId ?? '0'), + } + // If percents (distributionLimitIsInfinite), further alterations to percentages are not needed. + // In this case, set the percent now. + if (distributionLimitIsInfinite && !newPayoutSplit.projectOwner) { + newSplit = { + ...newSplit, + percent: + new SplitPortion((parseFloat(newPayoutSplit.amount.value) * ONE_BILLION) / 100), + } + } + return newSplit + } + return m + }) + _setPayoutSplits(newSplits) + if (!distributionLimitIsInfinite && !newPayoutSplit.projectOwner) { + handlePayoutSplitAmountChanged({ + editingPayoutSplit: newSplit, + newAmount: parseFloat(newPayoutSplit.amount.value), + newSplits, + }) + } + } + + /** + * Handle payoutSplit amount changed (called when payouts table rows' input fields update): + * - Sets new distributionLimit (DL) based on sum of new payout amounts + * - Changed the % of other splits based on the new DL keep their amount the same + * @param editingPayoutSplit - Split that has had its amount changed + * @param newAmount - The new amount of the @editingPayoutSplit + * @param newSplits - (optional) pass new splits to adjust. If undefined, uses current @payoutSplits state + */ + function handlePayoutSplitAmountChanged({ + editingPayoutSplit, + newAmount, + newSplits, + }: { + editingPayoutSplit: Split + newAmount: number + newSplits?: Split[] + }) { + const isNaN = Number.isNaN(newAmount) + const _amount = isNaN + ? 0 + : isProjectSplit(editingPayoutSplit) + ? newAmount + : deriveAmountBeforeFee(newAmount) + // Convert the newAmount to its percentage of the new DL in parts-per-bill + const newDistributionLimit = + distributionLimit !== undefined + ? getNewDistributionLimit({ + currentDistributionLimit: distributionLimit.toString(), + newSplitAmount: _amount, + editingSplitPercent: editingPayoutSplit.percent.toFloat(), + ownerRemainingAmount, + }) + : undefined // undefined means DL is infinite + const newSplitPercentPPB = round( + (_amount / (newDistributionLimit ?? 0)) * ONE_BILLION, + ) + let adjustedSplits: Split[] = newSplits ?? payoutSplits + // recalculate all split percents based on newly added split amount + if (newDistributionLimit && !distributionLimitIsInfinite) { + adjustedSplits = adjustedSplitPercents({ + splits: adjustedSplits, + oldDistributionLimit: (distributionLimit as number).toString() ?? '0', + newDistributionLimit: newDistributionLimit.toString(), + }) + } + + const newPayoutSplit = { + ...editingPayoutSplit, + percent: new SplitPortion(newSplitPercentPPB), + } as Split + + const newPayoutSplits = adjustedSplits.map(m => { + return hasEqualRecipient(m, editingPayoutSplit) + ? { + ...m, + ...newPayoutSplit, + } + : m + }) + _setDistributionLimit(newDistributionLimit) + _setPayoutSplits(newPayoutSplits) + } + + /** + * Handle payoutSplit amount deleted: + * - Deletes a payout split and adjusts the distributionLimit (DL) accordingly + * - Changed the % of other splits based on the new DL keep their amount the same + * @param split - Split to be deleted + */ + function handleDeletePayoutSplit({ payoutSplit }: { payoutSplit: Split }) { + const newSplits = payoutSplits.filter(m => !isEqual(m, payoutSplit)) + + let adjustedSplits: Split[] = newSplits + let newDistributionLimit = distributionLimit + if (distributionLimit && !distributionLimitIsInfinite) { + const currentAmount = _derivePayoutAmount({ + payoutSplit, + dontApplyFee: true, + }) + newDistributionLimit = distributionLimit - currentAmount + adjustedSplits = adjustedSplitPercents({ + splits: newSplits, + oldDistributionLimit: (distributionLimit as number).toString() ?? '0', + newDistributionLimit: newDistributionLimit.toString(), + }) + } + + _setDistributionLimit(newDistributionLimit) + _setPayoutSplits(adjustedSplits) + } + + function handleDeleteAllPayoutSplits() { + setDistributionLimit?.(0) + _setPayoutSplits([]) + } + + return { + distributionLimit, + distributionLimitIsInfinite, + setDistributionLimit: _setDistributionLimit, + currency, + setCurrency, + currencyOrPercentSymbol, + payoutSplits, + setPayoutSplits: _setPayoutSplits, + setSplits100Percent, + derivePayoutAmount: _derivePayoutAmount, + formattedPayoutPercent, + roundingPrecision, + handlePayoutSplitAmountChanged, + handleNewPayoutSplit, + handlePayoutSplitChanged, + handleDeletePayoutSplit, + handleDeleteAllPayoutSplits, + subTotal, + ownerRemainderValue, + totalFeeAmount, + } +} diff --git a/src/packages/v4/components/SplitList/SplitItem/EthAddressBeneficiary.tsx b/src/packages/v4/components/SplitList/SplitItem/EthAddressBeneficiary.tsx new file mode 100644 index 0000000000..fe06f096be --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/EthAddressBeneficiary.tsx @@ -0,0 +1,37 @@ +import { CrownFilled } from '@ant-design/icons' +import { Trans } from '@lingui/macro' +import { Tooltip } from 'antd' +import { JuiceboxAccountLink } from 'components/JuiceboxAccountLink' +import { isEqualAddress } from 'utils/address' + +export function ETHAddressBeneficiary({ + beneficaryAddress, + projectOwnerAddress, + hideAvatar, +}: { + beneficaryAddress: string | undefined + projectOwnerAddress: string | undefined + hideAvatar?: boolean +}) { + const isProjectOwner = isEqualAddress(projectOwnerAddress, beneficaryAddress) + + return ( +
+ {beneficaryAddress ? ( + + ) : null} + {!beneficaryAddress && isProjectOwner ? ( + Project owner (you) + ) : null} + {isProjectOwner && ( + Project owner}> + + + )} + : +
+ ) +} diff --git a/src/packages/v4/components/SplitList/SplitItem/JuiceboxProjectBeneficiary.tsx b/src/packages/v4/components/SplitList/SplitItem/JuiceboxProjectBeneficiary.tsx new file mode 100644 index 0000000000..48cd9daa22 --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/JuiceboxProjectBeneficiary.tsx @@ -0,0 +1,52 @@ +import { Trans } from '@lingui/macro' +import { Tooltip } from 'antd' +import { AllocatorBadge } from 'components/AllocatorBadge' +import { NULL_ALLOCATOR_ADDRESS } from 'constants/contracts/mainnet/Allocators' +import { JBSplit } from 'juice-sdk-core' +import { useRouter } from 'next/router' +import V4ProjectHandleLink from '../../V4ProjectHandleLink' +export function JuiceboxProjectBeneficiary({ + split, + value, +}: { + split: JBSplit + value?: string | JSX.Element +}) { + const router = useRouter() + const { chainName } = router.query + + if (!split.projectId) return null + + return ( +
+
+ + +
+ {split.hook === NULL_ALLOCATOR_ADDRESS ? ( +
+ {value ? ( + <> + + This address receives the tokens minted by this payout. + + } + > + + Tokens: + + + {value} + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/packages/v4/components/SplitList/SplitItem/LockedUntilValue.tsx b/src/packages/v4/components/SplitList/SplitItem/LockedUntilValue.tsx new file mode 100644 index 0000000000..749bbf8471 --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/LockedUntilValue.tsx @@ -0,0 +1,34 @@ +import { LockOutlined } from '@ant-design/icons' +import { Trans } from '@lingui/macro' +import { Tooltip } from 'antd' +import { formatDate } from 'utils/format/formatDate' + +export function LockedUntilValue({ + lockedUntil, + value, +}: { + lockedUntil?: number | undefined + value?: JSX.Element | string +}) { + const hasLockedUntil = lockedUntil && lockedUntil > 0n + + const lockedUntilFormatted = hasLockedUntil + ? formatDate(lockedUntil * 1000, 'yyyy-MM-DD') + : undefined + + if (!lockedUntilFormatted) return null + + return ( + + Locked until {value} + + } + className="h-22px ml-2 flex items-center text-sm text-grey-500 dark:text-grey-300" + > + +
{value ?? lockedUntilFormatted}
+
+ ) +} diff --git a/src/packages/v4/components/SplitList/SplitItem/ReservedTokensValue.tsx b/src/packages/v4/components/SplitList/SplitItem/ReservedTokensValue.tsx new file mode 100644 index 0000000000..efa8838671 --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/ReservedTokensValue.tsx @@ -0,0 +1,23 @@ +import { Trans } from '@lingui/macro' +import TooltipIcon from 'components/TooltipIcon' +import { SplitPortion } from 'juice-sdk-core' + +export function ReservedTokensValue({ + splitPercent, + reservedPercent, +}: { + splitPercent: SplitPortion + reservedPercent: number +}) { + const splitPercentNum = splitPercent.toFloat() + return ( + + {(reservedPercent * splitPercentNum) / 100}% of total token issuance. + + } + /> + ) +} diff --git a/src/packages/v4/components/SplitList/SplitItem/SplitAmountValue.tsx b/src/packages/v4/components/SplitList/SplitItem/SplitAmountValue.tsx new file mode 100644 index 0000000000..a83f6e951a --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/SplitAmountValue.tsx @@ -0,0 +1,89 @@ +import { DollarCircleOutlined } from '@ant-design/icons' +import { Trans } from '@lingui/macro' +import { Tooltip } from 'antd' +import { CurrencyName } from 'constants/currency' +import { SPLITS_TOTAL_PERCENT } from 'juice-sdk-core' +import { NativeTokenValue, useReadJbMultiTerminalFee } from 'juice-sdk-react' +import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption' +import { V4CurrencyName } from 'packages/v4/utils/currency' +import { isJuiceboxProjectSplit } from 'packages/v4/utils/v4Splits' +import { formatWad } from 'utils/format/formatNumber' +import { feeForAmount } from 'utils/math' +import { SplitProps } from './SplitItem' + +export function SplitAmountValue({ + props, + hideTooltip, +}: { + props: SplitProps + hideTooltip?: boolean +}) { + const { data: primaryNativeTerminalFee } = useReadJbMultiTerminalFee() + + const splitValue = props.totalValue + ? (props.totalValue * props.split.percent.value) / + BigInt(SPLITS_TOTAL_PERCENT) + : undefined + + const isJuiceboxProject = isJuiceboxProjectSplit(props.split) + const hasFee = !isJuiceboxProject && !props.dontApplyFeeToAmount + const feeAmount = hasFee + ? feeForAmount(splitValue, primaryNativeTerminalFee) ?? 0n + : 0n + const valueAfterFees = splitValue ? splitValue - feeAmount : 0 + + const currencyName = V4CurrencyName( + Number(props.currency) as V4CurrencyOption, + ) + + const createTooltipTitle = ( + curr: CurrencyName | undefined, + amount: bigint | undefined, + ) => { + if (hideTooltip || !amount) return undefined + return + } + + return ( + <> + + {valueAfterFees ? ( + <> + {currencyName ? ( + // + + ) : ( + // if no currency, assume its a token with 18 decimals (a wad) + <>{formatWad(valueAfterFees, { precision: 2 })} + )} + {props.valueSuffix ? {props.valueSuffix} : null} + + ) : null} + + + {props.showFee && !isJuiceboxProject && ( + + + fee + + } + className="ml-1" + > + + + )} + + ) +} diff --git a/src/packages/v4/components/SplitList/SplitItem/SplitItem.tsx b/src/packages/v4/components/SplitList/SplitItem/SplitItem.tsx new file mode 100644 index 0000000000..4b5274128d --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/SplitItem.tsx @@ -0,0 +1,55 @@ +import { JBSplit } from 'juice-sdk-core' +import { isJuiceboxProjectSplit } from 'packages/v4/utils/v4Splits' +import { ETHAddressBeneficiary } from './EthAddressBeneficiary' +import { JuiceboxProjectBeneficiary } from './JuiceboxProjectBeneficiary' +import { LockedUntilValue } from './LockedUntilValue' +import { ReservedTokensValue } from './ReservedTokensValue' +import { SplitValue } from './SplitValue' + +export type SplitProps = { + split: JBSplit + totalValue: bigint | undefined + projectOwnerAddress: string | undefined + reservedPercent?: number + valueSuffix?: string | JSX.Element + valueFormatProps?: { precision?: number } + currency?: bigint + oldCurrency?: bigint + showAmount?: boolean + showFee?: boolean + dontApplyFeeToAmount?: boolean +} + +export function SplitItem({ props }: { props: SplitProps }) { + const isJuiceboxProject = isJuiceboxProjectSplit(props.split) + + return ( +
+
+
+ {isJuiceboxProject ? ( + + ) : ( + + )} +
+ + {props.split.lockedUntil && props.split.lockedUntil > 0 ? ( + + ) : null} +
+
+ + {props.reservedPercent !== undefined ? ( + + ) : null} +
+
+ ) +} diff --git a/src/packages/v4/components/SplitList/SplitItem/SplitPercentValue.tsx b/src/packages/v4/components/SplitList/SplitItem/SplitPercentValue.tsx new file mode 100644 index 0000000000..8d8d603479 --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/SplitPercentValue.tsx @@ -0,0 +1,7 @@ +import { SplitPortion } from 'juice-sdk-core' + +export function SplitPercentValue({ percent }: { percent: SplitPortion }) { + const formattedPercent = percent.formatPercentage() + + return {formattedPercent}% +} diff --git a/src/packages/v4/components/SplitList/SplitItem/SplitValue.tsx b/src/packages/v4/components/SplitList/SplitItem/SplitValue.tsx new file mode 100644 index 0000000000..81dde36822 --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/SplitValue.tsx @@ -0,0 +1,19 @@ +import { Parenthesis } from 'components/Parenthesis' +import { SplitAmountValue } from './SplitAmountValue' +import { SplitProps } from './SplitItem' +import { SplitPercentValue } from './SplitPercentValue' + +export function SplitValue({ splitProps }: { splitProps: SplitProps }) { + return ( +
+ + {splitProps.showAmount && splitProps.totalValue && splitProps.totalValue > 0n ? ( +
+ + + +
+ ) : null} +
+ ) +} diff --git a/src/packages/v4/components/SplitList/SplitItem/index.tsx b/src/packages/v4/components/SplitList/SplitItem/index.tsx new file mode 100644 index 0000000000..6510985b47 --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitItem/index.tsx @@ -0,0 +1 @@ +export * from './SplitItem' diff --git a/src/packages/v4/components/SplitList/SplitList.tsx b/src/packages/v4/components/SplitList/SplitList.tsx new file mode 100644 index 0000000000..b29d4380f2 --- /dev/null +++ b/src/packages/v4/components/SplitList/SplitList.tsx @@ -0,0 +1,74 @@ +import { JBSplit } from 'juice-sdk-core' +import { + sortSplits, + v4GetProjectOwnerRemainderSplit, +} from 'packages/v4/utils/v4Splits' +import { useMemo } from 'react' +import { Hash } from 'viem' +import { SplitItem, SplitProps } from './SplitItem' +export type SplitListProps = { + splits: JBSplit[] + currency?: bigint + totalValue: bigint | undefined + projectOwnerAddress: string | undefined + showAmounts?: boolean + showFees?: boolean + valueSuffix?: string | JSX.Element + valueFormatProps?: { precision?: number } + reservedPercent?: number + dontApplyFeeToAmounts?: boolean +} + +export default function SplitList({ + splits, + showAmounts = false, + showFees = false, + currency, + totalValue, + projectOwnerAddress, + valueSuffix, + valueFormatProps, + reservedPercent, + dontApplyFeeToAmounts, +}: SplitListProps) { + const ownerSplit = useMemo(() => { + if (!projectOwnerAddress) return + return v4GetProjectOwnerRemainderSplit(projectOwnerAddress as Hash, splits) + }, [projectOwnerAddress, splits]) + + const splitProps: Omit = { + currency, + totalValue, + projectOwnerAddress, + valueSuffix, + valueFormatProps, + reservedPercent, + showFee: showFees, + showAmount: showAmounts, + dontApplyFeeToAmount: dontApplyFeeToAmounts, + } + + return ( +
+ {sortSplits(splits).map(split => { + return ( + + ) + })} + {ownerSplit && ownerSplit.percent.toFloat() > 0 ? ( + + ) : null} +
+ ) +} diff --git a/src/packages/v4/components/V4ProjectHandleLink.tsx b/src/packages/v4/components/V4ProjectHandleLink.tsx index 1af6f3cd91..eea594b52a 100644 --- a/src/packages/v4/components/V4ProjectHandleLink.tsx +++ b/src/packages/v4/components/V4ProjectHandleLink.tsx @@ -10,19 +10,21 @@ import { v4ProjectRoute } from 'utils/routes' */ export default function V4ProjectHandleLink({ className, + containerClassName, name, projectId, chainName, withProjectAvatar = false, }: { className?: string + containerClassName?: string name?: string | null chainName: string projectId: number withProjectAvatar?: boolean }) { return ( - <> +
{withProjectAvatar ? ( {chainName} Project #{projectId} - +
) } diff --git a/src/packages/v4/components/V4TokenHoldersModal.tsx b/src/packages/v4/components/V4TokenHoldersModal.tsx new file mode 100644 index 0000000000..ab9cf07315 --- /dev/null +++ b/src/packages/v4/components/V4TokenHoldersModal.tsx @@ -0,0 +1,27 @@ +import { BigNumber } from '@ethersproject/bignumber' +import ParticipantsModal from 'components/modals/ParticipantsModal' +import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' +import { useReadJbTokensTokenOf } from 'juice-sdk-react' +import { useV4TotalTokenSupply } from '../hooks/useV4TotalTokenSupply' + +export const V4TokenHoldersModal = ({ + open, + onClose, +}: { + open: boolean + onClose: VoidFunction +}) => { + const { data: tokenAddress } = useReadJbTokensTokenOf() + const { data: tokenSymbol } = useNameOfERC20(tokenAddress) + + const { data: totalTokenSupply } = useV4TotalTokenSupply() + return ( + + ) +} diff --git a/src/packages/v4/contexts/RulesetCountdownProvider.tsx b/src/packages/v4/contexts/RulesetCountdownProvider.tsx index 3814f84a62..bf897cdedf 100644 --- a/src/packages/v4/contexts/RulesetCountdownProvider.tsx +++ b/src/packages/v4/contexts/RulesetCountdownProvider.tsx @@ -25,7 +25,6 @@ export const RulesetCountdownProvider = ({ const endEpochSeconds = ruleset ? Number(ruleset.start + ruleset.duration) : 0 - const { remainingTimeText, secondsRemaining } = useCountdownClock(endEpochSeconds) diff --git a/src/packages/v4/graphql/queries/payEvents.graphql b/src/packages/v4/graphql/queries/payEvents.graphql index 1386cf1d51..87a9c83495 100644 --- a/src/packages/v4/graphql/queries/payEvents.graphql +++ b/src/packages/v4/graphql/queries/payEvents.graphql @@ -14,6 +14,7 @@ query PayEvents( ) { id amount + amountUSD beneficiary note timestamp diff --git a/src/packages/v4/hooks/useJBQueuedRuleset.ts b/src/packages/v4/hooks/useJBQueuedRuleset.ts deleted file mode 100644 index fe99c6ce15..0000000000 --- a/src/packages/v4/hooks/useJBQueuedRuleset.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { DecayRate, RulesetWeight } from 'juice-sdk-core'; -import { useReadJbRulesetsLatestQueuedOf } from 'juice-sdk-react'; -import { Ruleset } from '../models/ruleset'; - -export function useJBQueuedRuleset(): { data: Ruleset | undefined } { - const { data } = useReadJbRulesetsLatestQueuedOf(); - const _latestQueuedRuleset = data?.[0]; - - const queuedWeight = new RulesetWeight(_latestQueuedRuleset?.weight ?? 0n) - const queuedDecayRate = new DecayRate(_latestQueuedRuleset?.decayRate ?? 0n) - - const latestQueuedRuleset = _latestQueuedRuleset - ? { - ..._latestQueuedRuleset, - weight: queuedWeight, - decayRate: queuedDecayRate, - } - : undefined; - - return { - data: latestQueuedRuleset, - }; -} diff --git a/src/packages/v4/hooks/useJBUpcomingRuleset.ts b/src/packages/v4/hooks/useJBUpcomingRuleset.ts new file mode 100644 index 0000000000..c9030d367f --- /dev/null +++ b/src/packages/v4/hooks/useJBUpcomingRuleset.ts @@ -0,0 +1,43 @@ +import { DecayPercent, JBRulesetData, JBRulesetMetadata, RedemptionRate, ReservedPercent, RulesetWeight } from 'juice-sdk-core'; +import { useJBContractContext, useReadJbControllerUpcomingRulesetOf } from 'juice-sdk-react'; + +// @todo: add to SDK +export function useJBUpcomingRuleset(): { + ruleset: JBRulesetData | undefined, + rulesetMetadata: JBRulesetMetadata | undefined, + isLoading: boolean + } { + const { contracts, projectId } = useJBContractContext() + const { data, isLoading } = useReadJbControllerUpcomingRulesetOf({ + address: contracts.controller?.data ?? undefined, + args: [projectId] + }) + const _latestUpcomingRuleset = data?.[0] + const _latestUpcomingRulesetMetadata = data?.[1] + const upcomingWeight = new RulesetWeight(_latestUpcomingRuleset?.weight ?? 0n) + const upcomingDecayPercent = new DecayPercent(_latestUpcomingRuleset?.decayPercent ?? 0) + + const latestUpcomingRuleset = _latestUpcomingRuleset + ? { + ..._latestUpcomingRuleset, + weight: upcomingWeight, + decayPercent: upcomingDecayPercent, + } + : undefined; + + const upcomingReservedPercent = new ReservedPercent(_latestUpcomingRulesetMetadata?.reservedPercent ?? 0) + const upcomingRedemptionRate = new RedemptionRate(_latestUpcomingRulesetMetadata?.redemptionRate ?? 0) + const latestUpcomingRulesetMetadata = _latestUpcomingRulesetMetadata ? + { + ..._latestUpcomingRulesetMetadata, + reservedPercent: upcomingReservedPercent, + redemptionRate: upcomingRedemptionRate, + + } : undefined + + return { + ruleset: latestUpcomingRuleset, + rulesetMetadata: latestUpcomingRulesetMetadata, + isLoading + }; +} diff --git a/src/packages/v4/hooks/usePayoutLimits.ts b/src/packages/v4/hooks/usePayoutLimit.ts similarity index 51% rename from src/packages/v4/hooks/usePayoutLimits.ts rename to src/packages/v4/hooks/usePayoutLimit.ts index c11920f456..c1ab6c1abe 100644 --- a/src/packages/v4/hooks/usePayoutLimits.ts +++ b/src/packages/v4/hooks/usePayoutLimit.ts @@ -6,30 +6,30 @@ import { V4CurrencyOption } from '../models/v4CurrencyOption'; /** * @todo add to sdk */ -export function usePayoutLimits() { +export function usePayoutLimit() { const { projectId, - contracts: { primaryNativeTerminal: _primaryNativeTerminal }, + contracts: { primaryNativeTerminal, fundAccessLimits }, } = useJBContractContext(); const { data: ruleset } = useJBRuleset(); - const primaryNativeTerminal = _primaryNativeTerminal.data; - const payoutLimits = useReadJbFundAccessLimitsPayoutLimitsOf({ + const { data: payoutLimits, isLoading } = useReadJbFundAccessLimitsPayoutLimitsOf({ + address: fundAccessLimits.data ?? undefined, args: [ projectId, - ruleset?.id ?? 0n, - primaryNativeTerminal ?? constants.AddressZero, + BigInt(ruleset?.id ?? 0), + primaryNativeTerminal.data ?? constants.AddressZero, NATIVE_TOKEN, ], }); - const payoutLimit = payoutLimits?.data?.[0]; + const payoutLimit = payoutLimits?.[0]; return { - data: { + data: payoutLimit ? { ...payoutLimit, - currency: payoutLimit?.currency as V4CurrencyOption | undefined, - }, - isLoading: payoutLimits?.isLoading, + currency: Number(payoutLimit.currency) as V4CurrencyOption, + }: undefined, + isLoading }; } diff --git a/src/packages/v4/hooks/useQueuedPayoutLimits.ts b/src/packages/v4/hooks/useQueuedPayoutLimits.ts deleted file mode 100644 index 3ceb8cd06d..0000000000 --- a/src/packages/v4/hooks/useQueuedPayoutLimits.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as constants from '@ethersproject/constants'; -import { NATIVE_TOKEN } from 'juice-sdk-core'; -import { useJBContractContext, useReadJbFundAccessLimitsPayoutLimitsOf, useReadJbRulesetsLatestQueuedOf } from 'juice-sdk-react'; -import { V4CurrencyOption } from '../models/v4CurrencyOption'; - -/** - * @todo add to sdk - */ -export function useQueuedPayoutLimits() { - const { - projectId, - contracts: { primaryNativeTerminal: _primaryNativeTerminal }, - } = useJBContractContext() - - const { data: _latestQueuedRuleset } = useReadJbRulesetsLatestQueuedOf(); - - const primaryNativeTerminal = _primaryNativeTerminal.data; - - const latestQueuedRuleset = _latestQueuedRuleset?.[0]; - - const queuedPayoutLimits = useReadJbFundAccessLimitsPayoutLimitsOf({ - args: [ - projectId, - latestQueuedRuleset?.id ?? 0n, - primaryNativeTerminal ?? constants.AddressZero, - NATIVE_TOKEN, - ] - }); - const queuedPayoutLimit = queuedPayoutLimits?.data?.[0] - return { - data: { - ...queuedPayoutLimit, - currency: queuedPayoutLimit?.currency as V4CurrencyOption | undefined, - }, - isLoading: queuedPayoutLimits?.isLoading, - }; -} diff --git a/src/packages/v4/hooks/useUpcomingPayoutLimit.ts b/src/packages/v4/hooks/useUpcomingPayoutLimit.ts new file mode 100644 index 0000000000..d00e58bf60 --- /dev/null +++ b/src/packages/v4/hooks/useUpcomingPayoutLimit.ts @@ -0,0 +1,35 @@ +import * as constants from '@ethersproject/constants'; +import { NATIVE_TOKEN } from 'juice-sdk-core'; +import { useJBContractContext, useReadJbFundAccessLimitsPayoutLimitsOf } from 'juice-sdk-react'; +import { V4CurrencyOption } from '../models/v4CurrencyOption'; +import { useJBUpcomingRuleset } from './useJBUpcomingRuleset'; + +/** + * @todo add to sdk + */ +export function useUpcomingPayoutLimit() { + const { + projectId, + contracts: { primaryNativeTerminal, fundAccessLimits }, + } = useJBContractContext() + + const { ruleset: latestUpcomingRuleset } = useJBUpcomingRuleset(); + + const upcomingPayoutLimits = useReadJbFundAccessLimitsPayoutLimitsOf({ + address: fundAccessLimits.data || undefined, + args: [ + projectId, + BigInt(latestUpcomingRuleset?.id ?? 0n), + primaryNativeTerminal.data ?? constants.AddressZero, + NATIVE_TOKEN, + ] + }); + const upcomingPayoutLimit = upcomingPayoutLimits?.data?.[0] + return { + data: { + ...upcomingPayoutLimit, + currency: upcomingPayoutLimit?.currency as V4CurrencyOption | undefined, + }, + isLoading: upcomingPayoutLimits?.isLoading, + }; +} diff --git a/src/packages/v4/hooks/useUsedPayoutLimitOf.ts b/src/packages/v4/hooks/useUsedPayoutLimitOf.ts new file mode 100644 index 0000000000..5c8be280af --- /dev/null +++ b/src/packages/v4/hooks/useUsedPayoutLimitOf.ts @@ -0,0 +1,27 @@ +import { NATIVE_CURRENCY_ID, NATIVE_TOKEN } from 'juice-sdk-core'; +import { + useJBContractContext, + useJBRulesetContext, + useJBTerminalContext, + useReadJbTerminalStoreUsedPayoutLimitOf, +} from 'juice-sdk-react'; +import { zeroAddress } from 'viem'; + +export const useUsedPayoutLimitOf = () => { + const { store } = useJBTerminalContext(); + const { projectId, contracts } = useJBContractContext(); + const { ruleset } = useJBRulesetContext(); + + const { data: usedPayoutLimit, isLoading } = useReadJbTerminalStoreUsedPayoutLimitOf({ + address: store.data ?? undefined, + args: [ + contracts.primaryNativeTerminal.data ?? zeroAddress, + projectId, + NATIVE_TOKEN, + BigInt(ruleset.data?.cycleNumber ?? 0), + NATIVE_CURRENCY_ID, + ], + }); + + return { data: usedPayoutLimit, isLoading }; +}; diff --git a/src/packages/v4/hooks/useV4BalanceOfNativeTerminal.ts b/src/packages/v4/hooks/useV4BalanceOfNativeTerminal.ts new file mode 100644 index 0000000000..ec32f2a489 --- /dev/null +++ b/src/packages/v4/hooks/useV4BalanceOfNativeTerminal.ts @@ -0,0 +1,23 @@ +import { NATIVE_TOKEN } from 'juice-sdk-core'; +import { + useJBContractContext, + useJBTerminalContext, + useReadJbTerminalStoreBalanceOf, +} from 'juice-sdk-react'; +import { zeroAddress } from 'viem'; + +export const useV4BalanceOfNativeTerminal = () => { + const { store } = useJBTerminalContext(); + const { projectId, contracts } = useJBContractContext(); + + const { data: treasuryBalance, isLoading } = useReadJbTerminalStoreBalanceOf({ + address: store.data ?? undefined, + args: [ + contracts.primaryNativeTerminal.data ?? zeroAddress, + projectId, + NATIVE_TOKEN, + ], + }); + + return { data: treasuryBalance, isLoading }; +}; diff --git a/src/packages/v4/hooks/useV4PayoutSplits.ts b/src/packages/v4/hooks/useV4PayoutSplits.ts new file mode 100644 index 0000000000..41708a6b30 --- /dev/null +++ b/src/packages/v4/hooks/useV4PayoutSplits.ts @@ -0,0 +1,31 @@ +import { JBSplit, SplitPortion } from 'juice-sdk-core' +import { + useJBContractContext, + useJBRuleset, + useReadJbSplitsSplitsOf, + useReadJbTokensTokenOf, +} from 'juice-sdk-react' + +export const useV4CurrentPayoutSplits = () => { + const { projectId } = useJBContractContext() + const { data: tokenAddress } = useReadJbTokensTokenOf() + const { data: ruleset } = useJBRuleset() + + const groupId = BigInt(tokenAddress ?? 0) // contracts say this is: `uint256(uint160(tokenAddress))` + const { data: _splits, isLoading: currentSplitsLoading } = + useReadJbSplitsSplitsOf({ + args: [projectId, BigInt(ruleset?.id ?? 0), groupId], + query: { + select(data) { + return data.map(d => ({ + ...d, + percent: new SplitPortion(d.percent), + })) + }, + }, + }) + + const splits: JBSplit[] = _splits ? [..._splits] : [] + + return { splits, isLoading: currentSplitsLoading } +} diff --git a/src/packages/v4/hooks/useV4ProjectOwnerOf.ts b/src/packages/v4/hooks/useV4ProjectOwnerOf.ts new file mode 100644 index 0000000000..3be58bae7e --- /dev/null +++ b/src/packages/v4/hooks/useV4ProjectOwnerOf.ts @@ -0,0 +1,16 @@ +import { useJBContractContext, useReadJbProjectsOwnerOf } from "juice-sdk-react"; + +const useProjectOwnerOf = () => { + const { projectId } = useJBContractContext(); + + const { data: projectOwnerAddress, isLoading } = useReadJbProjectsOwnerOf({ + args: [projectId], + }); + + return { + data: projectOwnerAddress, + isLoading + }; +}; + +export default useProjectOwnerOf; diff --git a/src/packages/v4/hooks/useV4ReservedSplits.ts b/src/packages/v4/hooks/useV4ReservedSplits.ts new file mode 100644 index 0000000000..800586ddee --- /dev/null +++ b/src/packages/v4/hooks/useV4ReservedSplits.ts @@ -0,0 +1,30 @@ +import { JBSplit, SplitPortion } from 'juice-sdk-core' +import { + useJBContractContext, + useJBRuleset, + useReadJbSplitsSplitsOf, +} from 'juice-sdk-react' + +const RESERVED_SPLITS_GROUP_ID = 1n + +export const useV4ReservedSplits = () => { + const { projectId } = useJBContractContext() + const { data: ruleset } = useJBRuleset() + + const { data: _splits, isLoading: currentSplitsLoading } = + useReadJbSplitsSplitsOf({ + args: [projectId, BigInt(ruleset?.id ?? 0), RESERVED_SPLITS_GROUP_ID], + query: { + select(data) { + return data.map(d => ({ + ...d, + percent: new SplitPortion(d.percent), + })) + }, + }, + }) + + const splits: JBSplit[] = _splits ? [..._splits] : [] + + return { splits, isLoading: currentSplitsLoading } +} diff --git a/src/packages/v4/hooks/useV4TotalTokenSupply.ts b/src/packages/v4/hooks/useV4TotalTokenSupply.ts new file mode 100644 index 0000000000..e7df0b9bd4 --- /dev/null +++ b/src/packages/v4/hooks/useV4TotalTokenSupply.ts @@ -0,0 +1,15 @@ +import { useJBContractContext, useReadJbControllerTotalTokenSupplyWithReservedTokensOf } from 'juice-sdk-react'; + +export const useV4TotalTokenSupply = () => { + const { projectId, contracts: { controller } } = useJBContractContext(); + + const { data: totalTokenSupplyWei, isLoading } = useReadJbControllerTotalTokenSupplyWithReservedTokensOf({ + address: controller.data ?? undefined, + args: [projectId], + }); + + return { + data: totalTokenSupplyWei, + isLoading + }; +}; diff --git a/src/packages/v4/hooks/useV4WalletHasPermission.ts b/src/packages/v4/hooks/useV4WalletHasPermission.ts index e366409fcc..866b46b1d1 100644 --- a/src/packages/v4/hooks/useV4WalletHasPermission.ts +++ b/src/packages/v4/hooks/useV4WalletHasPermission.ts @@ -1,8 +1,9 @@ import * as constants from '@ethersproject/constants' import { useWallet } from 'hooks/Wallet' -import { useJBContractContext, useReadJbPermissionsHasPermissions, useReadJbProjectsOwnerOf } from 'juice-sdk-react' +import { useJBContractContext, useReadJbPermissionsHasPermissions } from 'juice-sdk-react' import { isEqualAddress } from 'utils/address' import { V4OperatorPermission } from '../models/v4Permissions' +import useProjectOwnerOf from './useV4ProjectOwnerOf' export function useV4WalletHasPermission( permission: V4OperatorPermission | V4OperatorPermission[], @@ -10,9 +11,7 @@ export function useV4WalletHasPermission( const { userAddress } = useWallet() const { projectId } = useJBContractContext() - const { data: projectOwnerAddress } = useReadJbProjectsOwnerOf({ - args: [projectId], - }) + const { data: projectOwnerAddress } = useProjectOwnerOf() const _operator = userAddress ?? constants.AddressZero const _account = projectOwnerAddress ?? constants.AddressZero diff --git a/src/packages/v4/models/ruleset.ts b/src/packages/v4/models/ruleset.ts deleted file mode 100644 index 30c5ef4c52..0000000000 --- a/src/packages/v4/models/ruleset.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DecayRate, RulesetWeight } from "juice-sdk-core"; - -export type Ruleset = Omit<{ - cycleNumber: bigint; - id: bigint; - basedOnId: bigint; - start: bigint; - duration: bigint; - weight: bigint; - decayRate: bigint; - approvalHook: `0x${string}`; - metadata: bigint; -}, "weight" | "decayRate"> & { - weight: RulesetWeight; - decayRate: DecayRate; -} diff --git a/src/packages/v4/utils/approvalHooks.ts b/src/packages/v4/utils/approvalHooks.ts index 33da693154..5e47611ad9 100644 --- a/src/packages/v4/utils/approvalHooks.ts +++ b/src/packages/v4/utils/approvalHooks.ts @@ -6,6 +6,6 @@ export const getApprovalStrategyByAddress = (address: string) => { return { address, - name: '1 day (@todo)' + name: '1 day (@todo: address->name)' }; } diff --git a/src/packages/v4/utils/distributions.ts b/src/packages/v4/utils/distributions.ts new file mode 100644 index 0000000000..d1add0d6ad --- /dev/null +++ b/src/packages/v4/utils/distributions.ts @@ -0,0 +1,260 @@ +import { BigNumber } from 'ethers' + +import { ONE_BILLION } from 'constants/numbers' +import { JBSplit as Split, SplitPortion, SPLITS_TOTAL_PERCENT } from 'juice-sdk-core' +import { fromWad, parseWad } from 'utils/format/formatNumber' +import { isInfinitePayoutLimit } from './fundingCycle' +import { + MAX_PAYOUT_LIMIT, +} from './math' +import { splitPortionFromFormattedPercent } from './v4Splits' + +export const JB_FEE = 0.025 + +/** + * Derive payout amount after the fee has been applied. + * @param amount - Amount before fee applied + * @returns Amount @amount minus the JB fee + */ +export function deriveAmountAfterFee(amount: number) { + return amount - amount * JB_FEE +} + +/** + * Derive payout amount before the fee has been applied. + * @param amount - An amount that has already had the fee applied + * @returns Amount @amount plus the JB fee + */ +export function deriveAmountBeforeFee(amount: number) { + return amount / (1 - JB_FEE) +} + +/** + * Derive payout amount from its % of the distributionLimit. Apply fee if necessary + * @param split - Payout split + * @param distributionLimit - Distribution limit + * @returns Amount that payout will receive as a number. + */ +export function derivePayoutAmount({ + payoutSplit, + distributionLimit, + dontApplyFee, +}: { + payoutSplit: Split + distributionLimit: number | undefined + dontApplyFee?: boolean +}) { + if (!distributionLimit) return 0 + const amountBeforeFee = + (payoutSplit.percent.toFloat() / ONE_BILLION) * distributionLimit + if (isJuiceboxProjectSplit(payoutSplit) || dontApplyFee) return amountBeforeFee // projects dont have fee applied + return deriveAmountAfterFee(amountBeforeFee) +} + +/** + * Gets amount from percent of a bigger amount + * @param percent {float} - value as a percentage. + * @param amount string (hexString) + * @returns {number} distribution amount + */ +export function amountFromPercent({ + percent, + amount, +}: { + percent: number + amount: string +}) { + return (percent / 100) * parseFloat(amount) +} + +/** + * Gets split percent from split amount and the distribution limit + * @param amount {float} - value as a percentage. + * @param distributionLimit number + * @returns {number} percent as an actual percentage of distribution limit (/100) + */ +export function getDistributionPercentFromAmount({ + amount, // Distribution amount before fee + distributionLimit, +}: { + amount: number + distributionLimit: number +}) { + return Number(splitPortionFromFormattedPercent((amount / distributionLimit) * 100)) +} + +/** + * Calculates sum of all split percentages + * @param splits {Split[]} - list of splits + * @returns {number} sum of all split percentanges + */ +export function getTotalSplitsPercentage(splits: Split[]) { + return splits.reduce( + (acc, curr) => acc + curr.percent.formatPercentage(), + 0, + ) +} + +/** + * Due to limitations of rounding errors, it's possible that adjustedSplitPercents causes + * the splits to sum to 99.99999999% or 100.0000001% (causes error) instead of 100%. + * This function does one final pass of the percents to ensure they sum to 100%. + * @param splits {Split[]} - list of current splits to possibly have their percents adjusted + * @returns {Split[]} splits with their percents adjusted + */ +export function ensureSplitsSumTo100Percent({ + splits, +}: { + splits: Split[] +}): Split[] { + // Calculate the percent total of the splits + const currentTotal = splits.reduce((sum, split) => sum + split.percent.toFloat(), 0) + // If the current total is already equal to SPLITS_TOTAL_PERCENT, no adjustment needed + if (currentTotal === SPLITS_TOTAL_PERCENT) { + return splits + } + + // Calculate the ratio to adjust each split by + const ratio = SPLITS_TOTAL_PERCENT / currentTotal + + // Adjust each split + const adjustedSplits = splits.map(split => ({ + ...split, + percent: new SplitPortion(Math.round(split.percent.toFloat() * ratio)), + })) + + // Calculate the total after adjustment + const adjustedTotal = adjustedSplits.reduce( + (sum, split) => sum + split.percent.toFloat(), + 0, + ) + if (adjustedTotal === SPLITS_TOTAL_PERCENT) { + return adjustedSplits + } + + // If there's STILL a difference due to rounding errors, adjust the largest split + const difference = SPLITS_TOTAL_PERCENT - adjustedTotal + const largestSplitIndex = adjustedSplits.findIndex( + split => split.percent.toFloat() === Math.max(...adjustedSplits.map(s => s.percent.toFloat())), + ) + if (adjustedSplits[largestSplitIndex]) { + adjustedSplits[largestSplitIndex].percent = new SplitPortion(adjustedSplits[largestSplitIndex].percent.toFloat() + difference) + } + + return adjustedSplits +} + +/** + * Adjusts exist split percents to stay the same amount when distribution limit is changed + * @param splits {Split[]} - list of current splits to have their percents adjusted + * @param oldDistributionLimit {string} - string of the old distribution limit number (e.g. '1') + * @param newDistributionLimit {string} - string of the new distribution limit number + * @returns {Split[]} splits with their percents adjusted + */ +export function adjustedSplitPercents({ + splits, + oldDistributionLimit, + newDistributionLimit, +}: { + splits: Split[] + oldDistributionLimit: string + newDistributionLimit: string +}) { + const adjustedSplits: Split[] = [] + splits.forEach((split: Split) => { + const currentAmount = amountFromPercent({ + percent: split.percent.formatPercentage(), + amount: oldDistributionLimit, + }) + + const newPercent = getDistributionPercentFromAmount({ + amount: currentAmount, + distributionLimit: parseFloat(newDistributionLimit), + }) + const adjustedSplit = { + beneficiary: split.beneficiary, + percent: new SplitPortion(newPercent), + preferAddToBalance: split.preferAddToBalance, + lockedUntil: split.lockedUntil, + projectId: split.projectId, + hook: split.hook, + } as Split + adjustedSplits?.push(adjustedSplit) + }) + return adjustedSplits +} + +/** + * Derives the new distribution limit when a split amount is altered or added + * @param editingSplitPercent {number} - percent of the split being edited (0 if adding a split) + * @param newDistributionLimit {string} - string of the new distribution limit number (e.g. '1') + * @returns {number} newDistributionLimit + */ +export function getNewDistributionLimit({ + editingSplitPercent, + newSplitAmount, + currentDistributionLimit, + ownerRemainingAmount, +}: { + editingSplitPercent: number // percent per billion + newSplitAmount: number + currentDistributionLimit: string + ownerRemainingAmount?: number +}) { + const previousSplitAmount = + currentDistributionLimit === '0' + ? 0 + : amountFromPercent({ + percent: new SplitPortion(editingSplitPercent).formatPercentage(), + amount: currentDistributionLimit, + }) // will be 0 when adding split but an actual amount when reconfiging or deleting + + return ( + parseFloat(currentDistributionLimit) - + previousSplitAmount + + newSplitAmount - + (ownerRemainingAmount ?? 0) + ) +} + +// Determines if a split is a Juicebox project +export function isJuiceboxProjectSplit(split: Split) { + return split.projectId ? BigNumber.from(split.projectId).gt(0) : false +} + +/** + * Converts the distribution limit that comes from redux from string to a number. If infinite, returns undefined. + * @param distributionLimit {string | undefined} - The distribution limit as a string (or undefined). + * @returns {number | undefined} - Returns the distribution limit as a number if it isn't infinite, otherwise returns undefined. + */ +export function distributionLimitStringtoNumber( + distributionLimit: string | undefined, +) { + if (distributionLimit === undefined) return undefined + const distributionLimitBN = parseWad(distributionLimit) + const distributionLimitIsInfinite = + !distributionLimitBN || distributionLimitBN.eq(MAX_PAYOUT_LIMIT) + return distributionLimitIsInfinite + ? undefined + : parseFloat(fromWad(distributionLimitBN)) +} + +/** + * Determines if two distributionLimits are the same + * @param distributionLimit1 {BigNumber | undefined} - First DL to compare (undefined === unlimited) + * @param distributionLimit2 {BigNumber | undefined} - Second DL to compare (undefined === unlimited) + + * @returns {boolean} - True if DLs are the same, + */ +export function distributionLimitsEqual( + distributionLimit1: bigint | undefined, + distributionLimit2: bigint | undefined, +) { + if ( + isInfinitePayoutLimit(distributionLimit1) && + isInfinitePayoutLimit(distributionLimit2) + ) { + return true + } + return distributionLimit1 === distributionLimit2 +} diff --git a/src/packages/v4/utils/formatV4CurrencyAmount.ts b/src/packages/v4/utils/formatCurrencyAmount.ts similarity index 100% rename from src/packages/v4/utils/formatV4CurrencyAmount.ts rename to src/packages/v4/utils/formatCurrencyAmount.ts diff --git a/src/packages/v4/utils/fundingCycle.ts b/src/packages/v4/utils/fundingCycle.ts new file mode 100644 index 0000000000..52c8e8e887 --- /dev/null +++ b/src/packages/v4/utils/fundingCycle.ts @@ -0,0 +1,21 @@ +import { MAX_PAYOUT_LIMIT } from "./math" + +export function isInfinitePayoutLimit( + payoutLimit: bigint | undefined, +) { + return ( + payoutLimit === undefined || + payoutLimit === MAX_PAYOUT_LIMIT + ) +} + +// Not zero and not infinite +export const isFinitePayoutLimit = ( + payoutLimit: bigint | undefined, +): boolean => { + return Boolean( + payoutLimit && + !(payoutLimit === 0n) && + !isInfinitePayoutLimit(payoutLimit), + ) +} diff --git a/src/packages/v4/utils/math.ts b/src/packages/v4/utils/math.ts index 690b67a47b..d38d9fa04b 100644 --- a/src/packages/v4/utils/math.ts +++ b/src/packages/v4/utils/math.ts @@ -1,3 +1,13 @@ import * as constants from '@ethersproject/constants' +import { feeForAmount } from 'utils/math' export const MAX_PAYOUT_LIMIT = constants.MaxUint256.toBigInt() + +export const amountSubFee = ( + amountWad: bigint | undefined, + feePerBillion: bigint | undefined, +): bigint | undefined => { + if (!feePerBillion || !amountWad) return + const feeAmount = feeForAmount(amountWad, feePerBillion) ?? 0n + return amountWad - feeAmount +} diff --git a/src/packages/v4/utils/splitToAllocation.ts b/src/packages/v4/utils/splitToAllocation.ts new file mode 100644 index 0000000000..12651b7e30 --- /dev/null +++ b/src/packages/v4/utils/splitToAllocation.ts @@ -0,0 +1,33 @@ +import { JBSplit, SplitPortion } from 'juice-sdk-core' + +import { zeroAddress } from 'viem' +import { AllocationSplit } from '../components/Allocation/Allocation' +import { sanitizeSplit } from './v4Splits' + +const defaultSplit: JBSplit = { + beneficiary: zeroAddress, + percent: new SplitPortion(0), + preferAddToBalance: false, + lockedUntil: 0, + projectId: 0n, + hook: zeroAddress, +} + +export const splitToAllocation = (split: JBSplit): AllocationSplit => { + return { + id: `${split.beneficiary}${ + split.projectId !== undefined && split.projectId !== 0n ? `-${split.projectId}` : '' + }`, + ...split, + percent: split.percent + } +} + +export const allocationToSplit = (allocation: AllocationSplit): JBSplit => { + const a = { + ...defaultSplit, + ...allocation, + percent: allocation.percent, + } + return sanitizeSplit(a) +} diff --git a/src/packages/v4/utils/v4Splits.ts b/src/packages/v4/utils/v4Splits.ts new file mode 100644 index 0000000000..0d85e64b7c --- /dev/null +++ b/src/packages/v4/utils/v4Splits.ts @@ -0,0 +1,285 @@ +import * as constants from '@ethersproject/constants' +import { JBSplit, SplitPortion, SPLITS_TOTAL_PERCENT } from 'juice-sdk-core' +import isEqual from 'lodash/isEqual' +import { formatWad } from 'utils/format/formatNumber' +import { Hash } from 'viem' +import { isFinitePayoutLimit } from './fundingCycle' + +/** + * Return a Split object that represents the remaining percentage allocated to the project owner. + * + * In the Juicebox protocol, if the sum of the split percentages is less than 100%, + * the remainder gets allocated to the project owner. + */ +export const v4GetProjectOwnerRemainderSplit = ( + projectOwnerAddress: Hash, + splits: JBSplit[], +): JBSplit & { isProjectOwner: true } => { + const totalSplitPercentage = v4TotalSplitsPercent(splits) + const ownerPercentage = new SplitPortion( + SPLITS_TOTAL_PERCENT - Number(totalSplitPercentage), + ) + + return { + beneficiary: projectOwnerAddress, + percent: ownerPercentage, + lockedUntil: 0, + projectId: 0n, + isProjectOwner: true, + preferAddToBalance: false, + hook: constants.AddressZero, + } +} + +/** + * Returns the sum of each split's percent in a list of splits + * @param splits {Split[]} - list of splits to sum percents + * @returns {bigint} - sum of percents in part-per-billion (max = SPLITS_TOTAL_PERCENT) + */ +export const v4TotalSplitsPercent = (splits: JBSplit[]): bigint => + splits?.reduce((sum, split) => sum + split.percent.value, 0n) ?? 0n + +// - true if the split has been removed (exists in old but not new), +// - false if new (exists in new but not old) +// - JBSplit if exists in old and new and there is a diff in the splits +// - undefined if exists in old and new and there is no diff in the splits +type OldSplit = JBSplit | boolean | undefined + +export type SplitWithDiff = JBSplit & { + oldSplit?: OldSplit +} + +// determines if two splits are the same 'entity' (either projectId or address) +export const hasEqualRecipient = (a: JBSplit, b: JBSplit) => { + const isProject = isProjectSplit(a) || isProjectSplit(b) + const idsEqual = + a.projectId === b.projectId || + BigInt(a.projectId ?? 0) === BigInt(b.projectId ?? 0) || + BigInt(b.projectId ?? 0) === BigInt(a.projectId ?? 0) + + return ( + (isProject && idsEqual) || (!isProject && a.beneficiary === b.beneficiary) + ) +} + +// return list of splits that exist in oldSplits but not newSplits +const getRemovedSplits = (oldSplits: JBSplit[], newSplits: JBSplit[]) => { + return oldSplits.filter(oldSplit => { + return !newSplits.some(newSplit => hasEqualRecipient(oldSplit, newSplit)) + }) +} + +export const sanitizeSplit = (split: JBSplit): JBSplit => { + return { + lockedUntil: split.lockedUntil ?? 0n, + projectId: split.projectId ?? '0x0', + beneficiary: split.beneficiary ?? constants.AddressZero, + hook: split.hook ?? constants.AddressZero, + preferAddToBalance: false, + percent: split.percent, + } +} + +/** + * Converts array of splits from transaction data (e.g. outgoing reconfig tx) to array of native JBSplit objects + * (Outgoing Split objects have percent and lockedUntil as bigints) + */ +export const toSplit = (splits: JBSplit[]): JBSplit[] => { + return ( + splits?.map( + (split: JBSplit) => ({ ...split, percent: split.percent } as JBSplit), + ) ?? [] + ) +} + +// returns a given list of splits sorted by percent allocation +export const sortSplits = (splits: JBSplit[]) => { + return [...splits].sort((a, b) => (a.percent < b.percent ? 1 : -1)) +} + +/* Determines if two splits AMOUNTS are equal. Extracts amounts for two splits from their respective totalValues **/ +function splitAmountsAreEqual({ + split1, + split2, + split1TotalValue, + split2TotalValue, +}: { + split1: JBSplit + split2: JBSplit + split1TotalValue: bigint + split2TotalValue: bigint +}) { + const split1Amount = formatWad( + (split1TotalValue * split1.percent.value) / BigInt(SPLITS_TOTAL_PERCENT), + { + precision: 2, + }, + ) + const split2Amount = formatWad( + (split2TotalValue * split2.percent.value) / BigInt(SPLITS_TOTAL_PERCENT), + { + precision: 2, + }, + ) + return split2Amount === split1Amount +} + +/* Determines if two splits are equal. If given totalValues, uses the amount of each split instead of its percent **/ +function splitsAreEqual({ + split1, + split2, + split1TotalValue, + split2TotalValue, +}: { + split1: JBSplit + split2: JBSplit + split1TotalValue: bigint + split2TotalValue: bigint +}) { + const isFiniteTotalValue = + isFinitePayoutLimit(split1TotalValue) && + isFinitePayoutLimit(split2TotalValue) + if (!isFiniteTotalValue) { + return isEqual(split1, split2) + } + + return ( + splitAmountsAreEqual({ + split1, + split2, + split1TotalValue, + split2TotalValue, + }) && + split1.beneficiary === split2.beneficiary && + split1.hook === split2.hook && + split1.lockedUntil === split2.lockedUntil && + split1.projectId === split2.projectId && + split1.preferAddToBalance === split2.preferAddToBalance + ) +} + +// returns all unique and diffed splits (projectIds or addresses), sorted by their new `percent` +// and assigns each split an additional property `oldSplit` (OldSplit) +export const processUniqueSplits = ({ + oldTotalValue, + newTotalValue, + oldSplits, + newSplits, + allSplitsChanged, +}: { + oldTotalValue?: bigint + newTotalValue?: bigint + oldSplits: JBSplit[] | undefined + newSplits: JBSplit[] + allSplitsChanged?: boolean // pass when you know all splits have changed (e.g. currency has changed) +}): Array< + SplitWithDiff & { + totalValue?: number + oldSplit?: OldSplit & { totalValue?: bigint } + } +> => { + const uniqueSplitsByProjectIdOrAddress: Array< + SplitWithDiff & { + totalValue?: bigint + oldSplit?: OldSplit & { totalValue?: bigint } + } + > = [] + if (!oldSplits) { + return sortSplits(newSplits) + } + + newSplits.map(split => { + const oldSplit = oldSplits.find(oldSplit => + hasEqualRecipient(oldSplit, split), + ) + const splitsEqual = + oldSplit && !allSplitsChanged && newTotalValue && oldTotalValue + ? splitsAreEqual({ + split1: split, + split2: oldSplit, + split1TotalValue: newTotalValue, + split2TotalValue: oldTotalValue, + }) + : false + + if (oldSplit && !splitsEqual) { + // adds diffed splits (exists in new and old and there is diff) + uniqueSplitsByProjectIdOrAddress.push({ + ...split, + oldSplit: { + ...oldSplit, + totalValue: oldTotalValue, + }, + }) + } else if (oldSplit && splitsEqual) { + // undiff'd split (exists in new and old and there is no diff) = DO NOTHING + return + } else { + // adds the new splits (exists in new but not old) + uniqueSplitsByProjectIdOrAddress.push({ + ...split, + oldSplit: false, + }) + } + }) + + // adds the old splits (exists in old but not new) + const removedSplits = getRemovedSplits(oldSplits, newSplits) + removedSplits.map(split => { + uniqueSplitsByProjectIdOrAddress.push({ + ...split, + oldSplit: true, + }) + }) + return sortSplits(uniqueSplitsByProjectIdOrAddress) +} + +export const isProjectSplit = (split: JBSplit): boolean => { + return Boolean(split.projectId) && split.projectId > 0n +} + +/** + * Returns the sum of each split's percent in a list of splits + * @param splits {JBSplit[]} - list of splits to sum percents + * @returns {number} - sum of percents in part-per-billion (max = SPLITS_TOTAL_PERCENT) + */ +export const totalSplitsPercent = (splits: JBSplit[]): number => + splits?.reduce((sum, split) => sum + Number(split.percent), 0) ?? 0 + +/** + * Determines if two lists of splits have any diff's within them. + * @param splits1 {JBSplit[]} - first list of splits + * @param splits2 {JBSplit[]} - second list of splits + * @returns {boolean} - true if splits have a diff, false if the same. + */ +export function splitsListsHaveDiff( + splits1: JBSplit[] | undefined, + splits2: JBSplit[] | undefined, +) { + if (!splits1 && !splits2) return false + if ((splits1 && !splits2) || (!splits1 && splits2)) return true + + for (const split1 of splits1 ?? []) { + const correspondingSplit = splits2?.find(split2 => + hasEqualRecipient(split1, split2), + ) + + if (!correspondingSplit || !isEqual(split1, correspondingSplit)) { + return true + } + } + + return splits1?.length !== splits2?.length +} + +// Determines if a split is a Juicebox project +export function isJuiceboxProjectSplit(split: JBSplit) { + return split.projectId ? split.projectId > 0n : false +} + +// e.g. Converts 10 to 100000000 (10% of SPLITS_TOTAL_PERCENT) +export const splitPortionFromFormattedPercent = (percentage: number): bigint => { + return percentage + ? BigInt((percentage * SPLITS_TOTAL_PERCENT) / 100) + : 0n +} diff --git a/src/packages/v4/views/V4ProjectDashboard/ProjectHeaderStats.tsx b/src/packages/v4/views/V4ProjectDashboard/ProjectHeaderStats.tsx new file mode 100644 index 0000000000..67430d0645 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/ProjectHeaderStats.tsx @@ -0,0 +1,69 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { ArrowTrendingUpIcon } from '@heroicons/react/24/outline' +import { t, Trans } from '@lingui/macro' +import ETHAmount from 'components/currency/ETHAmount' +import { ProjectHeaderStat } from 'components/Project/ProjectHeader/ProjectHeaderStat' +import { TRENDING_WINDOW_DAYS } from 'components/Projects/RankingExplanation' +import { PropsWithChildren, useCallback } from 'react' +import { twMerge } from 'tailwind-merge' +import { useProjectPageQueries } from './hooks/useProjectPageQueries' +import { useV4ProjectHeader } from './hooks/useV4ProjectHeader' + +export function ProjectHeaderStats() { + const { payments, totalVolume, last7DaysPercent } = useV4ProjectHeader() + const { setProjectPageTab } = useProjectPageQueries() + + const openActivityTab = useCallback( + () => setProjectPageTab('activity'), + [setProjectPageTab], + ) + + return ( +
+ + + + + } + /> + {last7DaysPercent !== Infinity ? ( + last {TRENDING_WINDOW_DAYS} days} + stat={ + 0 ? 'trending' : 'stagnant'}> + {last7DaysPercent}% + + } + data-testid="project-header-trending-perc" + /> + ) : null} +
+ ) +} + +type StatBadgeProps = { + type: 'trending' | 'stagnant' +} + +const StatBadge: React.FC> = ({ + type, + children, +}) => { + return ( +
+ + {children} +
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4PayRedeemCard/V4PayRedeemCard.tsx b/src/packages/v4/views/V4ProjectDashboard/V4PayRedeemCard/V4PayRedeemCard.tsx new file mode 100644 index 0000000000..144855e9fe --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4PayRedeemCard/V4PayRedeemCard.tsx @@ -0,0 +1,53 @@ +import { Button } from 'antd' +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { NATIVE_TOKEN, NATIVE_TOKEN_DECIMALS } from 'juice-sdk-core' +import { + useJBContractContext, + useWriteJbMultiTerminalPay, +} from 'juice-sdk-react' +import { useContext, useState } from 'react' +import { parseUnits } from 'viem' + +// TODO wildly incomplete. Needs full redo +export function V4PayRedeemCard({ className }: { className: string }) { + const [value, setValue] = useState('0.0001') + + const { contracts, projectId } = useJBContractContext() + const { addTransaction } = useContext(TxHistoryContext) + const { userAddress } = useWallet() + + const valuePayload = value ? parseUnits(value, NATIVE_TOKEN_DECIMALS) : 0n + + const { writeContractAsync: writePay } = useWriteJbMultiTerminalPay() + + const onWrite = async () => { + if (!value || !contracts.primaryNativeTerminal.data || !userAddress) { + return + } + + const args = [ + projectId, + NATIVE_TOKEN, + valuePayload, + userAddress, + 0n, + `JBM V4 ${projectId}`, // TODO update + '0x0', + ] as const + + const txHash = await writePay({ + address: contracts.primaryNativeTerminal.data, + args, + value: valuePayload, + }) + + addTransaction?.('Pay', { hash: txHash }) + } + + return ( +
+ +
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx index 124b106c3c..0e4c9b2b55 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx @@ -1,14 +1,10 @@ import { CoverPhoto } from 'components/Project/ProjectHeader/CoverPhoto' -import { NativeTokenValue, useJBContractContext, useNativeTokenSurplus } from 'juice-sdk-react' -import { ActivityList } from 'packages/v4/components/ActivityList/ActivitiyList' import { twMerge } from 'tailwind-merge' +import { V4PayRedeemCard } from './V4PayRedeemCard/V4PayRedeemCard' import { V4ProjectHeader } from './V4ProjectHeader' import { V4ProjectTabs } from './V4ProjectTabs/V4ProjectTabs' export function V4ProjectDashboard() { - const { projectId } = useJBContractContext() - const { data: nativeTokenSurplus } = useNativeTokenSurplus() - return ( <>
@@ -23,12 +19,12 @@ export function V4ProjectDashboard() { '[@media(min-width:960px)]:flex [@media(min-width:960px)]:max-w-6xl [@media(min-width:960px)]:justify-between [@media(min-width:960px)]:gap-x-8', )} > - {/* */} + />
-
-
-
- Surplus (overflow):{' '} - {nativeTokenSurplus ? ( - - ) : null} -
-
Project Id: {projectId.toString()}
- -
- -
-
- -
- Pay me -
Input
-
-
) } diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx index 6dba5295d2..716f5ab625 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx @@ -4,12 +4,11 @@ import { Button, Divider } from 'antd' import { Badge } from 'components/Badge' import EthereumAddress from 'components/EthereumAddress' import { GnosisSafeBadge } from 'components/Project/ProjectHeader/GnosisSafeBadge' +import { useSocialLinks } from 'components/Project/ProjectHeader/hooks/useSocialLinks' import { ProjectHeaderLogo } from 'components/Project/ProjectHeader/ProjectHeaderLogo' import { ProjectHeaderPopupMenu } from 'components/Project/ProjectHeader/ProjectHeaderPopupMenu' -import { ProjectHeaderStats } from 'components/Project/ProjectHeader/ProjectHeaderStats' import { SocialLinkButton } from 'components/Project/ProjectHeader/SocialLinkButton' import { Subtitle } from 'components/Project/ProjectHeader/Subtitle' -import { useSocialLinks } from 'components/Project/ProjectHeader/hooks/useSocialLinks' import { TruncatedText } from 'components/TruncatedText' import useMobile from 'hooks/useMobile' import Link from 'next/link' @@ -19,6 +18,7 @@ import { V4OperatorPermission } from 'packages/v4/models/v4Permissions' import { twMerge } from 'tailwind-merge' import { settingsPagePath, v4ProjectRoute } from 'utils/routes' import { useV4ProjectHeader } from './hooks/useV4ProjectHeader' +import { ProjectHeaderStats } from './ProjectHeaderStats' export type SocialLink = 'twitter' | 'discord' | 'telegram' | 'website' diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4CurrentUpcomingConfigurationPanel.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4CurrentUpcomingConfigurationPanel.ts deleted file mode 100644 index 73ed67dd9c..0000000000 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4CurrentUpcomingConfigurationPanel.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useV4CycleSection } from './useV4CycleSection' - - -export const useV4CurrentUpcomingConfigurationPanel = ( - type: 'current' | 'upcoming', -) => { - const cycle = useV4CycleSection(type) - // const token = useTokenSection(type) - // const otherRules = useOtherRulesSection(type) - // const extension = useExtensionSection(type) - - return { - cycle, - // token, - // otherRules, - // extension, - } -} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4CycleSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4CycleSection.ts deleted file mode 100644 index 00c9f371ca..0000000000 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4CycleSection.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ConfigurationPanelTableData } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' -import { useJBRuleset } from 'juice-sdk-react' -import { useJBQueuedRuleset } from 'packages/v4/hooks/useJBQueuedRuleset' -import { usePayoutLimits } from 'packages/v4/hooks/usePayoutLimits' -import { useQueuedPayoutLimits } from 'packages/v4/hooks/useQueuedPayoutLimits' -import { useV4FormatConfigurationCycleSection } from './useV4FormatConfigurationCycleSection' - -export const useV4CycleSection = ( - type: 'current' | 'upcoming', -): ConfigurationPanelTableData => { - const { data: ruleset } = useJBRuleset() - - const { data: queuedRuleset } = useJBQueuedRuleset() - - const { data: payoutLimits } = usePayoutLimits() - const payoutLimitAmount = payoutLimits?.amount - const payoutLimitCurrency = payoutLimits?.currency - - const { data: queuedPayoutLimits } = useQueuedPayoutLimits() - const queuedPayoutLimitAmount = queuedPayoutLimits?.amount - const queuedPayoutLimitCurrency = queuedPayoutLimits?.currency - - return useV4FormatConfigurationCycleSection({ - ruleset, - payoutLimitAmountCurrency: { - amount: payoutLimitAmount, - currency: payoutLimitCurrency, - }, - - queuedRuleset, - upcomingDistributionLimitAmountCurrency: { - distributionLimit: queuedPayoutLimitAmount, - currency: queuedPayoutLimitCurrency, - }, - - // Hide upcoming info from current section. - ...(type === 'current' && { - upcomingFundingCycle: null, - upcomingDistributionLimitAmountCurrency: null, - }), - }) -} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts deleted file mode 100644 index 971c483bbf..0000000000 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { t } from '@lingui/macro'; -import { pairToDatum } from 'components/Project/ProjectHeader/utils/pairToDatum'; -import { ConfigurationPanelDatum } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel'; -import { Ruleset } from 'packages/v4/models/ruleset'; -import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption'; -import { getApprovalStrategyByAddress } from 'packages/v4/utils/approvalHooks'; -import { formatCurrencyAmount } from 'packages/v4/utils/formatV4CurrencyAmount'; -import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math'; -import { useMemo } from 'react'; -import { formatTime } from 'utils/format/formatTime'; -import { timeSecondsToDateString } from 'utils/timeSecondsToDateString'; - -export const useV4FormatConfigurationCycleSection = ({ - ruleset, - payoutLimitAmountCurrency, - queuedRuleset, - upcomingPayoutLimitAmountCurrency, -}: { - ruleset?: Ruleset | null; - payoutLimitAmountCurrency: { - amount: bigint | undefined; - currency: V4CurrencyOption | undefined; - }; - queuedRuleset?: Ruleset | null; - upcomingPayoutLimitAmountCurrency?: { - amount: bigint | undefined; - currency: V4CurrencyOption | undefined; - } | null; -}) => { - const formatDuration = (duration: bigint | undefined) => { - if (duration === undefined) return undefined; - if (duration === 0n) return t`Not set`; - return timeSecondsToDateString(Number(duration), 'short', 'lower'); - }; - - const durationDatum: ConfigurationPanelDatum = useMemo(() => { - const currentDuration = formatDuration(ruleset?.duration); - if (queuedRuleset === null) { - return pairToDatum(t`Duration`, currentDuration, null); - } - const upcomingDuration = formatDuration(queuedRuleset?.duration); - - return pairToDatum(t`Duration`, currentDuration, upcomingDuration); - }, [ruleset?.duration, queuedRuleset]); - - const queuedRulesetStart = ruleset?.start ? - ruleset.start + (ruleset?.duration || 0n) - : 0n; - - const startTimeDatum: ConfigurationPanelDatum = useMemo(() => { - const formattedTime = - queuedRuleset === null - ? formatTime(ruleset?.start) - : ruleset?.duration === 0n - ? t`Any time` - : formatTime(queuedRulesetStart); - - const formatTimeDatum: ConfigurationPanelDatum = { - name: t`Start time`, - new: formattedTime, - easyCopy: true, - }; - return formatTimeDatum; - }, [ruleset?.start, ruleset?.duration, queuedRuleset, queuedRulesetStart]); - - const formatPayoutAmount = ( - amount: bigint | undefined, - currency: V4CurrencyOption | undefined, - ) => { - if (amount === undefined) return undefined; - if (amount === MAX_PAYOUT_LIMIT) return t`Unlimited`; - if (amount === 0n) return t`Zero (no payouts)`; - return formatCurrencyAmount({ - amount: Number(amount) / 1e18, // Assuming fromWad - currency, - }); - }; - - const payoutsDatum: ConfigurationPanelDatum = useMemo(() => { - const { amount, currency } = payoutLimitAmountCurrency ?? {}; - const currentPayout = formatPayoutAmount(amount, currency); - - if (upcomingPayoutLimitAmountCurrency === null) { - return pairToDatum(t`Payouts`, currentPayout, null); - } - - const upcomingPayoutLimit = - upcomingPayoutLimitAmountCurrency?.amount !== undefined - ? upcomingPayoutLimitAmountCurrency.amount - : undefined; - const upcomingPayoutLimitCurrency = - upcomingPayoutLimitAmountCurrency?.currency !== undefined - ? upcomingPayoutLimitAmountCurrency.currency - : undefined; - const upcomingPayout = formatPayoutAmount( - upcomingPayoutLimit, - upcomingPayoutLimitCurrency, - ); - - return pairToDatum(t`Payouts`, currentPayout, upcomingPayout); - }, [payoutLimitAmountCurrency, upcomingPayoutLimitAmountCurrency]); - - const editDeadlineDatum: ConfigurationPanelDatum = useMemo(() => { - const currentApprovalStrategy = ruleset?.approvalHook - ? getApprovalStrategyByAddress(ruleset.approvalHook) - : undefined; - const current = currentApprovalStrategy?.name; - if (queuedRuleset === null) { - return pairToDatum(t`Edit deadline`, current, null); - } - - const upcomingBallotStrategy = queuedRuleset?.approvalHook - ? getApprovalStrategyByAddress(queuedRuleset.approvalHook) - : undefined; - const upcoming = upcomingBallotStrategy?.name; - return pairToDatum(t`Edit deadline`, current, upcoming); - }, [ruleset?.approvalHook, queuedRuleset]); - - return useMemo(() => { - return { - duration: durationDatum, - startTime: startTimeDatum, - payouts: payoutsDatum, - editDeadline: editDeadlineDatum, - }; - }, [durationDatum, startTimeDatum, editDeadlineDatum, payoutsDatum]); -}; diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/ActivityOptions.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/ActivityOptions.tsx new file mode 100644 index 0000000000..e7179bd976 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/ActivityOptions.tsx @@ -0,0 +1,71 @@ +import { ArrowDownTrayIcon } from "@heroicons/react/24/outline" +import { t } from "@lingui/macro" +import { Button } from "antd" +import { ActivityOption, ALL_OPT } from "components/ActivityList" +import { JuiceListbox } from "components/inputs/JuiceListbox" +import { ProjectEventFilter } from "hooks/useProjectEvents" +import { useState } from "react" +import DownloadActivityModal from "./DownloadActivityModal" +import { ActivityEvents } from "./models/ActivityEvents" + +const activityOptions: ActivityOption[] = [ + ALL_OPT(), + { label: t`Paid`, value: 'payEvent' }, + // TODO:: Other events: + // { label: t`Redeemed`, value: 'redeemEvent' }, + // { label: t`Burned`, value: 'burnEvent' }, + // { label: t`Sent payouts`, value: 'distributePayoutsEvent' }, + // { + // label: t`Sent reserved tokens`, + // value: 'distributeReservedTokensEvent', + // }, + // { label: t`Edited cycle`, value: 'configureEvent' }, + // { label: t`Edited payout`, value: 'setFundAccessConstraintsEvent' }, + // { label: t`Transferred ETH to project`, value: 'addToBalanceEvent' }, + // { label: t`Deployed ERC20`, value: 'deployedERC20Event' }, + // { + // label: t`Created a project payer address`, + // value: 'deployETHERC20ProjectPayerEvent', + // }, + // { label: t`Created project`, value: 'projectCreateEvent' }, +] + +export function ActivityOptions({ + events +}: { + events: ActivityEvents +}) { + const [eventFilter, setEventFilter] = useState('all') + + const [downloadModalVisible, setDownloadModalVisible] = useState() + + const activityOption = activityOptions.find(o => o.value === eventFilter) + + const hasAnyEvents = Object.values(events).some(eventArray => eventArray && eventArray.length > 0); + const canDownload = hasAnyEvents + + return ( + <> +
+ {canDownload && ( +
+ setDownloadModalVisible(false)} + /> + + ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/DownloadActivityModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/DownloadActivityModal.tsx new file mode 100644 index 0000000000..15afd5548a --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/DownloadActivityModal.tsx @@ -0,0 +1,106 @@ +import { ArrowDownTrayIcon } from '@heroicons/react/24/outline' +import { t, Trans } from '@lingui/macro' +import { Button, Modal, ModalProps } from 'antd' +import InputAccessoryButton from 'components/buttons/InputAccessoryButton' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import { useJBContractContext } from 'juice-sdk-react' +import { useEffect, useState } from 'react' +import { useBlockNumber } from 'wagmi' +import { useDownloadPayments } from './hooks/useDownloadPayments' + +export default function DownloadActivityModal(props: ModalProps) { + const [blockNumber, setBlockNumber] = useState() + const { projectId: _projectId } = useJBContractContext() + const projectId = Number(_projectId) + + const { data: latestBlockNumber } = useBlockNumber() + + // Use block number 5 blocks behind chain head to allow for subgraph being a bit behind on indexing. + const adjustedLatestBlockNumber = latestBlockNumber ? Number(latestBlockNumber) - 5 : undefined + + const { downloadPayments } = useDownloadPayments(blockNumber ?? 0, projectId); + + useEffect(() => { + setBlockNumber(adjustedLatestBlockNumber) + }, [adjustedLatestBlockNumber]) + + return ( + Download project activity CSVs} + {...props} + > + + + setBlockNumber(val ? parseInt(val) : undefined)} + accessory={ + setBlockNumber(adjustedLatestBlockNumber)} + disabled={blockNumber === adjustedLatestBlockNumber} + /> + } + className="mb-0" + max={adjustedLatestBlockNumber} + /> + +
+ {/* */} + + {/* */} + + + + {/* + + */} +
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx new file mode 100644 index 0000000000..b12f0ea608 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx @@ -0,0 +1,77 @@ +import { t } from '@lingui/macro' +import { ActivityEvent } from 'components/activityEventElems/ActivityElement/ActivityElement' +import Loading from 'components/Loading' +import { NativeTokenValue, useJBContractContext, useJBTokenContext } from 'juice-sdk-react' +import { + OrderDirection, + PayEvent_OrderBy, + PayEventsDocument +} from 'packages/v4/graphql/client/graphql' +import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' +import { ActivityOptions } from './ActivityOptions' +import { PayEvent } from './models/ActivityEvents' +import { transformPayEventsRes } from './utils/transformEventsData' + + +export function V4ActivityList() { + const { token } = useJBTokenContext() + const { projectId } = useJBContractContext() + + // TODO: pageSize (pagination) + const { data: payEventsData, isLoading } = useSubgraphQuery(PayEventsDocument, { + orderBy: PayEvent_OrderBy.timestamp, + orderDirection: OrderDirection.desc, + where: { + projectId: Number(projectId), + }, + }) + + const payEvents = transformPayEventsRes(payEventsData) ?? [] + + if (!token?.data?.symbol) return null + return ( +
+
+

Activity

+ +
+
+ {isLoading && } + {isLoading || (payEvents && payEvents.length > 0) ? ( + payEvents?.map((event: PayEvent) => { + return ( +
+ + + + } + extra={ + + bought {event.beneficiaryTokenCount?.format(6)} {token.data?.symbol} + + } + /> +
+ ) + }) + ) : ( + No activity yet. + )} +
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx new file mode 100644 index 0000000000..311748a86c --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx @@ -0,0 +1,43 @@ + +import { Trans } from '@lingui/macro' +import { ErrorBoundaryCallout } from 'components/Callout/ErrorBoundaryCallout' +import Loading from 'components/Loading' +import VolumeChart from 'components/VolumeChart' +import { PV_V4 } from 'constants/pv' +import { useJBContractContext } from 'juice-sdk-react' +import { ProjectsDocument } from 'packages/v4/graphql/client/graphql' +import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' +import { Suspense } from 'react' +import { V4ActivityList } from './V4ActivityList' + +export function V4ActivityPanel() { + const { projectId } = useJBContractContext() + const { data } = useSubgraphQuery(ProjectsDocument, { + where: { + projectId: Number(projectId), + }, + }) + const createdAt = data?.projects?.[0].createdAt + + return ( +
+ {Boolean(projectId) && ( +
+ }> + Volume chart failed to load.} + > + + + +
+ )} + +
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/hooks/useDownloadPayments.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/hooks/useDownloadPayments.ts new file mode 100644 index 0000000000..0ef53cd166 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/hooks/useDownloadPayments.ts @@ -0,0 +1,70 @@ +import { t } from "@lingui/macro"; +import { OrderDirection, PayEvent_OrderBy, PayEventsDocument } from "packages/v4/graphql/client/graphql"; +import { useSubgraphQuery } from "packages/v4/graphql/useSubgraphQuery"; +import { useCallback, useState } from 'react'; +import { downloadCsvFile } from "utils/csv"; +import { emitErrorNotification } from 'utils/notifications'; +import { transformPayEventsRes } from "../utils/transformEventsData"; + + +export const useDownloadPayments = (blockNumber: number, projectId: number) => { + const [isLoading, setIsLoading] = useState(false); + + const { data: payEventsData } = useSubgraphQuery(PayEventsDocument, { + orderBy: PayEvent_OrderBy.timestamp, + orderDirection: OrderDirection.desc, + where: { + projectId: Number(projectId), + }, + }); + + const downloadPayments = useCallback(async () => { + if (blockNumber === undefined || !projectId) return; + + setIsLoading(true); + + const rows = [ + [ + t`Date`, + t`ETH paid`, + // t`USD value of ETH paid`, //TODO: not working for V4 (check subgraph + t`Payer`, + t`Transaction hash`, + ], // CSV header row + ]; + + try { + const payEvents = transformPayEventsRes(payEventsData); + + if (!payEvents) { + emitErrorNotification(t`Error loading payouts`); + throw new Error('No data.'); + } + + // Interpolate distributions into payouts. + let x = 0; + payEvents.forEach(p => { + let date = new Date((p.timestamp ?? 0) * 1000).toUTCString() + + if (date.includes(', ')) date = date.split(', ')[1] + + rows.push([ + date, + p.amount.format(), + // p.amountUSD ? p.amountUSD.format() : 'n/a', TODO: not working for V4 (check subgraph) + p.beneficiary, + p.txHash, + ]) + }) + + downloadCsvFile(`payments_v4_p${projectId}_block-${blockNumber}`, rows) + } catch (e) { + console.error('Error downloading payouts', e); + emitErrorNotification(t`Error downloading payouts, try again.`); + } finally { + setIsLoading(false); + } + }, [blockNumber, projectId, payEventsData]); + + return { downloadPayments, isLoading }; +}; diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/models/ActivityEvents.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/models/ActivityEvents.ts new file mode 100644 index 0000000000..f960855f98 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/models/ActivityEvents.ts @@ -0,0 +1,17 @@ +import { Ether, JBProjectToken } from 'juice-sdk-core' +import { Address } from 'viem' + +export type PayEvent = { + id: string + amount: Ether + amountUSD: Ether | undefined + beneficiary: Address + beneficiaryTokenCount?: JBProjectToken + timestamp: number + txHash: string +} + +export type ActivityEvents = { + payEvents: PayEvent[] + // TODO: other event types +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/utils/transformEventsData.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/utils/transformEventsData.ts new file mode 100644 index 0000000000..243a00239d --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/utils/transformEventsData.ts @@ -0,0 +1,21 @@ +import { Ether, JBProjectToken } from 'juice-sdk-core' +import { PayEventsQuery } from 'packages/v4/graphql/client/graphql' +import { PayEvent } from '../models/ActivityEvents' + +export function transformPayEventsRes( + data: PayEventsQuery | undefined, +): PayEvent[] | undefined { + return data?.payEvents.map(event => { + return { + id: event.id, + amount: new Ether(BigInt(event.amount)), + amountUSD: event.amountUSD ? new Ether(BigInt(event.amountUSD)) : undefined, + beneficiary: event.beneficiary, + beneficiaryTokenCount: new JBProjectToken( + BigInt(event.beneficiaryTokenCount), + ), + timestamp: event.timestamp, + txHash: event.txHash, + } + }) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4ConfigurationDisplayCard.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4ConfigurationDisplayCard.tsx similarity index 100% rename from src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4ConfigurationDisplayCard.tsx rename to src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4ConfigurationDisplayCard.tsx diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4CurrentUpcomingConfigurationPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4CurrentUpcomingConfigurationPanel.tsx similarity index 100% rename from src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4CurrentUpcomingConfigurationPanel.tsx rename to src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4CurrentUpcomingConfigurationPanel.tsx diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4CurrentUpcomingSubPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4CurrentUpcomingSubPanel.tsx similarity index 66% rename from src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4CurrentUpcomingSubPanel.tsx rename to src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4CurrentUpcomingSubPanel.tsx index bf0c27cb75..fcba8b6663 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4CurrentUpcomingSubPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4CurrentUpcomingSubPanel.tsx @@ -3,15 +3,24 @@ import { Trans, t } from '@lingui/macro' import { currentCycleRemainingLengthTooltip } from 'components/Project/ProjectTabs/CyclesPayoutsTab/CyclesPanelTooltips' import { UpcomingCycleChangesCallout } from 'components/Project/ProjectTabs/CyclesPayoutsTab/UpcomingCycleChangesCallout' import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/TitleDescriptionDisplayCard' -import { useMemo } from 'react' +import { RulesetCountdownProvider } from 'packages/v4/contexts/RulesetCountdownProvider' import { twMerge } from 'tailwind-merge' +import { useRulesetCountdown } from '../../hooks/useRulesetCountdown' import { useV4CurrentUpcomingSubPanel } from '../../hooks/useV4CurrentUpcomingSubPanel' -import { V4ConfigurationDisplayCard } from './V4ConfigurationDisplayCard' import { useV4UpcomingRulesetHasChanges } from './hooks/useV4UpcomingRulesetHasChanges' +import { V4ConfigurationDisplayCard } from './V4ConfigurationDisplayCard' +import { V4PayoutsSubPanel } from './V4PayoutsSubPanel' + +function CountdownClock({ rulesetUnlocked }: { rulesetUnlocked: boolean }) { + const { timeRemainingText } = useRulesetCountdown() + + const remainingTime = rulesetUnlocked ? '-' : timeRemainingText -const CYCLE_NUMBER_INDEX = 0 -const STATUS_INDEX = 1 -const CYCLE_LENGTH_INDEX = 2 + if (!remainingTime) { + return + } + return <>{remainingTime} +} export const V4CurrentUpcomingSubPanel = ({ id, @@ -21,42 +30,10 @@ export const V4CurrentUpcomingSubPanel = ({ const info = useV4CurrentUpcomingSubPanel(id) const { hasChanges, loading } = useV4UpcomingRulesetHasChanges() - const topPanelsInfo = useMemo(() => { - const topPanelInfo = [ - { - title: t`Ruleset #`, - value: info.rulesetNumber, - }, - { - title: t`Status`, - value: info.status, - }, - ] - if (info.type === 'current') { - topPanelInfo.push({ - title: t`Remaining time`, - value: info.remainingTime, - }) - } - if (info.type === 'upcoming') { - topPanelInfo.push({ - title: t`Ruleset length`, - value: info.rulesetLength, - }) - } - return topPanelInfo - }, [ - info.rulesetLength, - info.rulesetNumber, - info.remainingTime, - info.status, - info.type, - ]) - const rulesetLengthTooltip = info.type === 'current' ? currentCycleRemainingLengthTooltip : undefined - const rulesetLengthValue = topPanelsInfo[CYCLE_LENGTH_INDEX].value + const rulesetLengthValue = info.rulesetLength?.toString() const rulesetStatusTooltip = info.currentRulesetUnlocked ? ( The project's rules are unlocked and can change at any time. @@ -114,34 +91,46 @@ export const V4CurrentUpcomingSubPanel = ({
- } + title={t`Ruleset #`} + description={info.rulesetNumber?.toString() ?? } /> + info.status?.toString() ?? } tooltip={rulesetStatusTooltip} /> - - ) - } - tooltip={rulesetLengthTooltip} - /> + + {info.type === 'current' ? ( + + + + } + tooltip={rulesetLengthTooltip} + /> + ) : ( + + } + tooltip={rulesetLengthTooltip} + /> + )}
- {/* */} + ) } diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4CyclesPayoutsPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4CyclesPayoutsPanel.tsx similarity index 100% rename from src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/V4CyclesPayoutsPanel.tsx rename to src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4CyclesPayoutsPanel.tsx diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx new file mode 100644 index 0000000000..eef3a5898c --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx @@ -0,0 +1,155 @@ +import { t, Trans } from '@lingui/macro' +import { Form } from 'antd' +import InputAccessoryButton from 'components/buttons/InputAccessoryButton' +import { Callout } from 'components/Callout/Callout' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import TransactionModal from 'components/modals/TransactionModal' +import { FEES_EXPLANATION } from 'components/strings' +import { PayoutsTable } from 'packages/v4/components/PayoutsTable/PayoutsTable' +import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' +import { useUsedPayoutLimitOf } from 'packages/v4/hooks/useUsedPayoutLimitOf' +import { useV4BalanceOfNativeTerminal } from 'packages/v4/hooks/useV4BalanceOfNativeTerminal' +import { useV4CurrentPayoutSplits } from 'packages/v4/hooks/useV4PayoutSplits' +import { V4CurrencyName } from 'packages/v4/utils/currency' +import { useEffect, useState } from 'react' +import { fromWad } from 'utils/format/formatNumber' +import { useV4DistributableAmount } from './hooks/useV4DistributableAmount' + +export default function V4DistributePayoutsModal({ + open, + onCancel, + onConfirmed, +}: { + open?: boolean + onCancel?: VoidFunction + onConfirmed?: VoidFunction +}) { + const { splits: payoutSplits } = useV4CurrentPayoutSplits() + const { data: usedPayoutLimit } = useUsedPayoutLimitOf() + const { data: payoutLimit } = usePayoutLimit() + const { data: balanceOfNativeTerminal } = useV4BalanceOfNativeTerminal() + const { distributableAmount: distributable } = useV4DistributableAmount() + + const payoutLimitAmount = payoutLimit?.amount + const payoutLimitAmountCurrency = payoutLimit?.currency + + const [transactionPending, setTransactionPending] = useState() + const [loading, setLoading] = useState() + const [distributionAmount, setDistributionAmount] = useState() + + // TODO: const v4DistributePayoutsTx = useV4DistributePayoutsTx() + + useEffect(() => { + if (!payoutLimitAmount) return + + const unusedFunds = payoutLimitAmount - (usedPayoutLimit ?? 0n) ?? 0n + const distributable = balanceOfNativeTerminal && balanceOfNativeTerminal > unusedFunds + ? unusedFunds + : 0n + + setDistributionAmount(fromWad(distributable)) + }, [ + balanceOfNativeTerminal, + payoutLimitAmount, + usedPayoutLimit, + ]) + + async function executeDistributePayoutsTx() { + if (!payoutLimitAmountCurrency || !distributionAmount) return + + setLoading(true) + + const txSuccessful = true + // TODO: const txSuccessful = await v4DistributePayoutsTx( + // { + // amount: distributionAmount, + // currency: payoutLimitAmountCurrency, + // }, + // { + // onDone: () => { + // setTransactionPending(true) + // }, + // onConfirmed: () => { + // setLoading(false) + // setTransactionPending(false) + // onConfirmed?.() + // }, + // }, + // ) + + if (!txSuccessful) { + setLoading(false) + setTransactionPending(false) + } + } + + const currencyName = V4CurrencyName(payoutLimitAmountCurrency) + + return ( + Send payouts} + open={open} + onOk={executeDistributePayoutsTx} + onCancel={() => { + setDistributionAmount(undefined) + onCancel?.() + }} + okButtonProps={{ + disabled: !distributionAmount || distributionAmount === '0', + }} + confirmLoading={loading} + transactionPending={transactionPending} + okText={t`Send payouts`} + connectWalletText={t`Connect wallet to send payouts`} + width={640} + className="top-[40px]" + > +
+
+ Amount to pay out} + extra={Recipients will recieve payouts in ETH} + > + setDistributionAmount(value)} + min={0} + accessory={ +
+ + {currencyName} + + MAX} + onClick={() => + setDistributionAmount(distributable.format()) + } + /> +
+ } + /> +
+
+
+
+ Payout recipients +
+ +
+ +
+
+ + {FEES_EXPLANATION} +
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4ExportPayoutsCsvItem.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4ExportPayoutsCsvItem.tsx new file mode 100644 index 0000000000..6b7d46cc6f --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4ExportPayoutsCsvItem.tsx @@ -0,0 +1,50 @@ +import { ArrowUpTrayIcon } from '@heroicons/react/24/outline' +import { Trans, t } from '@lingui/macro' +import { Button, Tooltip } from 'antd' +import { useJBRuleset } from 'juice-sdk-react' +import { twMerge } from 'tailwind-merge' +import { useV4CurrentUpcomingPayoutSplits } from './hooks/useV4CurrentUpcomingPayoutSplits' +import { useV4ExportSplitsToCsv } from './hooks/useV4ExportSplitsToCsv' + +export const V4ExportPayoutsCsvItem = ({ + type, +}: { + type: 'current' | 'upcoming' +}) => { + const { splits: payoutSplits, isLoading } = useV4CurrentUpcomingPayoutSplits(type) + const { data: ruleset } = useJBRuleset() + const fcNumber = ruleset + ? type === 'current' + ? Number(ruleset.cycleNumber) + : Number(ruleset.cycleNumber) + 1 + : undefined + const disabled = !payoutSplits?.length + const { exportSplitsToCsv } = useV4ExportSplitsToCsv( + payoutSplits ?? [], + 'payouts', + fcNumber, + ) + + return ( + + + + ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4PayoutsSubPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4PayoutsSubPanel.tsx new file mode 100644 index 0000000000..e1e3331f43 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4PayoutsSubPanel.tsx @@ -0,0 +1,93 @@ +import { Trans, t } from '@lingui/macro' +import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/TitleDescriptionDisplayCard' +import { useMemo } from 'react' +import { twMerge } from 'tailwind-merge' +import { useV4PayoutsSubPanel } from './hooks/useV4PayoutsSubPanel' +import { V4ExportPayoutsCsvItem } from './V4ExportPayoutsCsvItem' +import { V4ProjectAllocationRow } from './V4ProjectAllocationRow' +import { V4SendPayoutsButton } from './V4SendPayoutsButton' +import { V4TreasuryStats } from './V4TreasuryStats' + +export const V4PayoutsSubPanel = ({ + className, + type, +}: { + className?: string + type: 'current' | 'upcoming' +}) => { + const { payouts, isLoading, totalPayoutAmount, payoutLimit } = + useV4PayoutsSubPanel(type) + + const hasPayouts = useMemo(() => { + if (!payouts || payouts.length === 0 || payoutLimit === 0n) + return false + return true + }, [payouts, payoutLimit]) + return ( +
+

+ Treasury & Payouts +

+
+ {type === 'current' && } + + , + }, + ], + }, + } + : {})} + > +
+ {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + )) + ) : hasPayouts ? ( + payouts?.map((payout, i) => ( + + )) + ) : ( + + None + + )} +
+ {hasPayouts && type === 'current' && ( + + )} +
+
+
+ ) +} + +const ProjectAllocationSkeleton = () => ( +
+
+ + + + +
+
+ +
+
+) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4ProjectAllocationRow.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4ProjectAllocationRow.tsx new file mode 100644 index 0000000000..6ab33ff5b1 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4ProjectAllocationRow.tsx @@ -0,0 +1,57 @@ +import { JuiceboxAccountLink } from 'components/JuiceboxAccountLink' +import { useRouter } from 'next/router' +import V4ProjectHandleLink from 'packages/v4/components/V4ProjectHandleLink' +import { ReactNode } from 'react' + +type V4ProjectAllocationRowProps = { + projectId: number | undefined + address: string + amount?: ReactNode | string + percent: string +} + +export const V4ProjectAllocationRow: React.FC = ({ + address, + projectId, + amount, + percent, +}) => { + const router = useRouter() + const { chainName } = router.query + return ( +
+
+ + {projectId ? ( + <> + + + ) : ( + + )} + +
+
+ {amount ? ( + <> + {amount} + ({percent}) + + ) : ( + {percent} + )} +
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4SendPayoutsButton.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4SendPayoutsButton.tsx new file mode 100644 index 0000000000..e65c98916d --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4SendPayoutsButton.tsx @@ -0,0 +1,47 @@ +import { ArrowUpCircleIcon } from '@heroicons/react/24/outline' +import { Trans } from '@lingui/macro' +import { Button, Tooltip } from 'antd' +import { useCallback, useMemo, useState } from 'react' +import { twMerge } from 'tailwind-merge' +import { useV4DistributableAmount } from './hooks/useV4DistributableAmount' +import V4DistributePayoutsModal from './V4DistributePayoutsModal' + +export const V4SendPayoutsButton = ({ + className, + containerClassName, +}: { + className?: string + containerClassName?: string +}) => { + const { distributableAmount } = useV4DistributableAmount() + const [open, setOpen] = useState(false) + const openModal = useCallback(() => setOpen(true), []) + const closeModal = useCallback(() => setOpen(false), []) + + const distributeButtonDisabled = useMemo(() => { + return distributableAmount.value === 0n + }, [distributableAmount]) + + return ( + No payouts remaining for this cycle.} + open={distributeButtonDisabled ? undefined : false} + className={containerClassName} + > + + + + ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4TreasuryStats.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4TreasuryStats.tsx new file mode 100644 index 0000000000..fcfc62e61f --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4TreasuryStats.tsx @@ -0,0 +1,48 @@ +import { Trans, t } from '@lingui/macro' +import { + availableToPayOutTooltip, + treasuryBalanceTooltip, +} from 'components/Project/ProjectTabs/CyclesPayoutsTab/CyclesPanelTooltips' +import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/TitleDescriptionDisplayCard' +import { useMemo } from 'react' +import { useV4TreasuryStats } from './hooks/useV4TreasuryStats' + +export const V4TreasuryStats = () => { + const { availableToPayout, surplus, treasuryBalance, redemptionRate } = + useV4TreasuryStats() + + const surplusTooltip = useMemo( + () => + redemptionRate && redemptionRate.toFloat() > 0 ? ( + + {surplus} is available for token redemptions or future payouts. + + ) : ( + {surplus} is available for future payouts. + ), + [surplus, redemptionRate], + ) + + return ( +
+ + + +
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingConfigurationPanel.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingConfigurationPanel.ts new file mode 100644 index 0000000000..57c34443c8 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingConfigurationPanel.ts @@ -0,0 +1,21 @@ +import { useV4CycleSection } from './useV4CycleSection' +import { useV4ExtensionSection } from './useV4ExtensionSection' +import { useV4OtherRulesSection } from './useV4OtherRulesSection' +import { useV4TokenSection } from './useV4TokenSection' + + +export const useV4CurrentUpcomingConfigurationPanel = ( + type: 'current' | 'upcoming', +) => { + const cycle = useV4CycleSection(type) + const token = useV4TokenSection(type) + const otherRules = useV4OtherRulesSection(type) + const extension = useV4ExtensionSection(type) + + return { + cycle, + token, + otherRules, + extension, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutLimit.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutLimit.ts new file mode 100644 index 0000000000..1172372901 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutLimit.ts @@ -0,0 +1,23 @@ +import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' +import { useUpcomingPayoutLimit } from 'packages/v4/hooks/useUpcomingPayoutLimit' + +export const useV4CurrentUpcomingPayoutLimit = ( + type: 'current' | 'upcoming', +) => { + const { data: payoutLimit, isLoading: currentLoading } = usePayoutLimit() + + const { data: upcomingPayoutLimit, isLoading: upcomingLoading } = useUpcomingPayoutLimit() + + if (type === 'current') { + return { + payoutLimit: payoutLimit?.amount, + payoutLimitCurrency: payoutLimit?.currency, + loading: currentLoading, + } + } + return { + distributionLimit: upcomingPayoutLimit.amount, + distributionLimitCurrency: upcomingPayoutLimit.currency, + loading: upcomingLoading + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts new file mode 100644 index 0000000000..0813a0d511 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts @@ -0,0 +1,46 @@ +import { JBSplit, SplitPortion } from 'juice-sdk-core' +import { + useJBContractContext, + useReadJbSplitsSplitsOf, + useReadJbTokensTokenOf, +} from 'juice-sdk-react' +import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' +import { useV4CurrentPayoutSplits } from 'packages/v4/hooks/useV4PayoutSplits' + +export const useV4CurrentUpcomingPayoutSplits = ( + type: 'current' | 'upcoming', +) => { + const { projectId } = useJBContractContext() + const { data: tokenAddress } = useReadJbTokensTokenOf() + const { splits, isLoading: currentSplitsLoading } = useV4CurrentPayoutSplits() + + const { ruleset: upcomingRuleset, isLoading: upcomingRulesetLoading } = + useJBUpcomingRuleset() + + const { data: _upcomingSplits, isLoading: upcomingSplitsLoading } = + useReadJbSplitsSplitsOf({ + args: [ + projectId, + BigInt(upcomingRuleset?.id ?? 0), + BigInt(tokenAddress ?? 0), + ], + query: { + select(data) { + return data.map(d => ({ + ...d, + percent: new SplitPortion(d.percent), + })) + }, + }, + }) + + const upcomingSplits: JBSplit[] = _upcomingSplits ? [..._upcomingSplits] : [] + + if (type === 'current') { + return { splits, isLoading: currentSplitsLoading } + } + return { + splits: upcomingSplits, + isLoading: upcomingSplitsLoading || upcomingRulesetLoading, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CycleSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CycleSection.ts new file mode 100644 index 0000000000..e8dcc0f9b7 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CycleSection.ts @@ -0,0 +1,43 @@ +import { ConfigurationPanelTableData } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { useJBRuleset } from 'juice-sdk-react' +import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' +import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' +import { useUpcomingPayoutLimit } from 'packages/v4/hooks/useUpcomingPayoutLimit' +import { useV4FormatConfigurationCycleSection } from './useV4FormatConfigurationCycleSection' + +export const useV4CycleSection = ( + type: 'current' | 'upcoming', +): ConfigurationPanelTableData => { + const { data: ruleset } = useJBRuleset() + + const { ruleset: upcomingRuleset, isLoading: upcomingRulesetLoading } = useJBUpcomingRuleset() + + const { data: payoutLimits } = usePayoutLimit() + const payoutLimitAmount = payoutLimits?.amount + const payoutLimitCurrency = payoutLimits?.currency + + const { data: upcomingPayoutLimit, isLoading: upcomingPayoutLimitLoading } = useUpcomingPayoutLimit() + const upcomingPayoutLimitAmount = upcomingPayoutLimit?.amount + const upcomingPayoutLimitCurrency = upcomingPayoutLimit?.currency + + return useV4FormatConfigurationCycleSection({ + ruleset, + payoutLimitAmountCurrency: { + amount: payoutLimitAmount, + currency: payoutLimitCurrency, + }, + upcomingRulesetLoading, + upcomingPayoutLimitLoading, + upcomingRuleset, + upcomingPayoutLimitAmountCurrency: { + amount: upcomingPayoutLimitAmount, + currency: upcomingPayoutLimitCurrency, + }, + + // Hide upcoming info from current section. + ...(type === 'current' && { + upcomingRuleset: null, + upcomingPayoutLimitAmountCurrency: null, + }), + }) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4DistributableAmount.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4DistributableAmount.ts new file mode 100644 index 0000000000..5929caacc7 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4DistributableAmount.ts @@ -0,0 +1,32 @@ +import { JBProjectToken } from 'juice-sdk-core' +import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' +import { useUsedPayoutLimitOf } from 'packages/v4/hooks/useUsedPayoutLimitOf' +import { useV4BalanceOfNativeTerminal } from 'packages/v4/hooks/useV4BalanceOfNativeTerminal' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' + +export const useV4DistributableAmount = () => { + const { data: usedPayoutLimit } = useUsedPayoutLimitOf() + + const { data: _treasuryBalance } = useV4BalanceOfNativeTerminal() + + const { data: payoutLimit } = usePayoutLimit() + + const effectiveDistributionLimit = payoutLimit?.amount ?? MAX_PAYOUT_LIMIT + const distributedAmount = usedPayoutLimit ?? 0n + const treasuryBalance = + _treasuryBalance ?? 0n + + const distributable = + effectiveDistributionLimit === 0n + ? effectiveDistributionLimit + : effectiveDistributionLimit - distributedAmount + + const distributableAmount = treasuryBalance > distributable + ? distributable + : treasuryBalance + + return { + distributableAmount: new JBProjectToken(distributableAmount), + currency: payoutLimit?.currency, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4ExportSplitsToCsv.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4ExportSplitsToCsv.ts new file mode 100644 index 0000000000..75a75392ad --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4ExportSplitsToCsv.ts @@ -0,0 +1,88 @@ +import { t } from '@lingui/macro' +import { JBSplit } from 'juice-sdk-core' +import { useJBContractContext } from 'juice-sdk-react' +import useProjectOwnerOf from 'packages/v4/hooks/useV4ProjectOwnerOf' +import { v4GetProjectOwnerRemainderSplit } from 'packages/v4/utils/v4Splits' +import { useState } from 'react' +import { downloadCsvFile } from 'utils/csv' +import { emitErrorNotification } from 'utils/notifications' +import { Hash } from 'viem' + +const CSV_HEADER = [ + 'beneficiary', + 'percent', + 'preferAddToBalance', + 'lockedUntil', + 'projectId', + 'hook', +] + +const splitToCsvRow = (split: JBSplit) => { + return [ + split.beneficiary, + `${split.percent.formatPercentage()}%`, + `${split.preferAddToBalance}`, + `${split.lockedUntil}`, + split.projectId.toString(), + split.hook, + ] +} + +const prepareSplitsCsv = ( + splits: JBSplit[], + projectOwnerAddress: Hash, +): (string | undefined)[][] => { + const csvContent = splits.map(splitToCsvRow) + + const rows = [CSV_HEADER, ...csvContent] + + const projectOwnerSplit = v4GetProjectOwnerRemainderSplit( + projectOwnerAddress, + splits, + ) + if (projectOwnerSplit.percent.toFloat() > 0) { + rows.push(splitToCsvRow(projectOwnerSplit)) + } + + return rows +} + +export const useV4ExportSplitsToCsv = ( + splits: JBSplit[], + splitName = 'splits', + fcNumber?: number, +) => { + const { data: projectOwnerAddress } = useProjectOwnerOf() + const { projectId } = useJBContractContext() + const [loading, setLoading] = useState(false) + + const handle = undefined + + const exportSplitsToCsv = () => { + if (!splits || !projectOwnerAddress) { + emitErrorNotification( + t`CSV data wasn't ready for export. Wait a few seconds and try again.`, + ) + return + } + + setLoading(true) + + try { + const csvContent = prepareSplitsCsv(splits, projectOwnerAddress) + const projectIdentifier = handle ? `@${handle}` : `project-${projectId}` + const filename = `${projectIdentifier}_${splitName}${ + fcNumber ? `_fc-${fcNumber}` : '' + }` + + downloadCsvFile(filename, csvContent) + } catch (e) { + console.error(e) + emitErrorNotification(t`CSV download failed.`) + } finally { + setLoading(false) + } + } + + return { exportSplitsToCsv, loading } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4ExtensionSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4ExtensionSection.ts new file mode 100644 index 0000000000..513d301ef7 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4ExtensionSection.ts @@ -0,0 +1,22 @@ +import { ConfigurationPanelTableData } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { useJBRulesetMetadata } from 'juice-sdk-react' +import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' +import { useV4FormatConfigurationExtensionSection } from './useV4FormatConfigurationExtensionSection' + +export const useV4ExtensionSection = ( + type: 'current' | 'upcoming', +): ConfigurationPanelTableData | null => { + const { data: rulesetMetadata } = useJBRulesetMetadata() + const { + rulesetMetadata: upcomingRulesetMetadata, + } = useJBUpcomingRuleset() + + + return useV4FormatConfigurationExtensionSection({ + rulesetMetadata, + upcomingRulesetMetadata, + ...(type === 'current' && { + upcomingRulesetMetadata: null, + }), + }) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts new file mode 100644 index 0000000000..23e017d81d --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts @@ -0,0 +1,142 @@ +import { t } from '@lingui/macro' +import { ConfigurationPanelDatum } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' +import { JBRulesetData } from 'juice-sdk-core' +import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption' +import { getApprovalStrategyByAddress } from 'packages/v4/utils/approvalHooks' +import { formatCurrencyAmount } from 'packages/v4/utils/formatCurrencyAmount' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' +import { useMemo } from 'react' +import { formatTime } from 'utils/format/formatTime' +import { timeSecondsToDateString } from 'utils/timeSecondsToDateString' + +export const useV4FormatConfigurationCycleSection = ({ + ruleset, + payoutLimitAmountCurrency, + upcomingRuleset, + upcomingRulesetLoading, + upcomingPayoutLimitLoading, + upcomingPayoutLimitAmountCurrency, +}: { + ruleset?: JBRulesetData | null + payoutLimitAmountCurrency: { + amount: bigint | undefined + currency: V4CurrencyOption | undefined + } + upcomingRuleset?: JBRulesetData | null + upcomingRulesetLoading: boolean + upcomingPayoutLimitLoading: boolean + upcomingPayoutLimitAmountCurrency?: { + amount: bigint | undefined + currency: V4CurrencyOption | undefined + } | null +}) => { + const formatDuration = (duration: number | undefined) => { + if (duration === undefined) return undefined + if (duration === 0) return t`Not set` + return timeSecondsToDateString(Number(duration), 'short', 'lower') + } + + const durationDatum: ConfigurationPanelDatum = useMemo(() => { + const currentDuration = formatDuration(ruleset?.duration) + if (upcomingRuleset === null || upcomingRulesetLoading) { + return pairToDatum(t`Duration`, currentDuration, null) + } + const upcomingDuration = formatDuration( + upcomingRuleset ? upcomingRuleset?.duration : ruleset?.duration, + ) + + return pairToDatum(t`Duration`, currentDuration, upcomingDuration) + }, [ruleset?.duration, upcomingRuleset, upcomingRulesetLoading]) + + const upcomingRulesetStart = ruleset?.start + ? ruleset.start + (ruleset?.duration || 0) + : 0 + + const startTimeDatum: ConfigurationPanelDatum = useMemo(() => { + const formattedTime = + upcomingRuleset === null + ? formatTime(ruleset?.start) + : ruleset?.duration === 0 + ? t`Any time` + : formatTime(upcomingRulesetStart) + + const formatTimeDatum: ConfigurationPanelDatum = { + name: t`Start time`, + new: formattedTime, + easyCopy: true, + } + return formatTimeDatum + }, [ruleset?.start, ruleset?.duration, upcomingRuleset, upcomingRulesetStart]) + + const formatPayoutAmount = ( + amount: bigint | undefined, + currency: V4CurrencyOption | undefined, + ) => { + if (amount === undefined || amount === MAX_PAYOUT_LIMIT) return t`Unlimited` + if (amount === 0n) return t`Zero (no payouts)` + return formatCurrencyAmount({ + amount: Number(amount) / 1e18, // Assuming fromWad + currency, + }) + } + + const payoutsDatum: ConfigurationPanelDatum = useMemo(() => { + const { amount, currency } = payoutLimitAmountCurrency ?? {} + const currentPayout = formatPayoutAmount(amount, currency) + + if ( + upcomingPayoutLimitAmountCurrency === null || + upcomingPayoutLimitLoading + ) { + return pairToDatum(t`Payouts`, currentPayout, null) + } + + const upcomingPayoutLimit = + upcomingPayoutLimitAmountCurrency?.amount !== undefined + ? upcomingPayoutLimitAmountCurrency.amount + : amount + const upcomingPayoutLimitCurrency = + upcomingPayoutLimitAmountCurrency?.currency !== undefined + ? upcomingPayoutLimitAmountCurrency.currency + : currency + const upcomingPayout = formatPayoutAmount( + upcomingPayoutLimit, + upcomingPayoutLimitCurrency, + ) + + return pairToDatum(t`Payouts`, currentPayout, upcomingPayout) + }, [ + payoutLimitAmountCurrency, + upcomingPayoutLimitAmountCurrency, + upcomingPayoutLimitLoading, + ]) + + const editDeadlineDatum: ConfigurationPanelDatum = useMemo(() => { + const currentApprovalStrategy = ruleset?.approvalHook + ? getApprovalStrategyByAddress(ruleset.approvalHook) + : undefined + const current = currentApprovalStrategy?.name + if (upcomingRuleset === null || upcomingPayoutLimitLoading) { + return pairToDatum(t`Edit deadline`, current, null) + } + + const upcomingBallotStrategy = upcomingRuleset?.approvalHook + ? getApprovalStrategyByAddress(upcomingRuleset.approvalHook) + : ruleset?.approvalHook + ? getApprovalStrategyByAddress(ruleset.approvalHook) + : undefined + + const upcoming = upcomingBallotStrategy?.name + return pairToDatum(t`Edit deadline`, current, upcoming) + }, [ruleset?.approvalHook, upcomingRuleset, upcomingPayoutLimitLoading]) + + return useMemo(() => { + return { + duration: durationDatum, + startTime: startTimeDatum, + payouts: payoutsDatum, + editDeadline: editDeadlineDatum, + } + }, [durationDatum, startTimeDatum, editDeadlineDatum, payoutsDatum]) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationExtensionSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationExtensionSection.ts new file mode 100644 index 0000000000..80805fc0a9 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationExtensionSection.ts @@ -0,0 +1,101 @@ +import { t } from '@lingui/macro' +import { + ConfigurationPanelDatum, + ConfigurationPanelTableData, +} from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairToDatum' +import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' +import { JBRulesetMetadata } from 'juice-sdk-core' +import { useMemo } from 'react' +import { isZeroAddress } from 'utils/address' +import { etherscanLink } from 'utils/etherscan' + +export const useV4FormatConfigurationExtensionSection = ({ + rulesetMetadata, + upcomingRulesetMetadata, +}: { + rulesetMetadata: JBRulesetMetadata | null | undefined + upcomingRulesetMetadata: JBRulesetMetadata | null | undefined +}): ConfigurationPanelTableData | null => { + const contractDatum: ConfigurationPanelDatum = useMemo(() => { + const currentContract = rulesetMetadata?.dataHook + const upcomingContract = upcomingRulesetMetadata?.dataHook + + if (upcomingRulesetMetadata === null) { + const link = currentContract + ? etherscanLink('address', currentContract) + : undefined + return pairToDatum(t`Contract`, currentContract, null, link, true) + } + + const link = upcomingContract + ? etherscanLink('address', upcomingContract) + : currentContract + ? etherscanLink('address', currentContract) + : undefined + return pairToDatum( + t`Contract`, + currentContract, + upcomingContract, + link, + true, + ) + }, [rulesetMetadata?.dataHook, upcomingRulesetMetadata]) + + const useForPaymentsDatum: ConfigurationPanelDatum = useMemo(() => { + const currentUseForPayments = rulesetMetadata?.useDataHookForPay + const upcomingUseForPayments = + upcomingRulesetMetadata?.useDataHookForPay + + if (upcomingRulesetMetadata === null) { + return flagPairToDatum(t`Use for payments`, currentUseForPayments, null) + } + + return flagPairToDatum( + t`Use for payments`, + currentUseForPayments, + upcomingUseForPayments, + ) + }, [rulesetMetadata?.useDataHookForPay, upcomingRulesetMetadata]) + + const useForRedemptionsDatum: ConfigurationPanelDatum = useMemo(() => { + const currentUseForRedemptions = + rulesetMetadata?.useDataHookForRedeem + const upcomingUseForRedemptions = + upcomingRulesetMetadata?.useDataHookForRedeem + + if (upcomingRulesetMetadata === null) { + return flagPairToDatum( + t`Use for redemptions`, + currentUseForRedemptions, + null, + ) + } + + return flagPairToDatum( + t`Use for redemptions`, + currentUseForRedemptions, + upcomingUseForRedemptions, + ) + }, [ + rulesetMetadata?.useDataHookForRedeem, + upcomingRulesetMetadata, + ]) + + const formatted = useMemo(() => { + return { + contract: contractDatum, + useForPayments: useForPaymentsDatum, + useForRedemptions: useForRedemptionsDatum, + } + }, [contractDatum, useForPaymentsDatum, useForRedemptionsDatum]) + + if ( + isZeroAddress(rulesetMetadata?.dataHook) && + !upcomingRulesetMetadata + ) { + return null + } + + return formatted +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationOtherRulesSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationOtherRulesSection.ts new file mode 100644 index 0000000000..fa88d1aeff --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationOtherRulesSection.ts @@ -0,0 +1,135 @@ +import { t } from '@lingui/macro' +import { + ConfigurationPanelDatum, + ConfigurationPanelTableData, +} from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairToDatum' +import { JBRulesetMetadata } from 'juice-sdk-core' +import { useMemo } from 'react' + +export const useV4FormatConfigurationOtherRulesSection = ({ + rulesetMetadata, + upcomingRulesetMetadata, +}: { + rulesetMetadata: JBRulesetMetadata | undefined | null + upcomingRulesetMetadata: JBRulesetMetadata | undefined | null +}): ConfigurationPanelTableData => { + const paymentsToThisProjectDatum: ConfigurationPanelDatum = useMemo(() => { + const currentPaymentsToThisProject = + rulesetMetadata?.pausePay !== undefined + ? !rulesetMetadata.pausePay + : undefined + + if (upcomingRulesetMetadata === null) { + return flagPairToDatum( + t`Payments to this project`, + currentPaymentsToThisProject, + null, + ) + } + + const upcomingPaymentsToThisProject = + upcomingRulesetMetadata?.pausePay !== undefined + ? !upcomingRulesetMetadata.pausePay + : undefined + + return flagPairToDatum( + t`Payments to this project`, + currentPaymentsToThisProject, + upcomingPaymentsToThisProject, + ) + }, [rulesetMetadata?.pausePay, upcomingRulesetMetadata]) + + const holdFeesDatum = useMemo(() => { + const currentHoldFees = rulesetMetadata?.holdFees + if (upcomingRulesetMetadata === null) { + return flagPairToDatum(t`Hold fees`, currentHoldFees, null) + } + const upcomingHoldFees = upcomingRulesetMetadata?.holdFees + + return flagPairToDatum(t`Hold fees`, currentHoldFees, upcomingHoldFees) + }, [rulesetMetadata?.holdFees, upcomingRulesetMetadata]) + + const setPaymentTerminalsDatum: ConfigurationPanelDatum = useMemo(() => { + const currentSetPaymentTerminals = + rulesetMetadata?.allowSetTerminals + if (upcomingRulesetMetadata === null) { + return flagPairToDatum( + t`Set payment terminals`, + currentSetPaymentTerminals, + null, + ) + } + const upcomingSetPaymentTerminals = + upcomingRulesetMetadata?.allowSetTerminals + + return flagPairToDatum( + t`Set payment terminals`, + currentSetPaymentTerminals, + upcomingSetPaymentTerminals, + ) + }, [ + rulesetMetadata?.allowSetTerminals, + upcomingRulesetMetadata, + ]) + + const setControllerDatum: ConfigurationPanelDatum = useMemo(() => { + const currentSetController = rulesetMetadata?.allowSetController + if (upcomingRulesetMetadata === null) { + return flagPairToDatum(t`Set controller`, currentSetController, null) + } + const upcomingSetController = + upcomingRulesetMetadata?.allowSetController + + return flagPairToDatum( + t`Set controller`, + currentSetController, + upcomingSetController, + ) + }, [ + rulesetMetadata?.allowSetController, + upcomingRulesetMetadata, + ]) + + // Generate the rest of the data, copilot + // Migrate payment terminal + // Migrate controller + const migratePaymentTerminalDatum: ConfigurationPanelDatum = useMemo(() => { + const currentMigratePaymentTerminal = + rulesetMetadata?.allowTerminalMigration + if (upcomingRulesetMetadata === null) { + return flagPairToDatum( + t`Migrate payment terminal`, + currentMigratePaymentTerminal, + null, + ) + } + const upcomingMigratePaymentTerminal = + upcomingRulesetMetadata?.allowTerminalMigration + + return flagPairToDatum( + t`Migrate payment terminal`, + currentMigratePaymentTerminal, + upcomingMigratePaymentTerminal, + ) + }, [ + rulesetMetadata?.allowTerminalMigration, + upcomingRulesetMetadata, + ]) + + return useMemo(() => { + return { + paymentsToThisProject: paymentsToThisProjectDatum, + holdFees: holdFeesDatum, + setPaymentTerminals: setPaymentTerminalsDatum, + setController: setControllerDatum, + migratePaymentTerminal: migratePaymentTerminalDatum, + } + }, [ + holdFeesDatum, + migratePaymentTerminalDatum, + paymentsToThisProjectDatum, + setControllerDatum, + setPaymentTerminalsDatum, + ]) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts new file mode 100644 index 0000000000..afaf6d49c6 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts @@ -0,0 +1,218 @@ +import { t } from '@lingui/macro' +import { + ConfigurationPanelDatum, + ConfigurationPanelTableData, +} from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairToDatum' +import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' +import { JBRulesetData, JBRulesetMetadata } from 'juice-sdk-core' +import { useMemo } from 'react' +import { tokenSymbolText } from 'utils/tokenSymbolText' + +export const useV4FormatConfigurationTokenSection = ({ + ruleset, + rulesetMetadata, + tokenSymbol: tokenSymbolRaw, + upcomingRuleset, + upcomingRulesetLoading, + upcomingRulesetMetadata, +}: { + ruleset: JBRulesetData | undefined | null + rulesetMetadata: JBRulesetMetadata | undefined | null + tokenSymbol: string | undefined + upcomingRuleset: JBRulesetData | undefined | null + upcomingRulesetLoading: boolean + upcomingRulesetMetadata?: JBRulesetMetadata | undefined | null +}): ConfigurationPanelTableData => { + const tokenSymbol = useMemo( + () => + tokenSymbolText({ + tokenSymbol: tokenSymbolRaw, + capitalize: false, + plural: true, + }), + [tokenSymbolRaw], + ) + const decayPercentFloat = ruleset?.decayPercent.toFloat() + const currentTotalIssuanceRate = ruleset?.weight.toFloat() + const queuedTotalIssuanceRate = upcomingRuleset ? + upcomingRuleset?.weight.toFloat() + : currentTotalIssuanceRate && decayPercentFloat ? + currentTotalIssuanceRate - (currentTotalIssuanceRate * decayPercentFloat) + : undefined + + const totalIssuanceRateDatum: ConfigurationPanelDatum = useMemo(() => { + const current = currentTotalIssuanceRate + ? `${currentTotalIssuanceRate} ${tokenSymbol}/ETH` + : undefined + + if (upcomingRuleset === null || upcomingRulesetLoading) { + return pairToDatum(t`Total issuance rate`, current, null) + } + const queued = queuedTotalIssuanceRate + ? `${queuedTotalIssuanceRate} ${tokenSymbol}/ETH` + : undefined + return pairToDatum(t`Total issuance rate`, current, queued) + }, [upcomingRuleset, currentTotalIssuanceRate, tokenSymbol, queuedTotalIssuanceRate, upcomingRulesetLoading]) + + const reservedPercentFloat = rulesetMetadata?.reservedPercent.toFloat() + const queuedReservedPercentFloat = upcomingRulesetMetadata?.reservedPercent.toFloat() + + const payerIssuanceRateDatum: ConfigurationPanelDatum = useMemo(() => { + const currentPayerIssuanceRate = currentTotalIssuanceRate && reservedPercentFloat ? + currentTotalIssuanceRate - (currentTotalIssuanceRate * reservedPercentFloat) + : undefined + + const current = currentPayerIssuanceRate + ? `${currentPayerIssuanceRate} ${tokenSymbol}/ETH` + : undefined + if (upcomingRuleset === null || upcomingRulesetMetadata === null || upcomingRulesetLoading) { + return pairToDatum(t`Payer issuance rate`, current, null) + } + const _reservedPercent = queuedReservedPercentFloat ?? reservedPercentFloat + const queuedPayerIssuanceRate = queuedTotalIssuanceRate && _reservedPercent ? + queuedTotalIssuanceRate - (queuedTotalIssuanceRate * _reservedPercent) + : undefined + const queued = queuedPayerIssuanceRate + ? `${queuedPayerIssuanceRate} ${tokenSymbol}/ETH` + : undefined + return pairToDatum(t`Payer issuance rate`, current, queued) + }, [ + tokenSymbol, + upcomingRuleset, + queuedReservedPercentFloat, + upcomingRulesetMetadata, + currentTotalIssuanceRate, + queuedTotalIssuanceRate, + reservedPercentFloat, + upcomingRulesetLoading + ]) + + const reservedPercentDatum: ConfigurationPanelDatum = useMemo(() => { + const current = rulesetMetadata?.reservedPercent ? + `${rulesetMetadata.reservedPercent.formatPercentage()}%` : undefined + if (upcomingRulesetMetadata === null || upcomingRulesetLoading) { + return pairToDatum(t`Reserved rate`, current, null) + } + + const queued = upcomingRulesetMetadata?.reservedPercent + ? `${upcomingRulesetMetadata.reservedPercent.formatPercentage()}%` + : rulesetMetadata?.reservedPercent ? + `${rulesetMetadata.reservedPercent.formatPercentage()}%` + : undefined + return pairToDatum(t`Reserved rate`, current, queued) + }, [upcomingRulesetMetadata, rulesetMetadata, upcomingRulesetLoading]) + + const decayPercentDatum: ConfigurationPanelDatum = useMemo(() => { + const current = ruleset ? + `${ruleset.decayPercent.formatPercentage()}%` + : undefined + + if (upcomingRuleset === null || upcomingRulesetLoading) { + return pairToDatum(t`Decay rate`, current, null) + } + const queued = upcomingRuleset + ? `${upcomingRuleset.decayPercent.formatPercentage()}%` + : ruleset ? + `${ruleset.decayPercent.formatPercentage()}%` + : undefined + + return pairToDatum(t`Decay rate`, current, queued) + }, [ruleset, upcomingRuleset, upcomingRulesetLoading]) + + const redemptionRateDatum: ConfigurationPanelDatum = useMemo(() => { + const currentRedemptionRate = rulesetMetadata?.redemptionRate.formatPercentage() + + const current = currentRedemptionRate + ? `${currentRedemptionRate}%` + : undefined + + if (upcomingRulesetMetadata === null || upcomingRulesetLoading) { + return pairToDatum(t`Redemption rate`, current, null) + } + + const queued = upcomingRulesetMetadata + ? `${upcomingRulesetMetadata?.redemptionRate.formatPercentage()}%` + : rulesetMetadata ? + `${rulesetMetadata.redemptionRate.formatPercentage()}%` + : undefined + return pairToDatum(t`Redemption rate`, current, queued) + }, [upcomingRulesetMetadata, rulesetMetadata, upcomingRulesetLoading]) + + const ownerTokenMintingRateDatum: ConfigurationPanelDatum = useMemo(() => { + const currentOwnerTokenMintingRate = + rulesetMetadata?.allowOwnerMinting !== undefined + ? rulesetMetadata?.allowOwnerMinting + : undefined + if (upcomingRulesetMetadata === null || upcomingRulesetLoading) { + return flagPairToDatum( + t`Owner token minting`, + currentOwnerTokenMintingRate, + null, + ) + } + + const queuedOwnerTokenMintingRate = + upcomingRulesetMetadata?.allowOwnerMinting !== undefined ? + upcomingRulesetMetadata?.allowOwnerMinting + : rulesetMetadata?.allowOwnerMinting !== undefined ? + rulesetMetadata.allowOwnerMinting + : undefined + + return flagPairToDatum( + t`Owner token minting`, + currentOwnerTokenMintingRate, + queuedOwnerTokenMintingRate, + ) + }, [rulesetMetadata?.allowOwnerMinting, upcomingRulesetMetadata, upcomingRulesetLoading]) + + const tokenTransfersDatum: ConfigurationPanelDatum = useMemo(() => { + const currentTokenTransfersDatum = + rulesetMetadata?.pauseCreditTransfers !== undefined + ? !rulesetMetadata?.pauseCreditTransfers + : undefined + if (upcomingRulesetMetadata === null || upcomingRulesetLoading) { + return flagPairToDatum( + t`Token transfers`, + !!currentTokenTransfersDatum, + null, + ) + } + const queuedTokenTransfersDatum = + upcomingRulesetMetadata?.pauseCreditTransfers !== undefined + ? !upcomingRulesetMetadata?.pauseCreditTransfers + : rulesetMetadata?.pauseCreditTransfers !== undefined ? + !rulesetMetadata.pauseCreditTransfers + : null + + return flagPairToDatum( + t`Token transfers`, + currentTokenTransfersDatum, + queuedTokenTransfersDatum, + ) + }, [ + rulesetMetadata?.pauseCreditTransfers, + upcomingRulesetMetadata, + upcomingRulesetLoading + ]) + + return useMemo(() => { + return { + totalIssuanceRate: totalIssuanceRateDatum, + payerIssuanceRate: payerIssuanceRateDatum, + reservedPercent: reservedPercentDatum, + decayPercentDatum: decayPercentDatum, + redemptionRate: redemptionRateDatum, + ownerTokenMintingRate: ownerTokenMintingRateDatum, + tokenTransfers: tokenTransfersDatum, + } + }, [ + decayPercentDatum, + totalIssuanceRateDatum, + ownerTokenMintingRateDatum, + payerIssuanceRateDatum, + redemptionRateDatum, + reservedPercentDatum, + tokenTransfersDatum, + ]) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4OtherRulesSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4OtherRulesSection.ts new file mode 100644 index 0000000000..9e66568513 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4OtherRulesSection.ts @@ -0,0 +1,21 @@ +import { ConfigurationPanelTableData } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { useJBRulesetMetadata } from 'juice-sdk-react' +import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' +import { useV4FormatConfigurationOtherRulesSection } from './useV4FormatConfigurationOtherRulesSection' + +export const useV4OtherRulesSection = ( + type: 'current' | 'upcoming', +): ConfigurationPanelTableData => { + const { data: rulesetMetadata } = useJBRulesetMetadata() + const { + rulesetMetadata: upcomingRulesetMetadata, + } = useJBUpcomingRuleset() + + return useV4FormatConfigurationOtherRulesSection({ + rulesetMetadata, + upcomingRulesetMetadata, + ...(type === 'current' && { + upcomingRulesetMetadata: null, + }), + }) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4PayoutsSubPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4PayoutsSubPanel.tsx new file mode 100644 index 0000000000..dced7b6d74 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4PayoutsSubPanel.tsx @@ -0,0 +1,120 @@ +import { JBSplit, SPLITS_TOTAL_PERCENT } from 'juice-sdk-core' +import { NativeTokenValue, useReadJbMultiTerminalFee } from 'juice-sdk-react' +import useProjectOwnerOf from 'packages/v4/hooks/useV4ProjectOwnerOf' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' +import { v4GetProjectOwnerRemainderSplit } from 'packages/v4/utils/v4Splits' +import { useCallback, useMemo } from 'react' +import assert from 'utils/assert' +import { feeForAmount } from 'utils/math' +import { useV4CurrentUpcomingPayoutLimit } from './useV4CurrentUpcomingPayoutLimit' +import { useV4CurrentUpcomingPayoutSplits } from './useV4CurrentUpcomingPayoutSplits' +import { useV4DistributableAmount } from './useV4DistributableAmount' + +const splitHasFee = (split: JBSplit) => { + return split.projectId || split.projectId > 0n +} + +const calculateSplitAmountWad = ( + split: JBSplit, + payoutLimit: bigint | undefined, + primaryETHTerminalFee: bigint | undefined, +) => { + const splitValue = payoutLimit + ? (payoutLimit * split.percent.value) / BigInt(SPLITS_TOTAL_PERCENT) + : undefined + const feeAmount = splitHasFee(split) + ? feeForAmount(splitValue, primaryETHTerminalFee) ?? 0n + : 0n + return splitValue ? splitValue - feeAmount : undefined +} + +export const useV4PayoutsSubPanel = (type: 'current' | 'upcoming') => { + const { splits, isLoading } = useV4CurrentUpcomingPayoutSplits(type) + + const { data: projectOwnerAddress } = useProjectOwnerOf() + + const { data: primaryNativeTerminalFee } = useReadJbMultiTerminalFee() + + const { distributableAmount } = useV4DistributableAmount() + + const { payoutLimit, payoutLimitCurrency } = + useV4CurrentUpcomingPayoutLimit(type) + + const showAmountOnPayout = useMemo(() => { + return payoutLimit === MAX_PAYOUT_LIMIT || payoutLimit === 0n + }, [payoutLimit]) + + const transformSplit = useCallback( + (split: JBSplit) => { + assert(split.beneficiary, 'Beneficiary must be defined') + let amount = undefined + const splitAmountWad = calculateSplitAmountWad( + split, + payoutLimit, + primaryNativeTerminalFee, + ) + if (showAmountOnPayout && splitAmountWad && payoutLimitCurrency) { + amount = + } + return { + projectId: split.projectId ? Number(split.projectId) : undefined, + address: split.beneficiary!, + percent: `${split.percent.formatPercentage()}%`, + amount, + } + }, + [ + payoutLimit, + payoutLimitCurrency, + showAmountOnPayout, + primaryNativeTerminalFee, + ], + ) + + const totalPayoutAmount = useMemo(() => { + if (!payoutLimit || !payoutLimitCurrency) return + if (payoutLimit === MAX_PAYOUT_LIMIT || payoutLimit === 0n) return + + return + }, [payoutLimit, payoutLimitCurrency]) + + const payouts = useMemo(() => { + if (isLoading || !splits) return + + if ( + // We don't need to worry about upcoming as this is informational only + type === 'current' && + splits.length === 0 && + payoutLimit === 0n && + distributableAmount.value === 0n + ) { + return [] + } + + const ownerPayout = projectOwnerAddress + ? v4GetProjectOwnerRemainderSplit(projectOwnerAddress, splits) + : undefined + + return [ + ...splits, + ...(ownerPayout && ownerPayout.percent.value > 0n ? [ownerPayout] : []), + ] + .sort((a, b) => Number(b.percent) - Number(a.percent)) + .map(transformSplit) + }, [ + distributableAmount, + projectOwnerAddress, + splits, + isLoading, + payoutLimit, + transformSplit, + type, + ]) + + return { + isLoading, + payouts, + totalPayoutAmount, + payoutLimit, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TokenSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TokenSection.ts new file mode 100644 index 0000000000..baafc6cd8f --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TokenSection.ts @@ -0,0 +1,33 @@ +import { ConfigurationPanelTableData } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' +import { useJBRuleset, useJBRulesetMetadata, useJBTokenContext } from 'juice-sdk-react' +import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' +import { useV4FormatConfigurationTokenSection } from './useV4FormatConfigurationTokenSection' + +export const useV4TokenSection = ( + type: 'current' | 'upcoming', +): ConfigurationPanelTableData => { + const { token } = useJBTokenContext() + const tokenSymbol = token?.data?.symbol + + const { data: ruleset } = useJBRuleset() + const { data: rulesetMetadata } = useJBRulesetMetadata() + const { + ruleset: upcomingRuleset, + rulesetMetadata: upcomingRulesetMetadata, + isLoading: upcomingRulesetLoading + } = useJBUpcomingRuleset() + + return useV4FormatConfigurationTokenSection({ + ruleset, + rulesetMetadata, + tokenSymbol, + upcomingRuleset, + upcomingRulesetMetadata, + upcomingRulesetLoading, + // Hide upcoming info from current section. + ...(type === 'current' && { + upcomingRuleset: null, + upcomingRulesetMetadata: null, + }), + }) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx new file mode 100644 index 0000000000..390ca45ed8 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx @@ -0,0 +1,55 @@ +import { t } from '@lingui/macro' +import { NativeTokenValue, useJBRulesetMetadata, useNativeTokenSurplus } from 'juice-sdk-react' +import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' +import { useV4BalanceOfNativeTerminal } from 'packages/v4/hooks/useV4BalanceOfNativeTerminal' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' +import { useMemo } from 'react' +import { useV4DistributableAmount } from './useV4DistributableAmount' + +export const useV4TreasuryStats = () => { + const { data: rulesetMetadata } = useJBRulesetMetadata() + const { distributableAmount } = useV4DistributableAmount() + const { data: surplusInNativeToken } = useNativeTokenSurplus() + + const { data: _treasuryBalance } = useV4BalanceOfNativeTerminal() + + const { data: payoutLimit } = usePayoutLimit() + + const treasuryBalance = useMemo(() => { + if (!_treasuryBalance) return undefined + + return ( + + ) + }, [_treasuryBalance]) + + + const surplus = useMemo(() => { + if (payoutLimit && payoutLimit.amount === MAX_PAYOUT_LIMIT) return t`No surplus` + + return ( + + ) + }, [ + surplusInNativeToken, + payoutLimit, + ]) + + const availableToPayout = useMemo(() => { + return ( + + ) + }, [distributableAmount]) + return { + treasuryBalance, + availableToPayout, + surplus, + redemptionRate: rulesetMetadata?.redemptionRate, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4UpcomingRulesetHasChanges.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4UpcomingRulesetHasChanges.ts similarity index 100% rename from src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/CyclesPayoutsPanel/hooks/useV4UpcomingRulesetHasChanges.ts rename to src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4UpcomingRulesetHasChanges.ts diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ProjectTabs.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ProjectTabs.tsx index d7dbf95334..120f3a7376 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ProjectTabs.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ProjectTabs.tsx @@ -2,16 +2,12 @@ import { Tab } from '@headlessui/react' import { t } from '@lingui/macro' import { ProjectTab } from 'components/Project/ProjectTabs/ProjectTab' import { useOnScreen } from 'hooks/useOnScreen' -import { useProjectPageQueries } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectPageQueries' -import { - Fragment, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import { twMerge } from 'tailwind-merge' -import { V4CyclesPayoutsPanel } from './CyclesPayoutsPanel/V4CyclesPayoutsPanel' +import { useProjectPageQueries } from '../hooks/useProjectPageQueries' +import { V4ActivityPanel } from './V4ActivityPanel/V4ActivityPanel' +import { V4CyclesPayoutsPanel } from './V4CyclesPayoutsPanel/V4CyclesPayoutsPanel' +import { V4TokensPanel } from './V4TokensPanel/V4TokensPanel' type ProjectTabConfig = { id: string @@ -48,14 +44,14 @@ export const V4ProjectTabs = ({ className }: { className?: string }) => { const tabs: ProjectTabConfig[] = useMemo( () => [ - { id: 'activity', name: t`Activity`, panel: <> }, + { id: 'activity', name: t`Activity`, panel: }, { id: 'about', name: t`About`, panel: <> }, { id: 'cycle_payouts', name: t`Cycles & Payouts`, panel: , }, - { id: 'tokens', name: t`Tokens`, panel: <> }, + { id: 'tokens', name: t`Tokens`, panel: }, ], [], ) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4DistributeReservedTokensModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4DistributeReservedTokensModal.tsx new file mode 100644 index 0000000000..6e48caa12a --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4DistributeReservedTokensModal.tsx @@ -0,0 +1,122 @@ +import { t, Trans } from '@lingui/macro' +import TransactionModal from 'components/modals/TransactionModal' +import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' +import { useJBContractContext, useReadJbTokensTokenOf, useReadJbTokensTotalCreditSupplyOf } from 'juice-sdk-react' +import SplitList from 'packages/v4/components/SplitList/SplitList' +import useProjectOwnerOf from 'packages/v4/hooks/useV4ProjectOwnerOf' +import { useV4ReservedSplits } from 'packages/v4/hooks/useV4ReservedSplits' +import { useState } from 'react' +import { formatWad } from 'utils/format/formatNumber' +import { tokenSymbolText } from 'utils/tokenSymbolText' + +export default function V4DistributeReservedTokensModal({ + open, + onCancel, + onConfirmed, +}: { + open?: boolean + onCancel?: VoidFunction + onConfirmed?: VoidFunction +}) { + const { projectId } = useJBContractContext() + const { splits: reservedTokensSplits } = useV4ReservedSplits() + const { data: projectOwnerAddress } = useProjectOwnerOf() + const { data: tokenAddress } = useReadJbTokensTokenOf() + const { data: tokenSymbol } = useNameOfERC20(tokenAddress) + + + const [loading, setLoading] = useState() + const [transactionPending, setTransactionPending] = useState() + + // const distributeReservedTokensTx = useDistributeReservedTokens() + const { data: totalCreditSupply } = useReadJbTokensTotalCreditSupplyOf({ + args: [projectId], + }) + async function distributeReservedTokens() { + setLoading(true) + + // const txSuccessful = await distributeReservedTokensTx( + // {}, + // { + // onDone: () => { + // setTransactionPending(true) + // }, + // onConfirmed: () => { + // setLoading(false) + // setTransactionPending(false) + // onConfirmed?.() + // }, + // }, + // ) + + // if (!txSuccessful) { + setLoading(false) + setTransactionPending(false) + // } + } + + const reservedTokensFormatted = formatWad(totalCreditSupply, { precision: 0 }) + + const tokenTextPlural = tokenSymbolText({ + tokenSymbol, + capitalize: false, + plural: true, + }) + + const tokenTextSingular = tokenSymbolText({ + tokenSymbol, + capitalize: true, + plural: false, + }) + + return ( + Send reserved {tokenTextPlural}} + open={open} + onOk={() => distributeReservedTokens()} + okText={t`Send ${tokenTextPlural}`} + connectWalletText={t`Connect wallet to send reserved ${tokenTextPlural}`} + confirmLoading={loading} + transactionPending={transactionPending} + onCancel={onCancel} + okButtonProps={{ disabled: !(totalCreditSupply && totalCreditSupply > 0n) }} + width={640} + centered={true} + > +
+
+ + Reserved {tokenTextPlural}:{' '} + + {reservedTokensFormatted} {tokenTextPlural} + + +
+
+

+ {tokenTextSingular} recipients +

+ + {reservedTokensSplits?.length === 0 ? ( +

+ + The project owner is the only reserved token recipient. Any + reserved tokens sent out this cycle will go to them. + +

+ ) : null} + + +
+
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ExportReservedTokensCsvItem.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ExportReservedTokensCsvItem.tsx new file mode 100644 index 0000000000..cc2677ba5b --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ExportReservedTokensCsvItem.tsx @@ -0,0 +1,25 @@ +import { ArrowUpTrayIcon } from '@heroicons/react/24/outline' +import { Trans } from '@lingui/macro' +import { useJBRuleset } from 'juice-sdk-react' +import { useV4ReservedSplits } from 'packages/v4/hooks/useV4ReservedSplits' +import { useV4ExportSplitsToCsv } from '../V4CyclesPayoutsPanel/hooks/useV4ExportSplitsToCsv' + +export const V4ExportReservedTokensCsvItem = () => { + const { splits: reservedTokensSplits } = useV4ReservedSplits() + const { data: ruleset } = useJBRuleset() + const { exportSplitsToCsv } = useV4ExportSplitsToCsv( + reservedTokensSplits ?? [], + 'reserved-tokens', + Number(ruleset?.cycleNumber), + ) + if (!reservedTokensSplits?.length) return null + + return ( + + + + Export tokens CSV + + + ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx new file mode 100644 index 0000000000..fe86053797 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx @@ -0,0 +1,100 @@ +import { Trans, t } from '@lingui/macro' +import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/TitleDescriptionDisplayCard' +import { reservedTokensTooltip } from 'components/Project/ProjectTabs/TokensPanelTooltips' +import { twMerge } from 'tailwind-merge' +import { V4ProjectAllocationRow } from '../V4CyclesPayoutsPanel/V4ProjectAllocationRow' +import { useV4ReservedTokensSubPanel } from './hooks/useV4ReservedTokensSubPanel' +import { V4ExportReservedTokensCsvItem } from './V4ExportReservedTokensCsvItem' +import { V4SendReservedTokensButton } from './V4SendReservedTokensButton' + +export const V4ReservedTokensSubPanel = ({ + className, +}: { + className?: string +}) => { + const { reservedList, totalCreditSupply, reservedPercent } = + useV4ReservedTokensSubPanel() + + const reservedPercentTooltip = ( + + {reservedPercent} of token issuance is set aside for the recipients below. + + ) + + return ( +
+

+ Reserved tokens +

+
+
+ {totalCreditSupply} + ) : ( +
+ ) + } + tooltip={reservedTokensTooltip} + /> + +
+ {reservedPercent && + totalCreditSupply && + reservedPercent !== '0' ? ( + + {totalCreditSupply || + reservedPercent || + (reservedList && reservedList.length > 1) ? ( + <> +
+ {reservedList + ? reservedList.map(props => ( + + )) + : null} +
+ + + + ) : ( +
+ + No distributable reserved tokens have been configured for this + project. + +
+ )} +
+ ) : null} +
+
+ ) +} + +const kebabMenuItems = [ + { + id: 'export', + component: , + }, +] diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4SendReservedTokensButton.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4SendReservedTokensButton.tsx new file mode 100644 index 0000000000..879843185c --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4SendReservedTokensButton.tsx @@ -0,0 +1,52 @@ +import { ArrowUpCircleIcon } from '@heroicons/react/24/outline' +import { Trans } from '@lingui/macro' +import { Button, Tooltip } from 'antd' +import { useJBContractContext, useReadJbTokensTotalCreditSupplyOf } from 'juice-sdk-react' +import { useCallback, useMemo, useState } from 'react' +import { twMerge } from 'tailwind-merge' +import { reloadWindow } from 'utils/windowUtils' +import V4DistributeReservedTokensModal from './V4DistributeReservedTokensModal' + +export const V4SendReservedTokensButton = ({ + className, + containerClassName, +}: { + className?: string + containerClassName?: string +}) => { + const { projectId } = useJBContractContext() + const { data: totalCreditSupply, isLoading: totalCreditSupplyLoading } = useReadJbTokensTotalCreditSupplyOf({ + args: [projectId], + }) + const [open, setOpen] = useState(false) + const openModal = useCallback(() => setOpen(true), []) + const closeModal = useCallback(() => setOpen(false), []) + + const distributeButtonDisabled = useMemo(() => { + return (totalCreditSupply ?? 0n) === 0n + }, [totalCreditSupply]) + + return ( + No reserved tokens to send.} + open={distributeButtonDisabled ? undefined : false} + className={containerClassName} + > + + + + ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx new file mode 100644 index 0000000000..9e86976584 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx @@ -0,0 +1,208 @@ +import { Trans, t } from '@lingui/macro' +import EthereumAddress from 'components/EthereumAddress' +import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/TitleDescriptionDisplayCard' +// import { TokenHoldersModal } from '../TokenHoldersModal/TokenHoldersModal' +// import { MigrateTokensButton } from './components/MigrateTokensButton' +// import { RedeemTokensButton } from './components/RedeemTokensButton' +// import { ReservedTokensSubPanel } from './components/ReservedTokensSubPanel' +// import { TokenRedemptionCallout } from './components/TokenRedemptionCallout' +// import { TransferUnclaimedTokensModalWrapper } from './components/TransferUnclaimedTokensModalWrapper' +import { AddTokenToMetamaskButton } from 'components/buttons/AddTokenToMetamaskButton' +import { IssueErc20TokenButton } from 'components/buttons/IssueErc20TokenButton' +import { V4TokenHoldersModal } from 'packages/v4/components/V4TokenHoldersModal' +import { useCallback, useState } from 'react' +import { reloadWindow } from 'utils/windowUtils' +import { useV4TokensPanel } from './hooks/useV4TokensPanel' +import { useV4YourBalanceMenuItems } from './hooks/useV4YourBalanceMenuItems' +import { V4ReservedTokensSubPanel } from './V4ReservedTokensSubPanel' + +export const V4TokensPanel = () => { + const { + userTokenBalance, + userTokenBalanceLoading, + // userLegacyTokenBalance, + // projectHasLegacyTokens, + // userV1ClaimedBalance, + projectToken, + totalSupply, + } = useV4TokensPanel() + + const [tokenHolderModalOpen, setTokenHolderModalOpen] = useState(false) + const openTokenHolderModal = useCallback( + () => setTokenHolderModalOpen(true), + [], + ) + const closeTokenHolderModal = useCallback( + () => setTokenHolderModalOpen(false), + [], + ) + + const { + items, + // redeemModalVisible, + // setRedeemModalVisible, + // claimTokensModalVisible, + setClaimTokensModalVisible, + // mintModalVisible, + // setMintModalVisible, + // transferUnclaimedTokensModalVisible, + // setTransferUnclaimedTokensModalVisible, + } = useV4YourBalanceMenuItems() + + return ( + <> +
+
+

Tokens

+
+ + {/* */} + +
+ {!userTokenBalanceLoading && userTokenBalance !== undefined && ( + + {userTokenBalance.format()} tokens +
+ {/* {projectHasErc20Token && ( + + )} */} + {/* */} +
+ + } + kebabMenu={userTokenBalance.value > 0n ? { + items + } : undefined} + /> + )} + + {/* {projectHasLegacyTokens && userLegacyTokenBalance?.gt(0) ? ( + + + +
+ } + /> + ) : null} */} + +
+
+ + + {totalSupply.format()} {projectToken} + + } + /> +
+ + View token holders + +
+ + +
+
+ + {/* setRedeemModalVisible(false)} + onConfirmed={reloadWindow} + /> + setClaimTokensModalVisible(false)} + onConfirmed={reloadWindow} + /> + setMintModalVisible(false)} + onConfirmed={reloadWindow} + /> + setTransferUnclaimedTokensModalVisible(false)} + onConfirmed={reloadWindow} + /> */} + + ) +} + +const ProjectTokenCard = () => { + const { + projectToken, + projectTokenAddress, + projectHasErc20Token, + canCreateErc20Token, + } = useV4TokensPanel() + return ( + +
+ {projectHasErc20Token ? projectToken : t`Token`} + + {projectHasErc20Token && ( + + + + )} +
+ {projectTokenAddress && projectHasErc20Token && ( + + )} + {canCreateErc20Token && ( + + )} + + } + /> + ) +} + +const ProjectTokenBadge = () => { + const { projectHasErc20Token } = useV4TokensPanel() + return ( + + {projectHasErc20Token ? 'ERC-20' : t`Juicebox native`} + + ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4BalanceMenuItemsUserFlags.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4BalanceMenuItemsUserFlags.ts new file mode 100644 index 0000000000..f34cdd73b0 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4BalanceMenuItemsUserFlags.ts @@ -0,0 +1,71 @@ +import { useWallet } from 'hooks/Wallet' +import { useJBContractContext, useJBRulesetMetadata, useNativeTokenSurplus, useReadJbTokensCreditBalanceOf, useReadJbTokensTokenOf } from 'juice-sdk-react' +import { useV4WalletHasPermission } from 'packages/v4/hooks/useV4WalletHasPermission' +import { V4OperatorPermission } from 'packages/v4/models/v4Permissions' +import { useMemo } from 'react' +import { isZeroAddress } from 'utils/address' +import { zeroAddress } from 'viem' + +export const useV4BalanceMenuItemsUserFlags = () => { + const { data: rulesetMetadata } = useJBRulesetMetadata() + const { data: tokenAddress } = useReadJbTokensTokenOf() + const { data: surplusInNativeToken } = useNativeTokenSurplus() + const { userAddress } = useWallet() + + const { projectId } = useJBContractContext() + const isDev = useMemo(() => process.env.NODE_ENV === 'development', []) + + const userHasMintPermission = useV4WalletHasPermission( + V4OperatorPermission.MINT_TOKENS, + ) + const hasOverflow = useMemo( + () => !!(surplusInNativeToken && surplusInNativeToken > 0n), + [surplusInNativeToken], + ) + const redemptionRateIsZero = !!(rulesetMetadata && rulesetMetadata.redemptionRate.value === 0n) + const redeemDisabled = useMemo( + () => !hasOverflow || redemptionRateIsZero, + [hasOverflow, redemptionRateIsZero], + ) + const projectHasIssuedTokens = useMemo( + () => !!tokenAddress && !isZeroAddress(tokenAddress), + [tokenAddress], + ) + const projectAllowsMint = useMemo( + () => !!rulesetMetadata?.allowOwnerMinting, + [rulesetMetadata], + ) + const { data: creditBalance } = useReadJbTokensCreditBalanceOf({ // previously `unclaimedTokenBalance` + args: [ + userAddress ?? zeroAddress, + projectId + ] + }) + + const canBurnTokens = useMemo( + () => redeemDisabled || isDev, + [isDev, redeemDisabled], + ) + + const canClaimErcTokens = useMemo( + () => projectHasIssuedTokens || isDev, + [isDev, projectHasIssuedTokens], + ) + + const canMintTokens = useMemo( + () => (projectAllowsMint && userHasMintPermission) || isDev, + [isDev, projectAllowsMint, userHasMintPermission], + ) + const creditBalanceZero = !!(creditBalance && creditBalance > 0n) + const canTransferTokens = useMemo( + () => creditBalanceZero || isDev, + [creditBalanceZero, isDev], + ) + + return { + canBurnTokens, + canClaimErcTokens, + canMintTokens, + canTransferTokens, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4ReservedTokensSubPanel.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4ReservedTokensSubPanel.ts new file mode 100644 index 0000000000..a1c1787d52 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4ReservedTokensSubPanel.ts @@ -0,0 +1,93 @@ +import { WeiPerEther } from '@ethersproject/constants' +import { SplitPortion, SPLITS_TOTAL_PERCENT } from 'juice-sdk-core' +import { + useJBContractContext, + useJBRulesetMetadata, + useReadJbTokensTotalCreditSupplyOf, +} from 'juice-sdk-react' +import useProjectOwnerOf from 'packages/v4/hooks/useV4ProjectOwnerOf' +import { useV4ReservedSplits } from 'packages/v4/hooks/useV4ReservedSplits' +import { useMemo } from 'react' +import assert from 'utils/assert' +import { formatAmount } from 'utils/format/formatAmount' + +export const useV4ReservedTokensSubPanel = () => { + const { projectId } = useJBContractContext() + const { data: projectOwnerAddress } = useProjectOwnerOf() + const { splits: reservedTokensSplits } = useV4ReservedSplits() + const { data: rulesetMetadata } = useJBRulesetMetadata() + const reservedPercent = `${rulesetMetadata?.reservedPercent.formatPercentage()}%` + + const { data: totalCreditSupply } = useReadJbTokensTotalCreditSupplyOf({ + args: [projectId], + }) + + const reservedList = useMemo(() => { + if (!projectOwnerAddress || !projectId || !reservedTokensSplits) return + // If there aren't explicitly defined splits, all reserved tokens go to this project. + if (reservedTokensSplits?.length === 0) + return [ + { + projectId: Number(projectId), + address: projectOwnerAddress!, + percent: `${new SplitPortion( + SPLITS_TOTAL_PERCENT, + ).formatPercentage()}%`, + }, + ] + + // If the splits don't add up to 100%, remaining tokens go to this project. + let splitsPercentTotal = 0 + const processedSplits = reservedTokensSplits + .sort((a, b) => Number(b.percent) - Number(a.percent)) + .map(split => { + assert(split.beneficiary, 'Beneficiary must be defined') + splitsPercentTotal += Number(split.percent.value) + + return { + projectId: Number(split.projectId), + address: split.beneficiary!, + percent: `${split.percent.formatPercentage()}%`, + } + }) + + const remainingPercentage = SPLITS_TOTAL_PERCENT - splitsPercentTotal + + // Check if this project is already one of the splits. + if (!(remainingPercentage === 0)) { + const projectSplitIndex = processedSplits.findIndex( + v => v.projectId === Number(projectId), + ) + if (projectSplitIndex != -1) + // If it is, increase its split percentage to bring the total to 100%. + processedSplits[projectSplitIndex].percent = `${new SplitPortion( + remainingPercentage + + Number(reservedTokensSplits[projectSplitIndex].percent.value), + ).formatPercentage()}%` + // If it isn't, add a split at the beginning which brings the total percentage to 100%. + else + processedSplits.unshift({ + projectId: Number(projectId), + address: projectOwnerAddress!, + percent: `${new SplitPortion( + remainingPercentage, + ).formatPercentage()}%`, + }) + } + + return processedSplits + }, [reservedTokensSplits, projectOwnerAddress, projectId]) + + const totalCreditSupplyFormatted = useMemo(() => { + if (totalCreditSupply === undefined) return + return formatAmount(Number(totalCreditSupply / WeiPerEther.toBigInt()), { + maximumFractionDigits: 2, + }) + }, [totalCreditSupply]) + + return { + reservedList, + totalCreditSupply: totalCreditSupplyFormatted, + reservedPercent, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4TokensPanel.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4TokensPanel.ts new file mode 100644 index 0000000000..994087fc8b --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4TokensPanel.ts @@ -0,0 +1,66 @@ +import { useWallet } from 'hooks/Wallet' +import { JBProjectToken } from 'juice-sdk-core' +import { useJBContractContext, useJBTokenContext, useReadJbTokensTotalBalanceOf } from 'juice-sdk-react' +import { useV4TotalTokenSupply } from 'packages/v4/hooks/useV4TotalTokenSupply' +import { useV4WalletHasPermission } from 'packages/v4/hooks/useV4WalletHasPermission' +import { V4OperatorPermission } from 'packages/v4/models/v4Permissions' +import { useMemo } from 'react' +import { isZeroAddress } from 'utils/address' +import { tokenSymbolText } from 'utils/tokenSymbolText' +import { zeroAddress } from 'viem' + +export const useV4TokensPanel = () => { + const { projectId } = useJBContractContext() + const { userAddress } = useWallet() + const { token } = useJBTokenContext() + const tokenAddress = token?.data?.address + + const { data: _totalTokenSupply } = useV4TotalTokenSupply() + + const projectToken = tokenSymbolText({ + tokenSymbol: token?.data?.symbol, + capitalize: false, + plural: true, + }) + // const projectHasLegacyTokens = useProjectHasLegacyTokens() + const hasDeployErc20Permission = useV4WalletHasPermission( + V4OperatorPermission.DEPLOY_ERC20, + ) + const projectHasErc20Token = Boolean(tokenAddress && !isZeroAddress(tokenAddress)) + + const { data: _userTokenBalance, isLoading: userTokenBalanceLoading } = + useReadJbTokensTotalBalanceOf({ + args: [ + userAddress ?? zeroAddress, + projectId + ] + }) + const userTokenBalance = useMemo(() => { + if (_userTokenBalance === undefined) return + return new JBProjectToken(_userTokenBalance ?? 0n) + }, [_userTokenBalance]) + + // const { totalLegacyTokenBalance, v1ClaimedBalance } = + // useTotalLegacyTokenBalance({ projectId }) + + const totalTokenSupply = useMemo(() => { + return new JBProjectToken(_totalTokenSupply ?? 0n) + }, [_totalTokenSupply]) + + const canCreateErc20Token = useMemo(() => { + return !projectHasErc20Token && hasDeployErc20Permission + }, [hasDeployErc20Permission, projectHasErc20Token]) + + return { + userTokenBalance, + userTokenBalanceLoading, + // userLegacyTokenBalance: totalLegacyTokenBalance, + // projectHasLegacyTokens, + // userV1ClaimedBalance: v1ClaimedBalance, + projectToken, + projectTokenAddress: tokenAddress, + totalSupply: totalTokenSupply, + projectHasErc20Token, + canCreateErc20Token, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4YourBalanceMenuItems.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4YourBalanceMenuItems.tsx new file mode 100644 index 0000000000..d9997ecc63 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/hooks/useV4YourBalanceMenuItems.tsx @@ -0,0 +1,101 @@ +import { + ArrowRightOnRectangleIcon, + FireIcon, + PlusCircleIcon, + ReceiptRefundIcon, +} from '@heroicons/react/24/outline' +import { t } from '@lingui/macro' +import { PopupMenuItem } from 'components/ui/PopupMenu' +import { ReactNode, useMemo, useState } from 'react' +import { useV4BalanceMenuItemsUserFlags } from './useV4BalanceMenuItemsUserFlags' + +export const useV4YourBalanceMenuItems = () => { + const { canBurnTokens, canClaimErcTokens, canMintTokens, canTransferTokens } = + useV4BalanceMenuItemsUserFlags() + + const [redeemModalVisible, setRedeemModalVisible] = useState(false) + const [claimTokensModalVisible, setClaimTokensModalVisible] = useState(false) + const [mintModalVisible, setMintModalVisible] = useState(false) + const [ + transferUnclaimedTokensModalVisible, + setTransferUnclaimedTokensModalVisible, + ] = useState(false) + + const items = useMemo(() => { + const tokenMenuItems: PopupMenuItem[] = [] + if (canBurnTokens) { + tokenMenuItems.push({ + id: 'burn', + label: ( + } + /> + ), + onClick: () => setRedeemModalVisible(true), + }) + } + if (canClaimErcTokens) { + tokenMenuItems.push({ + id: 'claim', + label: ( + } + /> + ), + onClick: () => setClaimTokensModalVisible(true), + }) + } + if (canMintTokens) { + tokenMenuItems.push({ + id: 'mint', + label: ( + } + /> + ), + onClick: () => setMintModalVisible(true), + }) + } + if (canTransferTokens) { + tokenMenuItems.push({ + id: 'transfer', + label: ( + } + /> + ), + onClick: () => setTransferUnclaimedTokensModalVisible(true), + }) + } + return tokenMenuItems + }, [canBurnTokens, canClaimErcTokens, canMintTokens, canTransferTokens]) + + return { + items, + redeemModalVisible, + setRedeemModalVisible, + claimTokensModalVisible, + setClaimTokensModalVisible, + mintModalVisible, + setMintModalVisible, + transferUnclaimedTokensModalVisible, + setTransferUnclaimedTokensModalVisible, + } +} + +const TokenItemLabel = ({ + label, + icon, +}: { + label: ReactNode + icon: ReactNode +}) => ( + <> + {icon} + {label} + +) diff --git a/src/packages/v4/views/V4ProjectDashboard/hooks/useProjectPageQueries.ts b/src/packages/v4/views/V4ProjectDashboard/hooks/useProjectPageQueries.ts new file mode 100644 index 0000000000..41ae2f2929 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/hooks/useProjectPageQueries.ts @@ -0,0 +1,86 @@ +import { useRouter } from 'next/router' +import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption' +import { useCallback, useMemo } from 'react' + +type ProjectPageTab = + | 'activity' + | 'about' + | 'nft_rewards' + | 'cycle_payouts' + | 'tokens' + +export type ProjectPayReceipt = { + totalAmount: { + amount: number + currency: V4CurrencyOption + } + tokensReceived: string + timestamp: Date + transactionHash: string | undefined + fromAddress: string + nfts: { + id: number + }[] +} + +export const useProjectPageQueries = () => { + const router = useRouter() + + const projectPageTab = router.query.tabid as ProjectPageTab | undefined + const projectPayReceiptString = router.query.payReceipt as string | undefined + + const projectPayReceipt = useMemo(() => { + if (!projectPayReceiptString) { + return undefined + } + + try { + const parsed = JSON.parse(projectPayReceiptString) as ProjectPayReceipt + return { + ...parsed, + timestamp: new Date(parsed.timestamp), + } + } catch (error) { + console.error('Failed to parse projectPayReceipt', error) + return undefined + } + }, [projectPayReceiptString]) + + const setProjectPageTab = useCallback( + (tabId: string) => { + router.replace( + { + pathname: router.pathname, + query: { ...router.query, tabid: tabId }, + }, + undefined, + { shallow: true }, + ) + }, + [router], + ) + + const setProjectPayReceipt = useCallback( + (payReceipt: ProjectPayReceipt | undefined) => { + router.replace( + { + pathname: router.pathname, + query: { + ...router.query, + payReceipt: payReceipt ? JSON.stringify(payReceipt) : undefined, + }, + }, + undefined, + { shallow: true }, + ) + }, + [router], + ) + + return { + projectPageTab, + setProjectPageTab, + projectPayReceipt, + setProjectPayReceipt, + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/hooks/useV4CurrentUpcomingSubPanel.ts b/src/packages/v4/views/V4ProjectDashboard/hooks/useV4CurrentUpcomingSubPanel.ts index f397e2a84d..7561a68860 100644 --- a/src/packages/v4/views/V4ProjectDashboard/hooks/useV4CurrentUpcomingSubPanel.ts +++ b/src/packages/v4/views/V4ProjectDashboard/hooks/useV4CurrentUpcomingSubPanel.ts @@ -2,54 +2,52 @@ import { t } from '@lingui/macro' import { useJBRuleset, useReadJbRulesetsCurrentApprovalStatusForLatestRulesetOf, - useReadJbRulesetsLatestQueuedOf, } from 'juice-sdk-react' import { V4ApprovalStatus } from 'models/ballot' +import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' import { useMemo } from 'react' import { timeSecondsToDateString } from 'utils/timeSecondsToDateString' -import { useRulesetCountdown } from './useRulesetCountdown' export const useV4CurrentUpcomingSubPanel = (type: 'current' | 'upcoming') => { const { data: ruleset, isLoading: rulesetLoading } = useJBRuleset() - const { data: _latestQueuedRuleset, isLoading: queuedRulesetsLoading } = - useReadJbRulesetsLatestQueuedOf() - const { timeRemainingText } = useRulesetCountdown() + const { ruleset: latestUpcomingRuleset, isLoading: upcomingRulesetsLoading } = + useJBUpcomingRuleset() - const latestQueuedRuleset = _latestQueuedRuleset?.[0] const { data: approvalStatus } = useReadJbRulesetsCurrentApprovalStatusForLatestRulesetOf() - const rulesetNumber = useMemo(() => { if (type === 'current') { return Number(ruleset?.cycleNumber) } - return latestQueuedRuleset?.cycleNumber - ? Number(latestQueuedRuleset.cycleNumber) + return latestUpcomingRuleset?.cycleNumber + ? Number(latestUpcomingRuleset.cycleNumber) + : ruleset?.cycleNumber + ? ruleset.cycleNumber + 1 : undefined - }, [ruleset?.cycleNumber, type, latestQueuedRuleset?.cycleNumber]) + }, [ruleset?.cycleNumber, type, latestUpcomingRuleset?.cycleNumber]) const rulesetUnlocked = useMemo(() => { if (type === 'current') { - return ruleset?.duration === 0n ?? true + return ruleset?.duration === 0 ?? true } - return latestQueuedRuleset?.duration == 0n ?? true - }, [ruleset?.duration, type, latestQueuedRuleset?.duration]) + return latestUpcomingRuleset?.duration == 0 ?? true + }, [ruleset?.duration, type, latestUpcomingRuleset?.duration]) const upcomingRulesetLength = useMemo(() => { - if (!latestQueuedRuleset) return + if (!latestUpcomingRuleset) + return timeSecondsToDateString(Number(ruleset?.duration), 'short') if (rulesetUnlocked) return '-' return timeSecondsToDateString( - Number(latestQueuedRuleset.duration), + Number(latestUpcomingRuleset.duration), 'short', ) - }, [rulesetUnlocked, latestQueuedRuleset]) + }, [rulesetUnlocked, latestUpcomingRuleset, ruleset]) /** Determines if the CURRENT cycle is unlocked. * This is used to check if the upcoming cycle can start at any time. */ - const currentRulesetUnlocked = ruleset?.duration === 0n ?? true + const currentRulesetUnlocked = ruleset?.duration === 0 ?? true const status = rulesetUnlocked ? t`Unlocked` : t`Locked` - const remainingTime = rulesetUnlocked ? '-' : timeRemainingText // Short circuit current for faster loading if (type === 'current') { @@ -59,11 +57,10 @@ export const useV4CurrentUpcomingSubPanel = (type: 'current' | 'upcoming') => { type, rulesetNumber, status, - remainingTime, } } - if (rulesetLoading || queuedRulesetsLoading) + if (rulesetLoading || upcomingRulesetsLoading) return { loading: true, type, @@ -80,7 +77,7 @@ export const useV4CurrentUpcomingSubPanel = (type: 'current' | 'upcoming') => { hasPendingConfiguration: /** * If a ruleset is unlocked, it may have a pending change. - * The only way it would, is if the approval status of the latestQueuedRuleset is `approved`. + * The only way it would, is if the approval status of the latestUpcomingRuleset is `approved`. */ rulesetUnlocked && typeof approvalStatus !== 'undefined' && diff --git a/src/packages/v4/views/V4ProjectDashboard/hooks/useV4ProjectHeader.ts b/src/packages/v4/views/V4ProjectDashboard/hooks/useV4ProjectHeader.ts index ea630d929a..a09560f02b 100644 --- a/src/packages/v4/views/V4ProjectDashboard/hooks/useV4ProjectHeader.ts +++ b/src/packages/v4/views/V4ProjectDashboard/hooks/useV4ProjectHeader.ts @@ -4,13 +4,13 @@ import { useProjectTrendingPercentageIncrease } from 'hooks/useProjectTrendingPe import { SubtitleType, useSubtitle } from 'hooks/useSubtitle' import { useJBContractContext, - useJBProjectMetadataContext, - useReadJbProjectsOwnerOf, + useJBProjectMetadataContext } from 'juice-sdk-react' import { GnosisSafe } from 'models/safe' import { useRouter } from 'next/router' import { ProjectsDocument } from 'packages/v4/graphql/client/graphql' import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' +import useProjectOwnerOf from 'packages/v4/hooks/useV4ProjectOwnerOf' export interface ProjectHeaderData { title: string | undefined subtitle: { text: string; type: SubtitleType } | undefined @@ -31,9 +31,7 @@ export const useV4ProjectHeader = (): ProjectHeaderData => { const { metadata } = useJBProjectMetadataContext() const projectMetadata = metadata?.data - const { data: projectOwnerAddress } = useReadJbProjectsOwnerOf({ - args: [projectId], - }) + const { data: projectOwnerAddress } = useProjectOwnerOf() const projectIdNum = parseInt(projectId.toString()) diff --git a/src/redux/hooks/useEditingPayoutSplits.ts b/src/redux/hooks/useEditingPayoutSplits.ts index f9c2c65f9e..67efa9dfdd 100644 --- a/src/redux/hooks/useEditingPayoutSplits.ts +++ b/src/redux/hooks/useEditingPayoutSplits.ts @@ -1,4 +1,4 @@ -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { useCallback } from 'react' import { useAppDispatch } from 'redux/hooks/useAppDispatch' import { useAppSelector } from 'redux/hooks/useAppSelector' diff --git a/src/redux/hooks/useEditingReservedTokensSplits.ts b/src/redux/hooks/useEditingReservedTokensSplits.ts index c45f1b85a2..7899e7d899 100644 --- a/src/redux/hooks/useEditingReservedTokensSplits.ts +++ b/src/redux/hooks/useEditingReservedTokensSplits.ts @@ -1,4 +1,4 @@ -import { Split } from 'models/splits' +import { Split } from 'packages/v2v3/models/splits' import { useCallback } from 'react' import { useAppDispatch } from 'redux/hooks/useAppDispatch' import { useAppSelector } from 'redux/hooks/useAppSelector' diff --git a/src/redux/slices/editingV2Project/editingV2Project.ts b/src/redux/slices/editingV2Project/editingV2Project.ts index 4c2cdc4799..4cd4d38807 100644 --- a/src/redux/slices/editingV2Project/editingV2Project.ts +++ b/src/redux/slices/editingV2Project/editingV2Project.ts @@ -12,9 +12,9 @@ import { PayoutsSelection } from 'models/payoutsSelection' import { ProjectTagName } from 'models/project-tags' import { ProjectTokensSelection } from 'models/projectTokenSelection' import { ReconfigurationStrategy } from 'models/reconfigurationStrategy' -import { Split } from 'models/splits' import { TreasurySelection } from 'models/treasurySelection' import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' +import { Split } from 'packages/v2v3/models/splits' import { SerializedV2V3FundAccessConstraint, SerializedV2V3FundingCycleData, diff --git a/src/redux/slices/editingV2Project/types.ts b/src/redux/slices/editingV2Project/types.ts index 7b72d90658..f6c4e541c3 100644 --- a/src/redux/slices/editingV2Project/types.ts +++ b/src/redux/slices/editingV2Project/types.ts @@ -11,12 +11,12 @@ import { PayoutsSelection } from 'models/payoutsSelection' import { ProjectMetadata } from 'models/projectMetadata' import { ProjectTokensSelection } from 'models/projectTokenSelection' import { ReconfigurationStrategy } from 'models/reconfigurationStrategy' +import { TreasurySelection } from 'models/treasurySelection' +import { NftPricingContext } from 'packages/v2v3/hooks/JB721Delegate/contractReader/useNftCollectionPricingContext' import { ETHPayoutGroupedSplits, ReservedTokensGroupedSplits, -} from 'models/splits' -import { TreasurySelection } from 'models/treasurySelection' -import { NftPricingContext } from 'packages/v2v3/hooks/JB721Delegate/contractReader/useNftCollectionPricingContext' +} from 'packages/v2v3/models/splits' import { SerializedV2V3FundAccessConstraint, SerializedV2V3FundingCycleData, diff --git a/src/utils/antdRules/allocationInputAlreadyExistsRule.ts b/src/utils/antdRules/allocationInputAlreadyExistsRule.ts index e5bf32b16c..d5af4fa6aa 100644 --- a/src/utils/antdRules/allocationInputAlreadyExistsRule.ts +++ b/src/utils/antdRules/allocationInputAlreadyExistsRule.ts @@ -1,6 +1,6 @@ import { RuleObject } from 'antd/lib/form' import isEqual from 'lodash/isEqual' -import { projectIdToHex } from 'utils/splits' +import { projectIdToHex } from 'packages/v2v3/utils/v2v3Splits' /** * Rule is the same as {@link inputAlreadyExistsRule}, however will allow for diff --git a/src/utils/csv.ts b/src/utils/csv.ts index c0c8b856e4..d1c1da0026 100644 --- a/src/utils/csv.ts +++ b/src/utils/csv.ts @@ -1,7 +1,7 @@ import { BigNumber } from 'ethers' import { getAddress } from 'ethers/lib/utils' -import { Split } from 'models/splits' import { PayoutMod, TicketMod } from 'packages/v1/models/mods' +import { Split } from 'packages/v2v3/models/splits' import { splitPercentFrom } from 'packages/v2v3/utils/math' import { percentToPermyriad } from './format/formatNumber' diff --git a/src/utils/format/formatTime.ts b/src/utils/format/formatTime.ts index 8d8a785d0b..5c76fa4ce6 100644 --- a/src/utils/format/formatTime.ts +++ b/src/utils/format/formatTime.ts @@ -125,9 +125,9 @@ export const deriveDurationOption = ( ) } -export const formatTime = (timestamp: bigint | undefined) => { - if (timestamp === undefined || timestamp === 0n) return undefined; - const timeDate = new Date(Number(timestamp) * 1000); +export const formatTime = (timestamp: number | undefined) => { + if (timestamp === undefined || timestamp === 0) return undefined; + const timeDate = new Date(timestamp * 1000); const isoDateString = timeDate.toISOString().split('T')[0]; const formatOptions: Intl.DateTimeFormatOptions = { weekday: 'long', diff --git a/src/utils/math.ts b/src/utils/math.ts index 1c6674e9de..76b4b34c8a 100644 --- a/src/utils/math.ts +++ b/src/utils/math.ts @@ -1,3 +1,4 @@ +import { ONE_BILLION } from 'constants/numbers' import { BigNumber } from 'ethers' export type WeightFunction = ( @@ -40,3 +41,11 @@ export const roundIfCloseToNextInteger = ( } return num } + +export const feeForAmount = ( + amountWad: bigint | undefined, + feePerBillion: bigint | undefined, +): bigint | undefined => { + if (!feePerBillion || !amountWad) return + return amountWad * feePerBillion / BigInt(ONE_BILLION) +} diff --git a/src/utils/tokenSymbolText.ts b/src/utils/tokenSymbolText.ts index b689d7b81d..224b030885 100644 --- a/src/utils/tokenSymbolText.ts +++ b/src/utils/tokenSymbolText.ts @@ -12,13 +12,13 @@ export const tokenSymbolText = ({ plural?: boolean includeTokenWord?: boolean }) => { - const tokenTextSingular = capitalize ? t`Token` : t`token` - const tokenTextPlural = capitalize ? t`Tokens` : t`tokens` - const tokenText = plural ? tokenTextPlural : tokenTextSingular + const defaultTokenTextSingular = capitalize ? t`Token` : t`token` + const defaultTokenTextPlural = capitalize ? t`Tokens` : t`tokens` + const defaultTokenText = plural ? defaultTokenTextPlural : defaultTokenTextSingular if (includeTokenWord) { - return tokenSymbol ? `${tokenSymbol} ${tokenText}` : tokenText + return tokenSymbol ? `${tokenSymbol} ${defaultTokenText}` : defaultTokenText } - return tokenSymbol ?? tokenText + return tokenSymbol ?? defaultTokenText } diff --git a/yarn.lock b/yarn.lock index c285dedb65..16b0257563 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12541,18 +12541,18 @@ jsprim@^1.2.2: array-includes "^3.1.5" object.assign "^4.1.3" -juice-sdk-core@^9.1.3-alpha: - version "9.1.3-alpha" - resolved "https://registry.yarnpkg.com/juice-sdk-core/-/juice-sdk-core-9.1.3-alpha.tgz#74f6859893f86836b306e40a1bc0097da027fa9a" - integrity sha512-UGPkzu1p6i6+ITsfM7ioSoqAyJJ/7GSa4T+WTUXfS5kVxD0dIImfZnhM3HpKtqhx6FpTnD4TGiOuVBbJeRXgtw== +juice-sdk-core@^10.0.3-alpha: + version "10.0.3-alpha" + resolved "https://registry.yarnpkg.com/juice-sdk-core/-/juice-sdk-core-10.0.3-alpha.tgz#dcd1afa2faa13f42559ced3b308e5e886892c7bd" + integrity sha512-E+Wx7zv/PCOWrY9Co62ilyHa/6ge44xltTsNeaF96a3d3jWhWlkLlrDBrHDnT48VvClw0LbnRWOFvaB662OyFg== dependencies: bs58 "^5.0.0" fpnum "^1.0.0" -juice-sdk-react@^9.2.3-alpha: - version "9.2.3-alpha" - resolved "https://registry.yarnpkg.com/juice-sdk-react/-/juice-sdk-react-9.2.3-alpha.tgz#22a02fd969b001165e98c0f7283e6fbff611aa30" - integrity sha512-lLhI5C7MUs7E/k26nrrIOC5AcnKFffqOgKij8miQKuv2lQrsqKP2TCDP/lcAT6Ig+a2AciDmVpq8cmICtlxTxQ== +juice-sdk-react@^10.0.1-alpha: + version "10.0.1-alpha" + resolved "https://registry.yarnpkg.com/juice-sdk-react/-/juice-sdk-react-10.0.1-alpha.tgz#a1a9273292ed7b6f2f97e87e60c131b8cd53ee03" + integrity sha512-Lpdymh4bt2YBba2htXn/RB+Kohs1iMhu2mqzadwAtrYpsFz+dKkL5GFA1YLLSC96d31KSVJX/bArX46Uc5hnoA== juice@^10.0.0: version "10.0.0"