From 239552a4d91a4a51285b5be60eac87303a8a6578 Mon Sep 17 00:00:00 2001 From: johnnyd-eth Date: Thu, 29 Aug 2024 09:42:14 +0800 Subject: [PATCH 1/3] feat: v4 create (base) --- src/packages/v4/components/Create/Create.tsx | 123 +++++++ .../components/CreateBadge/DefaultBadge.tsx | 10 + .../components/CreateBadge/OptionalBadge.tsx | 10 + .../CreateBadge/RecommendedBadge.tsx | 14 + .../components/CreateBadge/SkippedBadge.tsx | 10 + .../Create/components/CreateBadge/index.ts | 11 + .../CreateCollapse/CreateCollapse.tsx | 30 ++ .../CreateCollapse/CreateCollapsePanel.tsx | 16 + .../Create/components/Icons/Infinity.tsx | 21 ++ .../components/Icons/ManualSettingsIcon.tsx | 21 ++ .../Create/components/Icons/TargetIcon.tsx | 21 ++ .../Create/components/Icons/TokensIcon.tsx | 21 ++ .../Create/components/Icons/index.ts | 11 + .../Create/components/OptionalHeader.tsx | 12 + .../Create/components/Selection/Selection.tsx | 57 ++++ .../components/Selection/SelectionCard.tsx | 139 ++++++++ .../Selection/components/CheckedCircle.tsx | 33 ++ .../components/RadialBackgroundIcon.tsx | 23 ++ .../Create/components/Wizard/Page.tsx | 80 +++++ .../PageButtonControl/PageButtonControl.tsx | 40 +++ .../components/BackButton.tsx | 11 + .../components/DoneButton.tsx | 11 + .../components/NextButton.tsx | 11 + .../Wizard/Steps/MobileProgressModal.tsx | 53 +++ .../components/Wizard/Steps/MobileStep.tsx | 45 +++ .../Create/components/Wizard/Steps/Steps.tsx | 88 +++++ .../Create/components/Wizard/Wizard.tsx | 51 +++ .../Wizard/contexts/PageContext.tsx | 15 + .../Wizard/contexts/WizardContext.tsx | 11 + .../Create/components/Wizard/hooks/usePage.ts | 66 ++++ .../components/Wizard/hooks/useSteps.ts | 74 +++++ .../components/Wizard/hooks/useWizard.ts | 69 ++++ .../pages/FundingCycles/FundingCyclesPage.tsx | 245 ++++++++++++++ .../hooks/useFundingCyclesForm.ts | 107 ++++++ .../pages/NftRewards/NftRewardsPage.tsx | 27 ++ .../hooks/useCreateFlowNftRewardsForm.ts | 200 +++++++++++ .../pages/PayoutsPage/PayoutsPage.tsx | 21 ++ .../components/CreateFlowPayoutsTable.tsx | 81 +++++ .../PayoutsPage/components/RadioCard.tsx | 47 +++ .../components/TreasuryOptionsRadio.tsx | 174 ++++++++++ .../hooks/useAvailablePayoutsSelections.ts | 8 + .../pages/PayoutsPage/hooks/usePayoutsForm.ts | 39 +++ .../ProjectDetails/ProjectDetailsPage.tsx | 314 ++++++++++++++++++ .../hooks/useProjectDetailsForm.ts | 237 +++++++++++++ .../pages/ProjectToken/ProjectTokenPage.tsx | 105 ++++++ .../CustomTokenSettings.tsx | 254 ++++++++++++++ .../ReservedTokenRateCallout.tsx | 44 +++ .../components/DefaultSettings.tsx | 60 ++++ .../ProjectToken/hooks/useProjectTokenForm.ts | 225 +++++++++++++ .../ReconfigurationRulesPage.tsx | 142 ++++++++ .../components/CustomRuleCard.tsx | 32 ++ .../components/RuleCard.tsx | 41 +++ .../hooks/useReconfigurationRulesForm.ts | 191 +++++++++++ .../pages/ReviewDeploy/ReviewDeployPage.tsx | 257 ++++++++++++++ .../ReviewDeploy/components/DeploySuccess.tsx | 110 ++++++ .../FundingConfigurationReview.tsx | 50 +++ .../hooks/useFundingConfigurationReview.ts | 75 +++++ .../ProjectDetailsReview.tsx | 171 ++++++++++ .../ProjectTokenReview/ProjectTokenReview.tsx | 97 ++++++ .../hooks/useProjectTokenReview.ts | 50 +++ .../components/ReviewDescription.tsx | 27 ++ .../RewardsReview/RewardsReview.tsx | 90 +++++ .../components/RulesReview/RulesReview.tsx | 76 +++++ .../RulesReview/hooks/useRulesReview.ts | 66 ++++ .../pages/hooks/useFormDispatchWatch.ts | 48 +++ .../hooks/NFT/useDeployNftProject.ts | 137 ++++++++ .../hooks/NFT/useIsNftProject.ts | 16 + .../hooks/NFT/useUploadNftRewards.ts | 62 ++++ .../hooks/useDeployStandardProject.ts | 74 +++++ .../hooks/DeployProject/useDeployProject.ts | 253 ++++++++++++++ .../useAvailableReconfigurationStrategies.ts | 31 ++ .../hooks/useLoadInitialStateFromQuery.ts | 178 ++++++++++ .../Create/hooks/useLockPageRulesWrapper.ts | 36 ++ .../determineAvailablePayoutsSelections.ts | 18 + .../utils/formatFundingCycleDuration.ts | 31 ++ .../projectTokenSettingsToReduxFormat.ts | 40 +++ src/pages/create/index.tsx | 48 ++- 77 files changed, 5833 insertions(+), 10 deletions(-) create mode 100644 src/packages/v4/components/Create/Create.tsx create mode 100644 src/packages/v4/components/Create/components/CreateBadge/DefaultBadge.tsx create mode 100644 src/packages/v4/components/Create/components/CreateBadge/OptionalBadge.tsx create mode 100644 src/packages/v4/components/Create/components/CreateBadge/RecommendedBadge.tsx create mode 100644 src/packages/v4/components/Create/components/CreateBadge/SkippedBadge.tsx create mode 100644 src/packages/v4/components/Create/components/CreateBadge/index.ts create mode 100644 src/packages/v4/components/Create/components/CreateCollapse/CreateCollapse.tsx create mode 100644 src/packages/v4/components/Create/components/CreateCollapse/CreateCollapsePanel.tsx create mode 100644 src/packages/v4/components/Create/components/Icons/Infinity.tsx create mode 100644 src/packages/v4/components/Create/components/Icons/ManualSettingsIcon.tsx create mode 100644 src/packages/v4/components/Create/components/Icons/TargetIcon.tsx create mode 100644 src/packages/v4/components/Create/components/Icons/TokensIcon.tsx create mode 100644 src/packages/v4/components/Create/components/Icons/index.ts create mode 100644 src/packages/v4/components/Create/components/OptionalHeader.tsx create mode 100644 src/packages/v4/components/Create/components/Selection/Selection.tsx create mode 100644 src/packages/v4/components/Create/components/Selection/SelectionCard.tsx create mode 100644 src/packages/v4/components/Create/components/Selection/components/CheckedCircle.tsx create mode 100644 src/packages/v4/components/Create/components/Selection/components/RadialBackgroundIcon.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/Page.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/BackButton.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/DoneButton.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/NextButton.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/Steps/MobileProgressModal.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/Steps/MobileStep.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/Steps/Steps.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/Wizard.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/contexts/PageContext.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/contexts/WizardContext.tsx create mode 100644 src/packages/v4/components/Create/components/Wizard/hooks/usePage.ts create mode 100644 src/packages/v4/components/Create/components/Wizard/hooks/useSteps.ts create mode 100644 src/packages/v4/components/Create/components/Wizard/hooks/useWizard.ts create mode 100644 src/packages/v4/components/Create/components/pages/FundingCycles/FundingCyclesPage.tsx create mode 100644 src/packages/v4/components/Create/components/pages/FundingCycles/hooks/useFundingCyclesForm.ts create mode 100644 src/packages/v4/components/Create/components/pages/NftRewards/NftRewardsPage.tsx create mode 100644 src/packages/v4/components/Create/components/pages/NftRewards/hooks/useCreateFlowNftRewardsForm.ts create mode 100644 src/packages/v4/components/Create/components/pages/PayoutsPage/PayoutsPage.tsx create mode 100644 src/packages/v4/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx create mode 100644 src/packages/v4/components/Create/components/pages/PayoutsPage/components/RadioCard.tsx create mode 100644 src/packages/v4/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx create mode 100644 src/packages/v4/components/Create/components/pages/PayoutsPage/hooks/useAvailablePayoutsSelections.ts create mode 100644 src/packages/v4/components/Create/components/pages/PayoutsPage/hooks/usePayoutsForm.ts create mode 100644 src/packages/v4/components/Create/components/pages/ProjectDetails/ProjectDetailsPage.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ProjectDetails/hooks/useProjectDetailsForm.ts create mode 100644 src/packages/v4/components/Create/components/pages/ProjectToken/ProjectTokenPage.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/ReservedTokenRateCallout.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ProjectToken/components/DefaultSettings.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts create mode 100644 src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReconfigurationRules/components/CustomRuleCard.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReconfigurationRules/components/RuleCard.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReconfigurationRules/hooks/useReconfigurationRulesForm.ts create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/DeploySuccess.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectDetailsReview/ProjectDetailsReview.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/ProjectTokenReview.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ReviewDescription.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RewardsReview/RewardsReview.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/RulesReview.tsx create mode 100644 src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/hooks/useRulesReview.ts create mode 100644 src/packages/v4/components/Create/components/pages/hooks/useFormDispatchWatch.ts create mode 100644 src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useDeployNftProject.ts create mode 100644 src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useIsNftProject.ts create mode 100644 src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useUploadNftRewards.ts create mode 100644 src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts create mode 100644 src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts create mode 100644 src/packages/v4/components/Create/hooks/useAvailableReconfigurationStrategies.ts create mode 100644 src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts create mode 100644 src/packages/v4/components/Create/hooks/useLockPageRulesWrapper.ts create mode 100644 src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts create mode 100644 src/packages/v4/components/Create/utils/formatFundingCycleDuration.ts create mode 100644 src/packages/v4/components/Create/utils/projectTokenSettingsToReduxFormat.ts diff --git a/src/packages/v4/components/Create/Create.tsx b/src/packages/v4/components/Create/Create.tsx new file mode 100644 index 0000000000..6e67c424ec --- /dev/null +++ b/src/packages/v4/components/Create/Create.tsx @@ -0,0 +1,123 @@ +import { t, Trans } from '@lingui/macro' +import { DeployButtonText } from 'components/buttons/DeployProjectButtonText' +import { + CYCLE_EXPLANATION, + RECONFIG_RULES_EXPLANATION, +} from 'components/strings' +import { readNetwork } from 'constants/networks' +import { NetworkName } from 'models/networkName' +import { useRouter } from 'next/router' +import { CreateBadge } from './components/CreateBadge' +import { FundingCyclesPage } from './components/pages/FundingCycles/FundingCyclesPage' +import { NftRewardsPage } from './components/pages/NftRewards/NftRewardsPage' +import { PayoutsPage } from './components/pages/PayoutsPage/PayoutsPage' +import { ProjectDetailsPage } from './components/pages/ProjectDetails/ProjectDetailsPage' +import { ProjectTokenPage } from './components/pages/ProjectToken/ProjectTokenPage' +import { ReconfigurationRulesPage } from './components/pages/ReconfigurationRules/ReconfigurationRulesPage' +import { DeploySuccess } from './components/pages/ReviewDeploy/components/DeploySuccess' +import { ReviewDeployPage } from './components/pages/ReviewDeploy/ReviewDeployPage' +import { Wizard } from './components/Wizard/Wizard' +import { useLoadingInitialStateFromQuery } from './hooks/useLoadInitialStateFromQuery' + +export function Create() { + const router = useRouter() + const deployedProjectId = router.query.deployedProjectId as string + const initialStateLoading = useLoadingInitialStateFromQuery() + + if (initialStateLoading) return <>XX// + + if (deployedProjectId) { + const projectId = parseInt(deployedProjectId) + return + } + + return ( +
+

+ Create a project +

+ {/* TODO: Remove wizard-create once form item css override is replaced */} +
+ }> + + + + + + + + Pay out ETH from your project to any Ethereum wallet or Juicebox + project. ETH which isn't paid out will be available for + token redemptions, or for use in future cycles. Payouts reset + each cycle. + + } + > + + + + When people pay your project, they receive its tokens. Project + tokens can be used for governance or community access, and token + holders can redeem their tokens to reclaim some ETH from your + project. You can also reserve some tokens for recipients of your + choosing. + + } + > + + + + NFTs + +
+ } + description={ + Reward your supporters with custom NFTs. + } + > + + + Edit Deadline} + description={RECONFIG_RULES_EXPLANATION} + > + + + + Review your project and deploy it to{' '} + {readNetwork.name ?? NetworkName.mainnet}. + + } + > + + + +
+ + ) +} diff --git a/src/packages/v4/components/Create/components/CreateBadge/DefaultBadge.tsx b/src/packages/v4/components/Create/components/CreateBadge/DefaultBadge.tsx new file mode 100644 index 0000000000..776ce4d92c --- /dev/null +++ b/src/packages/v4/components/Create/components/CreateBadge/DefaultBadge.tsx @@ -0,0 +1,10 @@ +import { Trans } from '@lingui/macro' +import { Badge } from 'components/Badge' + +export const DefaultBadge = () => { + return ( + + Default + + ) +} diff --git a/src/packages/v4/components/Create/components/CreateBadge/OptionalBadge.tsx b/src/packages/v4/components/Create/components/CreateBadge/OptionalBadge.tsx new file mode 100644 index 0000000000..e8c41cb1b5 --- /dev/null +++ b/src/packages/v4/components/Create/components/CreateBadge/OptionalBadge.tsx @@ -0,0 +1,10 @@ +import { Trans } from '@lingui/macro' +import { Badge } from 'components/Badge' + +export const OptionalBadge = () => { + return ( + + Optional + + ) +} diff --git a/src/packages/v4/components/Create/components/CreateBadge/RecommendedBadge.tsx b/src/packages/v4/components/Create/components/CreateBadge/RecommendedBadge.tsx new file mode 100644 index 0000000000..e638a0e834 --- /dev/null +++ b/src/packages/v4/components/Create/components/CreateBadge/RecommendedBadge.tsx @@ -0,0 +1,14 @@ +import { Trans } from '@lingui/macro' +import { Tooltip } from 'antd' +import { Badge } from 'components/Badge' +import { ReactNode } from 'react' + +export const RecommendedBadge = ({ tooltip }: { tooltip?: ReactNode }) => { + return ( + + + Recommended + + + ) +} diff --git a/src/packages/v4/components/Create/components/CreateBadge/SkippedBadge.tsx b/src/packages/v4/components/Create/components/CreateBadge/SkippedBadge.tsx new file mode 100644 index 0000000000..78d804ed7a --- /dev/null +++ b/src/packages/v4/components/Create/components/CreateBadge/SkippedBadge.tsx @@ -0,0 +1,10 @@ +import { Trans } from '@lingui/macro' +import { Badge } from 'components/Badge' + +export const SkippedBadge = () => { + return ( + + Skipped + + ) +} diff --git a/src/packages/v4/components/Create/components/CreateBadge/index.ts b/src/packages/v4/components/Create/components/CreateBadge/index.ts new file mode 100644 index 0000000000..a494314430 --- /dev/null +++ b/src/packages/v4/components/Create/components/CreateBadge/index.ts @@ -0,0 +1,11 @@ +import { DefaultBadge } from './DefaultBadge' +import { OptionalBadge } from './OptionalBadge' +import { RecommendedBadge } from './RecommendedBadge' +import { SkippedBadge } from './SkippedBadge' + +export const CreateBadge = { + Default: DefaultBadge, + Skipped: SkippedBadge, + Recommended: RecommendedBadge, + Optional: OptionalBadge, +} diff --git a/src/packages/v4/components/Create/components/CreateCollapse/CreateCollapse.tsx b/src/packages/v4/components/Create/components/CreateCollapse/CreateCollapse.tsx new file mode 100644 index 0000000000..4242959469 --- /dev/null +++ b/src/packages/v4/components/Create/components/CreateCollapse/CreateCollapse.tsx @@ -0,0 +1,30 @@ +import { DownOutlined } from '@ant-design/icons' +import { Collapse } from 'antd' +import { CreateCollapsePanel } from './CreateCollapsePanel' + +export const CreateCollapse: React.FC< + React.PropsWithChildren<{ + activeKey?: string | number | (string | number)[] + onChange?: (key: string | string[]) => void + }> +> & { + Panel: typeof CreateCollapsePanel +} = ({ activeKey, onChange, children }) => { + return ( + ( + + )} + onChange={onChange} + {...(activeKey ? { activeKey } : {})} + > + {children} + + ) +} + +CreateCollapse.Panel = CreateCollapsePanel diff --git a/src/packages/v4/components/Create/components/CreateCollapse/CreateCollapsePanel.tsx b/src/packages/v4/components/Create/components/CreateCollapse/CreateCollapsePanel.tsx new file mode 100644 index 0000000000..23d649ff4f --- /dev/null +++ b/src/packages/v4/components/Create/components/CreateCollapse/CreateCollapsePanel.tsx @@ -0,0 +1,16 @@ +import { Collapse, CollapsePanelProps, Divider } from 'antd' + +export const CreateCollapsePanel: React.FC< + React.PropsWithChildren +> = ({ hideDivider, ...props }) => { + return ( + + { + <> + {props.children} + {!hideDivider && } + + } + + ) +} diff --git a/src/packages/v4/components/Create/components/Icons/Infinity.tsx b/src/packages/v4/components/Create/components/Icons/Infinity.tsx new file mode 100644 index 0000000000..e7f4155f14 --- /dev/null +++ b/src/packages/v4/components/Create/components/Icons/Infinity.tsx @@ -0,0 +1,21 @@ +import Icon from '@ant-design/icons' +import { Property } from 'csstype' +import { SVGAttributes } from 'react' + +const _SVG: React.FC< + React.PropsWithChildren> +> = props => ( + + + +) + +export const InfinityIcon = ({ fill }: { fill?: Property.Fill }) => { + return <_SVG />} style={{ fill }} /> +} diff --git a/src/packages/v4/components/Create/components/Icons/ManualSettingsIcon.tsx b/src/packages/v4/components/Create/components/Icons/ManualSettingsIcon.tsx new file mode 100644 index 0000000000..478f0de711 --- /dev/null +++ b/src/packages/v4/components/Create/components/Icons/ManualSettingsIcon.tsx @@ -0,0 +1,21 @@ +import Icon from '@ant-design/icons' +import { Property } from 'csstype' +import { SVGAttributes } from 'react' + +const _SVG: React.FC< + React.PropsWithChildren> +> = props => ( + + + +) + +export const ManualSettingsIcon = ({ fill }: { fill?: Property.Fill }) => { + return <_SVG />} style={{ fill }} /> +} diff --git a/src/packages/v4/components/Create/components/Icons/TargetIcon.tsx b/src/packages/v4/components/Create/components/Icons/TargetIcon.tsx new file mode 100644 index 0000000000..1cad3909a7 --- /dev/null +++ b/src/packages/v4/components/Create/components/Icons/TargetIcon.tsx @@ -0,0 +1,21 @@ +import Icon from '@ant-design/icons' +import { Property } from 'csstype' +import { SVGAttributes } from 'react' + +const _SVG: React.FC< + React.PropsWithChildren> +> = props => ( + + + +) + +export const TargetIcon = ({ fill }: { fill?: Property.Fill }) => { + return <_SVG />} style={{ fill }} /> +} diff --git a/src/packages/v4/components/Create/components/Icons/TokensIcon.tsx b/src/packages/v4/components/Create/components/Icons/TokensIcon.tsx new file mode 100644 index 0000000000..641153a147 --- /dev/null +++ b/src/packages/v4/components/Create/components/Icons/TokensIcon.tsx @@ -0,0 +1,21 @@ +import Icon from '@ant-design/icons' +import { Property } from 'csstype' +import { SVGAttributes } from 'react' + +const _SVG: React.FC< + React.PropsWithChildren> +> = props => ( + + + +) + +export const TokensIcon = ({ fill }: { fill?: Property.Fill }) => { + return <_SVG />} style={{ fill }} /> +} diff --git a/src/packages/v4/components/Create/components/Icons/index.ts b/src/packages/v4/components/Create/components/Icons/index.ts new file mode 100644 index 0000000000..4ffaaf3d4f --- /dev/null +++ b/src/packages/v4/components/Create/components/Icons/index.ts @@ -0,0 +1,11 @@ +import { InfinityIcon } from './Infinity' +import { ManualSettingsIcon } from './ManualSettingsIcon' +import { TargetIcon } from './TargetIcon' +import { TokensIcon } from './TokensIcon' + +export const Icons = { + ManualSettings: ManualSettingsIcon, + Target: TargetIcon, + Infinity: InfinityIcon, + Tokens: TokensIcon, +} diff --git a/src/packages/v4/components/Create/components/OptionalHeader.tsx b/src/packages/v4/components/Create/components/OptionalHeader.tsx new file mode 100644 index 0000000000..4582104065 --- /dev/null +++ b/src/packages/v4/components/Create/components/OptionalHeader.tsx @@ -0,0 +1,12 @@ +import { Trans } from '@lingui/macro' + +export const OptionalHeader = ({ header }: { header: string }) => { + return ( + <> + {header}{' '} + + (Optional) + + + ) +} diff --git a/src/packages/v4/components/Create/components/Selection/Selection.tsx b/src/packages/v4/components/Create/components/Selection/Selection.tsx new file mode 100644 index 0000000000..acb6c17c15 --- /dev/null +++ b/src/packages/v4/components/Create/components/Selection/Selection.tsx @@ -0,0 +1,57 @@ +import React, { CSSProperties, useCallback, useState } from 'react' +import { twMerge } from 'tailwind-merge' +import { SelectionCard } from './SelectionCard' + +export const SelectionContext = React.createContext<{ + selection?: string | undefined + defocusOnSelect?: boolean + setSelection?: (selection: string | undefined) => void +}>({}) + +export const Selection: React.FC< + React.PropsWithChildren<{ + value?: string + defocusOnSelect?: boolean + disableInteractivity?: boolean + allowDeselect?: boolean + className?: string + style?: CSSProperties + onChange?: (value: string | undefined) => void + }> +> & { Card: typeof SelectionCard } = ({ + defocusOnSelect, + disableInteractivity, + allowDeselect = true, + value, + className, + onChange, + children, +}) => { + const [selection, setSelection] = useState(value) + const _selection = value ?? selection + const setSelectionWrapper = useCallback( + (selection: string | undefined) => { + const _setSelection = onChange ?? setSelection + const eventIsDeselecting = selection === undefined + if (!allowDeselect && eventIsDeselecting) return + _setSelection?.(selection ?? '') + }, + [allowDeselect, onChange], + ) + + return ( + +
+ {children} +
+
+ ) +} + +Selection.Card = SelectionCard diff --git a/src/packages/v4/components/Create/components/Selection/SelectionCard.tsx b/src/packages/v4/components/Create/components/Selection/SelectionCard.tsx new file mode 100644 index 0000000000..fd642503f5 --- /dev/null +++ b/src/packages/v4/components/Create/components/Selection/SelectionCard.tsx @@ -0,0 +1,139 @@ +import { Divider } from 'antd' +import { ReactNode, useCallback, useContext, useMemo } from 'react' +import { classNames } from 'utils/classNames' +import { SelectionContext } from './Selection' +import { CheckedCircle } from './components/CheckedCircle' +import { RadialBackgroundIcon } from './components/RadialBackgroundIcon' + +const Container: React.FC< + React.PropsWithChildren<{ + isSelected: boolean + isDefocused: boolean + isDisabled: boolean + }> +> = ({ isDefocused, isSelected, isDisabled, children }) => { + const borderColorClassNames = useMemo(() => { + if (isSelected) return 'border-bluebs-500' + return classNames( + !isDisabled ? 'hover:border-smoke-500 dark:hover:border-slate-100' : '', + isDefocused + ? 'border-smoke-200 dark:border-slate-500' + : 'border-smoke-300 dark:border-slate-300', + ) + }, [isDefocused, isDisabled, isSelected]) + + const backgroundColorClassNames = useMemo(() => { + if (isDefocused) return 'bg-smoke-50 dark:bg-slate-800' + return 'dark:bg-slate-600' + }, [isDefocused]) + + return ( +
+ {children} +
+ ) +} + +interface SelectionCardProps { + name: string + title: ReactNode + icon?: ReactNode + titleBadge?: ReactNode + description?: ReactNode + isSelected?: boolean + isDisabled?: boolean + checkPosition?: 'left' | 'right' +} + +export const SelectionCard: React.FC< + React.PropsWithChildren +> = ({ + name, + title, + icon, + description, + checkPosition = 'right', + isDisabled = false, + children, +}) => { + const { selection, defocusOnSelect, setSelection } = + useContext(SelectionContext) + const isSelected = selection === name + + const onClick = useCallback(() => { + if (isDisabled) return + if (isSelected) { + setSelection?.(undefined) + return + } + setSelection?.(name) + }, [isDisabled, isSelected, name, setSelection]) + + const defocused = + (!!defocusOnSelect && !!selection && !isSelected) || isDisabled + + /** + * Undefined means default color. + */ + const titleColorClassNames = useMemo(() => { + if (defocused) return 'text-grey-400 dark:text-slate-400' + }, [defocused]) + + const checkedCircle = ( +
+ +
+ ) + + return ( + +
+
+
+ {checkPosition === 'left' && checkedCircle} +
+ {icon && ( + + )} +
+
+
+ {title} +
+ {isSelected && description &&
{description}
} +
+ {checkPosition === 'right' && checkedCircle} +
+
+
+ {isSelected && children && ( +
+ +
{children}
+
+ )} +
+ ) +} diff --git a/src/packages/v4/components/Create/components/Selection/components/CheckedCircle.tsx b/src/packages/v4/components/Create/components/Selection/components/CheckedCircle.tsx new file mode 100644 index 0000000000..5cf596abbf --- /dev/null +++ b/src/packages/v4/components/Create/components/Selection/components/CheckedCircle.tsx @@ -0,0 +1,33 @@ +import { CheckCircleFilled } from '@ant-design/icons' +import { twMerge } from 'tailwind-merge' +import { classNames } from 'utils/classNames' + +export const CheckedCircle: React.FC< + React.PropsWithChildren<{ + className?: string + checked: boolean + defocused?: boolean + }> +> = ({ className, checked, defocused }) => { + if (checked) { + return ( + + ) + } + return ( +
+ ) +} diff --git a/src/packages/v4/components/Create/components/Selection/components/RadialBackgroundIcon.tsx b/src/packages/v4/components/Create/components/Selection/components/RadialBackgroundIcon.tsx new file mode 100644 index 0000000000..d9755a9b4c --- /dev/null +++ b/src/packages/v4/components/Create/components/Selection/components/RadialBackgroundIcon.tsx @@ -0,0 +1,23 @@ +import { ReactNode } from 'react' +import { classNames } from 'utils/classNames' + +export const RadialBackgroundIcon = ({ + icon, + isDefocused, +}: { + icon: ReactNode + isDefocused: boolean +}) => { + return ( +
+ {icon} +
+ ) +} diff --git a/src/packages/v4/components/Create/components/Wizard/Page.tsx b/src/packages/v4/components/Create/components/Wizard/Page.tsx new file mode 100644 index 0000000000..169e1e9035 --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/Page.tsx @@ -0,0 +1,80 @@ +import { Trans } from '@lingui/macro' +import useMobile from 'hooks/useMobile' +import { ReactNode } from 'react' +import { twMerge } from 'tailwind-merge' +import { PageButtonControl } from './PageButtonControl/PageButtonControl' +import { Steps } from './Steps/Steps' +import { PageContext } from './contexts/PageContext' +import { usePage } from './hooks/usePage' + +export interface PageProps { + className?: string + name: string + title?: ReactNode + description?: ReactNode +} + +export const Page: React.FC> & { + ButtonControl: typeof PageButtonControl +} = ({ className, name, title, description, children }) => { + const isMobile = useMobile() + const { + canGoBack, + isFinalPage, + isHidden, + doneText, + nextPageName, + goToPreviousPage, + goToNextPage, + lockPageProgress, + unlockPageProgress, + } = usePage({ + name, + }) + + if (isHidden) return null + + return ( + +
+
+
+
+

+ {title} +

+ {isMobile && nextPageName && ( +
+ Next: {nextPageName} +
+ )} +
{' '} + {isMobile && } +
+ +

{description}

+
+
{children}
+
+
+ ) +} + +Page.ButtonControl = PageButtonControl diff --git a/src/packages/v4/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx b/src/packages/v4/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx new file mode 100644 index 0000000000..bc568c0773 --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx @@ -0,0 +1,40 @@ +import { useContext } from 'react' +import { PageContext } from '../contexts/PageContext' +import { BackButton } from './components/BackButton' +import { DoneButton } from './components/DoneButton' +import { NextButton } from './components/NextButton' + +export const PageButtonControl = ({ + isNextEnabled = true, // Default enabled if not supplied + isNextLoading = false, // Default not loading if not supplied + onPageDone, +}: { + isNextEnabled?: boolean + isNextLoading?: boolean + onPageDone?: () => void +}) => { + const { canGoBack, isFinalPage, doneText, goToPreviousPage } = + useContext(PageContext) + + return ( +
+ {canGoBack && } +
+ {!isFinalPage ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/BackButton.tsx b/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/BackButton.tsx new file mode 100644 index 0000000000..6c5d59abca --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/BackButton.tsx @@ -0,0 +1,11 @@ +import { ArrowLeftOutlined } from '@ant-design/icons' +import { Trans } from '@lingui/macro' +import { Button, ButtonProps } from 'antd' + +export const BackButton = (props: ButtonProps) => { + return ( + + ) +} diff --git a/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/DoneButton.tsx b/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/DoneButton.tsx new file mode 100644 index 0000000000..7fc2b3002c --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/DoneButton.tsx @@ -0,0 +1,11 @@ +import { t } from '@lingui/macro' +import { Button, ButtonProps } from 'antd' +import { ReactNode } from 'react' + +export const DoneButton = (props: ButtonProps & { text?: ReactNode }) => { + return ( + + ) +} diff --git a/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/NextButton.tsx b/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/NextButton.tsx new file mode 100644 index 0000000000..2969196c1a --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/PageButtonControl/components/NextButton.tsx @@ -0,0 +1,11 @@ +import { ArrowRightOutlined } from '@ant-design/icons' +import { Trans } from '@lingui/macro' +import { Button, ButtonProps } from 'antd' + +export const NextButton = (props: ButtonProps) => { + return ( + + ) +} diff --git a/src/packages/v4/components/Create/components/Wizard/Steps/MobileProgressModal.tsx b/src/packages/v4/components/Create/components/Wizard/Steps/MobileProgressModal.tsx new file mode 100644 index 0000000000..3f235a32a5 --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/Steps/MobileProgressModal.tsx @@ -0,0 +1,53 @@ +import { Trans } from '@lingui/macro' +import { Modal } from 'antd' +import { MobileStep } from './MobileStep' + +export const MobileProgressModal: React.FC< + React.PropsWithChildren<{ + steps: { id: string; title: string; disabled: boolean }[] + furthestStepIndex: number + currentStepIndex: number + open?: boolean + onStepClicked?: (index: number) => void + onCancel?: VoidFunction + }> +> = ({ + steps, + furthestStepIndex, + currentStepIndex, + open, + onStepClicked, + onCancel, +}) => { + return ( + +

+ Create a project +

+ + } + footer={null} + open={open} + onCancel={onCancel} + > + {steps?.map((step, index) => { + return ( + + ) + })} +
+ ) +} diff --git a/src/packages/v4/components/Create/components/Wizard/Steps/MobileStep.tsx b/src/packages/v4/components/Create/components/Wizard/Steps/MobileStep.tsx new file mode 100644 index 0000000000..2299da99fe --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/Steps/MobileStep.tsx @@ -0,0 +1,45 @@ +import { CheckCircleFilled } from '@ant-design/icons' +import { useCallback } from 'react' +import { classNames } from 'utils/classNames' + +export const MobileStep = ({ + step, + index, + selected, + isCompleted, + onClick, +}: { + step: { id: string; title: string; disabled: boolean } + index: number + selected: boolean + isCompleted: boolean + onClick?: (index: number) => void +}) => { + const handleOnClick = useCallback(() => { + if (step.disabled) return + onClick?.(index) + }, [index, onClick, step.disabled]) + + return ( +
+
+ + {index + 1}. {step.title} + + {isCompleted && } +
+
+ ) +} diff --git a/src/packages/v4/components/Create/components/Wizard/Steps/Steps.tsx b/src/packages/v4/components/Create/components/Wizard/Steps/Steps.tsx new file mode 100644 index 0000000000..14a944aa66 --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/Steps/Steps.tsx @@ -0,0 +1,88 @@ +import { Steps as AntSteps, Progress } from 'antd' +import useMobile from 'hooks/useMobile' +import { useModal } from 'hooks/useModal' +import { useCallback } from 'react' +import { useSteps } from '../hooks/useSteps' +import { MobileProgressModal } from './MobileProgressModal' + +export const Steps = () => { + const isMobile = useMobile() + const { steps, current, furthestStepReached, onStepClicked } = useSteps() + const progressModal = useModal() + + const renderSteps = useCallback( + (steps: { id: string; title: string; disabled: boolean }[]) => { + const getStatus = (index: number) => { + if (index <= furthestStepReached.index) { + if (index < (current.index ?? -1)) { + return 'finish' + } + return 'process' + } + return 'wait' + } + + return steps.map((step, index) => { + return ( + + ) + }) + }, + [current.index, furthestStepReached.index], + ) + + if (isMobile) { + return ( + <> +
+ ( +
+ {current.index !== undefined ? current.index + 1 : '??'}/ + {steps?.length ?? '??'} +
+ )} + percent={ + ((current.index !== undefined ? current.index + 1 : 0) / + (steps?.length ?? 1)) * + 100 + } + type="circle" + /> +
+ + + ) + } + + return ( +
+ + {!!steps?.length && renderSteps(steps)} + +
+ ) +} diff --git a/src/packages/v4/components/Create/components/Wizard/Wizard.tsx b/src/packages/v4/components/Create/components/Wizard/Wizard.tsx new file mode 100644 index 0000000000..a796e7ea43 --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/Wizard.tsx @@ -0,0 +1,51 @@ +import useMobile from 'hooks/useMobile' +import React, { ReactNode } from 'react' +import { twJoin } from 'tailwind-merge' +import { Page } from './Page' +import { Steps } from './Steps/Steps' +import { WizardContext } from './contexts/WizardContext' +import { useWizard } from './hooks/useWizard' + +const WizardContainer: React.FC< + React.PropsWithChildren<{ + className?: string + }> +> = ({ children, className }) => { + return ( +
+ {children} +
+ ) +} + +export const Wizard: React.FC< + React.PropsWithChildren<{ + className?: string + doneText?: ReactNode + }> +> & { + Page: typeof Page +} = props => { + const isMobile = useMobile() + const { currentPage, pages, goToPage } = useWizard({ + children: React.Children.toArray(props.children), + }) + + return ( + + + {!isMobile && } + {props.children} + + + ) +} + +Wizard.Page = Page diff --git a/src/packages/v4/components/Create/components/Wizard/contexts/PageContext.tsx b/src/packages/v4/components/Create/components/Wizard/contexts/PageContext.tsx new file mode 100644 index 0000000000..4d312ba620 --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/contexts/PageContext.tsx @@ -0,0 +1,15 @@ +import { createContext, ReactNode } from 'react' + +export const PageContext: React.Context< + Partial<{ + pageName: string + isHidden: boolean + canGoBack: boolean + isFinalPage: boolean + doneText: ReactNode + goToNextPage: () => void + goToPreviousPage: () => void + lockPageProgress: () => void + unlockPageProgress: () => void + }> +> = createContext({}) diff --git a/src/packages/v4/components/Create/components/Wizard/contexts/WizardContext.tsx b/src/packages/v4/components/Create/components/Wizard/contexts/WizardContext.tsx new file mode 100644 index 0000000000..07b2def639 --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/contexts/WizardContext.tsx @@ -0,0 +1,11 @@ +import { createContext, ReactNode } from 'react' +import { PageProps } from '../Page' + +export const WizardContext: React.Context< + Partial<{ + currentPage: string + goToPage: (page: string) => void + pages: PageProps[] + doneText: ReactNode + }> +> = createContext({}) diff --git a/src/packages/v4/components/Create/components/Wizard/hooks/usePage.ts b/src/packages/v4/components/Create/components/Wizard/hooks/usePage.ts new file mode 100644 index 0000000000..ca1573a61b --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/hooks/usePage.ts @@ -0,0 +1,66 @@ +import { CreatePage } from 'models/createPage' +import { useCallback, useContext, useMemo } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { editingV2ProjectActions } from 'redux/slices/editingV2Project' +import { WizardContext } from '../contexts/WizardContext' + +export const usePage = ({ name }: { name: string }) => { + const dispatch = useAppDispatch() + const { currentPage, pages, goToPage, doneText } = useContext(WizardContext) + + const pageIndex = useMemo( + () => pages?.findIndex(p => p.name === name) ?? -1, + [name, pages], + ) + const isHidden = useMemo(() => name !== currentPage, [name, currentPage]) + const canGoBack = useMemo(() => pageIndex > 0, [pageIndex]) + const isFinalPage = useMemo( + () => pageIndex >= 0 && pageIndex === (pages?.length ?? 0) - 1, + [pageIndex, pages?.length], + ) + + const nextPageName = useMemo( + () => (!isFinalPage && pages ? pages[pageIndex + 1].title : undefined), + [isFinalPage, pageIndex, pages], + ) + + const goToNextPage = useCallback(() => { + if (!pages || !goToPage) return + if (pageIndex === pages.length - 1) return + const nextPage = pages[pageIndex + 1].name + + goToPage(nextPage) + }, [goToPage, pageIndex, pages]) + + const goToPreviousPage = useCallback(() => { + if (!pages || !goToPage) return + if (pageIndex <= 0) return + const previousPage = pages[pageIndex - 1].name + goToPage(previousPage) + }, [goToPage, pageIndex, pages]) + + const lockPageProgress = useCallback(() => { + dispatch( + editingV2ProjectActions.addCreateSoftLockedPage(name as CreatePage), + ) + }, [dispatch, name]) + + const unlockPageProgress = useCallback(() => { + // We need to make sure pages can't unsoftlock other pages :\ + dispatch( + editingV2ProjectActions.removeCreateSoftLockedPage(name as CreatePage), + ) + }, [dispatch, name]) + + return { + isHidden, + canGoBack, + isFinalPage, + doneText, + nextPageName, + goToNextPage, + goToPreviousPage, + lockPageProgress, + unlockPageProgress, + } +} diff --git a/src/packages/v4/components/Create/components/Wizard/hooks/useSteps.ts b/src/packages/v4/components/Create/components/Wizard/hooks/useSteps.ts new file mode 100644 index 0000000000..1947d1ff9c --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/hooks/useSteps.ts @@ -0,0 +1,74 @@ +import { t } from '@lingui/macro' +import { CreatePage } from 'models/createPage' +import { useCallback, useContext, useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { useEditingCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { WizardContext } from '../contexts/WizardContext' + +const stepNames = (): Record => { + return { + projectDetails: t`Details`, + fundingCycles: t`Cycles`, + payouts: t`Payouts`, + projectToken: t`Token`, + nftRewards: t`NFTs`, + reconfigurationRules: t`Deadline`, + reviewDeploy: t`Deploy`, + } +} + +export const useSteps = () => { + const { pages, currentPage, goToPage } = useContext(WizardContext) + const { furthestPageReached } = useEditingCreateFurthestPageReached() + const softLockedPageQueue = useAppSelector( + state => state.editingV2Project.createSoftLockPageQueue, + ) + + const firstIndexOfLockedPage = useMemo(() => { + const index = Object.keys(stepNames()).findIndex(stepName => + softLockedPageQueue?.includes(stepName as CreatePage), + ) + return index === -1 ? undefined : index + }, [softLockedPageQueue]) + + const furthertStepIndex = useMemo(() => { + if (firstIndexOfLockedPage !== undefined) return firstIndexOfLockedPage + return Object.keys(stepNames()).indexOf(furthestPageReached) + }, [firstIndexOfLockedPage, furthestPageReached]) + + if (!pages?.length || !currentPage) { + console.warn( + 'Steps used but no pages found. Did you forget to add WizardContext.Provider, or add pages?', + { pages, currentPage }, + ) + } + + const steps = useMemo( + () => + pages?.map((p, i) => ({ + id: p.name, + title: stepNames()[p.name], + disabled: i > furthertStepIndex, + })), + [furthertStepIndex, pages], + ) + + const currentIndex = useMemo( + () => pages?.findIndex(page => page.name === currentPage), + [currentPage, pages], + ) + + const onStepClicked = useCallback( + (index: number) => { + goToPage?.(pages?.[index].name ?? '') + }, + [goToPage, pages], + ) + + return { + steps, + current: { index: currentIndex }, + furthestStepReached: { index: furthertStepIndex }, + onStepClicked, + } +} diff --git a/src/packages/v4/components/Create/components/Wizard/hooks/useWizard.ts b/src/packages/v4/components/Create/components/Wizard/hooks/useWizard.ts new file mode 100644 index 0000000000..b14c816043 --- /dev/null +++ b/src/packages/v4/components/Create/components/Wizard/hooks/useWizard.ts @@ -0,0 +1,69 @@ +import { CreatePage } from 'models/createPage' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { useEditingCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { PageProps } from '../Page' + +const isPage = (element: PageProps | undefined) => { + return element?.name !== undefined +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const useWizard = ({ children }: { children?: any[] }) => { + const [currentPage, setCurrentPage] = useState('') + const { furthestPageReached } = useEditingCreateFurthestPageReached() + const softLockedPageQueue = useAppSelector( + state => state.editingV2Project.createSoftLockPageQueue, + ) + + const pages: PageProps[] = useMemo(() => { + if (!children) return [] + return children + .map(child => { + if (!child.props || (child.props && !isPage(child.props))) { + console.warn('Invalid child in Wizard', { child }) + return undefined + } + return { + name: child.props.name, + title: child.props.title, + description: child.props.description, + } + }) + .filter(p => !!p) as PageProps[] + }, [children]) + + const firstPageAvailable = useMemo(() => { + if (softLockedPageQueue?.length) { + return ( + pages.find(p => softLockedPageQueue.includes(p.name as CreatePage)) + ?.name || '' + ) + } + return furthestPageReached ?? '' + }, [furthestPageReached, pages, softLockedPageQueue]) + + const goToPage = useCallback( + (page: string) => { + if (pages.find(p => p.name === page)) { + setCurrentPage(page) + return + } + console.error('Invalid page called to goToPage', { + page, + pages: pages.map(p => p.name), + }) + }, + [pages], + ) + + useEffect(() => { + setCurrentPage( + firstPageAvailable ? firstPageAvailable : pages[0]?.name ?? '', + ) + // We only want to run useEffect once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { currentPage, setCurrentPage, pages, goToPage } +} diff --git a/src/packages/v4/components/Create/components/pages/FundingCycles/FundingCyclesPage.tsx b/src/packages/v4/components/Create/components/pages/FundingCycles/FundingCyclesPage.tsx new file mode 100644 index 0000000000..7caef86242 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/FundingCycles/FundingCyclesPage.tsx @@ -0,0 +1,245 @@ +import { + CheckCircleFilled, + InfoCircleOutlined, + RedoOutlined, +} from '@ant-design/icons' +import { Trans, t } from '@lingui/macro' +import { Form, Tooltip } from 'antd' +import { useWatch } from 'antd/lib/form/Form' +import { Callout } from 'components/Callout/Callout' +import { DurationInput } from 'components/inputs/DurationInput' +import { JuiceDatePicker } from 'components/inputs/JuiceDatePicker' +import { CREATE_FLOW } from 'constants/fathomEvents' +import { trackFathomGoal } from 'lib/fathom' +import moment from 'moment' +import Link from 'next/link' +import { useLockPageRulesWrapper } from 'packages/v2v3/components/Create/hooks/useLockPageRulesWrapper' +import { useContext, useEffect } from 'react' +import { useSetCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { durationMustExistRule } from 'utils/antdRules' +import { CreateBadge } from '../../CreateBadge' +import { CreateCollapse } from '../../CreateCollapse/CreateCollapse' +import { Icons } from '../../Icons' +import { OptionalHeader } from '../../OptionalHeader' +import { Selection } from '../../Selection/Selection' +import { Wizard } from '../../Wizard/Wizard' +import { PageContext } from '../../Wizard/contexts/PageContext' +import { + FundingCyclesFormProps, + useFundingCyclesForm, +} from './hooks/useFundingCyclesForm' + +const FundingCycleCallout: React.FC> = () => { + const form = Form.useFormInstance() + const selection = useWatch('selection', form) + + if (!selection) return null + + switch (selection) { + case 'automated': + return ( + +

+ + Cycle #1 starts when you create your project. With locked cycles, + if you edit your project's rules during Cycle #1, those edits will + be queued for the next cycle. + +

+

+ + In other words: instead of taking effect immediately, those edits + will take effect when the next cycle starts (Cycle #2). If you + need more flexibility, switch to unlocked cycles. + +

+
+ ) + case 'manual': + return ( + + + Cycle #1 starts when you create your project. With unlocked cycles, + you can edit your project's rules at any time. This gives you more + flexibility, but may appear risky to supporters. Switching to locked + cycles will help you build supporter confidence. + + + ) + } +} + +export const FundingCyclesPage = () => { + useSetCreateFurthestPageReached('fundingCycles') + const { goToNextPage, lockPageProgress, unlockPageProgress } = + useContext(PageContext) + const { form, initialValues } = useFundingCyclesForm() + const lockPageRulesWrapper = useLockPageRulesWrapper() + + const launchDate = useWatch('launchDate', form) + const selection = useWatch('selection', form) + const isNextEnabled = !!selection + + // A bit of a workaround to soft lock the page when the user edits data. + useEffect(() => { + if (!selection) { + lockPageProgress?.() + return + } + if (selection === 'automated') { + const duration = form.getFieldValue('duration') + if (!duration?.duration) { + lockPageProgress?.() + return + } + } + unlockPageProgress?.() + }, [form, isNextEnabled, lockPageProgress, selection, unlockPageProgress]) + + return ( +
{ + goToNextPage?.() + trackFathomGoal(CREATE_FLOW.CYCLES_NEXT_CTA) + }} + scrollToFirstError + > +
+
+ + + + Locked Cycles{' '} + + +

+ With Locked Cycles, your project's rules are + locked for a period of time. +

+

+ + This helps build trust with your contributors. + +

+
+ + } + /> +
+ } + description={t`Set a duration for locked cycles.`} + icon={} + > + + Your project's rules cannot be + edited during the first cycle. + + } + rules={lockPageRulesWrapper([ + durationMustExistRule({ label: t`Cycle duration` }), + ])} + > + + + + } + /> + + + {selection && ( + + + + {launchDate && ( + + )} +
+ } + key={0} + hideDivider + > + + + Set a future date & time to start your project's first + cycle. + + + } + extra={ + launchDate ? ( + + Your project's first cycle will start on{' '} + + {launchDate.clone().format('YYYY-MM-DD')} at{' '} + {launchDate.clone().format('HH:mm:ss z')} + + . Your project will be visible on{' '} + juicebox.money once you finish + setting your project up, but supporters won't be able to + pay or interact with it until the first cycle begins. + + ) : ( + + Leave this blank to start your first cycle immediately + after you finish setting up your project. + + ) + } + > + + { + if (!current) return false + const now = moment() + if ( + current.isSame(now, 'day') || + current.isAfter(now, 'day') + ) + return false + return true + }} + showTime={{ defaultValue: moment('00:00:00') }} + /> + + + + + )} +
+ + + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/FundingCycles/hooks/useFundingCyclesForm.ts b/src/packages/v4/components/Create/components/pages/FundingCycles/hooks/useFundingCyclesForm.ts new file mode 100644 index 0000000000..31f668ec79 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/FundingCycles/hooks/useFundingCyclesForm.ts @@ -0,0 +1,107 @@ +import { useForm, useWatch } from 'antd/lib/form/Form' +import { DurationInputValue } from 'components/inputs/DurationInput' +import moment from 'moment' +import { useDebugValue, useEffect, useMemo } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { + DEFAULT_MUST_START_AT_OR_AFTER, + editingV2ProjectActions, +} from 'redux/slices/editingV2Project' +import { + deriveDurationUnit, + otherUnitToSeconds, + secondsToOtherUnit, +} from 'utils/format/formatTime' + +export type FundingCyclesFormProps = Partial<{ + selection: 'automated' | 'manual' + duration: DurationInputValue + launchDate: moment.Moment +}> + +export const useFundingCyclesForm = () => { + const [form] = useForm() + const { fundingCycleData, fundingCyclesPageSelection, mustStartAtOrAfter } = + useAppSelector(state => state.editingV2Project) + useDebugValue(form.getFieldsValue()) + + const initialValues: FundingCyclesFormProps | undefined = useMemo(() => { + const selection = fundingCyclesPageSelection + const launchDate = + mustStartAtOrAfter !== DEFAULT_MUST_START_AT_OR_AFTER && + !isNaN(parseFloat(mustStartAtOrAfter)) + ? moment.unix(parseFloat(mustStartAtOrAfter)) + : undefined + + if (!fundingCycleData.duration?.length || selection !== 'automated') { + // Return default values if the user hasn't selected a funding cycle type yet. + return { duration: { duration: 14, unit: 'days' }, selection, launchDate } + } + + const durationInSeconds = parseInt(fundingCycleData.duration) + const durationUnit = deriveDurationUnit(durationInSeconds) + const duration = secondsToOtherUnit({ + duration: durationInSeconds, + unit: durationUnit, + }) + + return { + selection, + duration: { duration, unit: durationUnit }, + launchDate, + } + }, [ + fundingCycleData.duration, + fundingCyclesPageSelection, + mustStartAtOrAfter, + ]) + + const dispatch = useAppDispatch() + const selection = useWatch('selection', form) + const duration = useWatch('duration', form) + const launchDate = useWatch('launchDate', form) + + useEffect(() => { + dispatch(editingV2ProjectActions.setFundingCyclesPageSelection(selection)) + + // We need to handle manual case first as duration might be undefined, but + // manual set. + if (selection === 'manual') { + dispatch(editingV2ProjectActions.setDuration('0')) + return + } + + if (!selection || duration?.duration === undefined) { + dispatch(editingV2ProjectActions.setDuration('')) + return + } + if (selection === 'automated') { + const newDuration = otherUnitToSeconds({ + duration: duration.duration, + unit: duration.unit, + }) + dispatch(editingV2ProjectActions.setDuration(newDuration.toString())) + return + } + }, [selection, duration, dispatch]) + + useEffect(() => { + if (launchDate === undefined) return + if (launchDate === null || !launchDate.unix().toString()) { + dispatch( + editingV2ProjectActions.setMustStartAtOrAfter( + DEFAULT_MUST_START_AT_OR_AFTER, + ), + ) + return + } + dispatch( + editingV2ProjectActions.setMustStartAtOrAfter( + launchDate?.unix().toString(), + ), + ) + }, [dispatch, launchDate]) + + return { form, initialValues } +} diff --git a/src/packages/v4/components/Create/components/pages/NftRewards/NftRewardsPage.tsx b/src/packages/v4/components/Create/components/pages/NftRewards/NftRewardsPage.tsx new file mode 100644 index 0000000000..79b9e9612c --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/NftRewards/NftRewardsPage.tsx @@ -0,0 +1,27 @@ +import { AddNftCollectionForm } from 'components/NftRewards/AddNftCollectionForm/AddNftCollectionForm' +import { CREATE_FLOW } from 'constants/fathomEvents' +import { trackFathomGoal } from 'lib/fathom' +import { useContext } from 'react' +import { useSetCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { Wizard } from '../../Wizard/Wizard' +import { PageContext } from '../../Wizard/contexts/PageContext' +import { useCreateFlowNftRewardsForm } from './hooks/useCreateFlowNftRewardsForm' + +export function NftRewardsPage() { + const { goToNextPage } = useContext(PageContext) + + const { form, initialValues } = useCreateFlowNftRewardsForm() + useSetCreateFurthestPageReached('nftRewards') + + return ( + } + onFinish={() => { + goToNextPage?.() + trackFathomGoal(CREATE_FLOW.NFT_NEXT_CTA) + }} + /> + ) +} diff --git a/src/packages/v4/components/Create/components/pages/NftRewards/hooks/useCreateFlowNftRewardsForm.ts b/src/packages/v4/components/Create/components/pages/NftRewards/hooks/useCreateFlowNftRewardsForm.ts new file mode 100644 index 0000000000..e308334d8a --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/NftRewards/hooks/useCreateFlowNftRewardsForm.ts @@ -0,0 +1,200 @@ +import { Form } from 'antd' +import { NftRewardsFormProps } from 'components/NftRewards/AddNftCollectionForm/AddNftCollectionForm' +import { NftRewardTier } from 'models/nftRewards' +import { useEffect, useMemo } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { editingV2ProjectActions } from 'redux/slices/editingV2Project' +import { withHttps, withoutHttp } from 'utils/externalLink' +import { + defaultNftCollectionDescription, + defaultNftCollectionName, +} from 'utils/nftRewards' +import { useFormDispatchWatch } from '../../hooks/useFormDispatchWatch' + +export const useCreateFlowNftRewardsForm = () => { + const [form] = Form.useForm() + const { + collectionMetadata, + rewardTiers, + postPayModal, + governanceType, + flags, + } = useAppSelector(state => state.editingV2Project.nftRewards) + const { projectMetadata, fundingCycleMetadata } = useAppSelector( + state => state.editingV2Project, + ) + const initialValues: NftRewardsFormProps = useMemo(() => { + const collectionName = + collectionMetadata?.name ?? + defaultNftCollectionName(projectMetadata.name!) + const collectionDescription = + collectionMetadata?.description ?? + defaultNftCollectionDescription(projectMetadata.name!) + const collectionSymbol = collectionMetadata?.symbol + + const rewards: NftRewardTier[] = + rewardTiers?.map(t => ({ + id: Math.floor(Math.random() * 1000000), + name: t.name, + contributionFloor: t.contributionFloor, + description: t.description, + maxSupply: t.maxSupply, + remainingSupply: t.maxSupply, + externalLink: t.externalLink, + fileUrl: t.fileUrl, + beneficiary: t.beneficiary, + reservedRate: t.reservedRate, + votingWeight: t.votingWeight, + })) ?? [] + + return { + rewards, + onChainGovernance: governanceType, + useDataSourceForRedeem: fundingCycleMetadata.useDataSourceForRedeem, + preventOverspending: flags?.preventOverspending, + collectionName, + collectionSymbol, + collectionDescription, + postPayMessage: postPayModal?.content, + postPayButtonText: postPayModal?.ctaText, + postPayButtonLink: withoutHttp(postPayModal?.ctaLink), + } + }, [ + collectionMetadata?.name, + collectionMetadata?.description, + collectionMetadata?.symbol, + projectMetadata.name, + rewardTiers, + governanceType, + postPayModal?.content, + postPayModal?.ctaText, + postPayModal?.ctaLink, + fundingCycleMetadata.useDataSourceForRedeem, + flags.preventOverspending, + ]) + + useFormDispatchWatch({ + form, + fieldName: 'rewards', + ignoreUndefined: true, // Needed to stop an infinite loop + currentValue: rewardTiers, + dispatchFunction: editingV2ProjectActions.setNftRewardTiers, + formatter: v => { + if (!v) return [] + if (typeof v !== 'object') { + console.error('Invalid type passed to setNftRewardTiers dispatch', v) + throw new Error('Invalid type passed to setNftRewardTiers dispatch') + } + return v.map(reward => ({ + contributionFloor: reward.contributionFloor, + maxSupply: reward.maxSupply, + remainingSupply: reward.maxSupply, + fileUrl: reward.fileUrl, + name: reward.name, + id: reward.id, + externalLink: reward.externalLink, + description: reward.description, + beneficiary: reward.beneficiary, + reservedRate: reward.reservedRate, + votingWeight: reward.votingWeight, + })) + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'collectionName', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setNftRewardsName, + formatter: v => { + if (!v || typeof v !== 'string') return '' + return v + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'collectionSymbol', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setNftRewardsSymbol, + formatter: v => { + if (!v || typeof v !== 'string') return '' + return v + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'collectionDescription', + ignoreUndefined: true, + dispatchFunction: + editingV2ProjectActions.setNftRewardsCollectionDescription, + formatter: v => { + if (!v || typeof v !== 'string') return '' + return v + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'useDataSourceForRedeem', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setUseDataSourceForRedeem, + formatter: v => !!v, + }) + + useFormDispatchWatch({ + form, + fieldName: 'preventOverspending', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setNftPreventOverspending, + formatter: v => !!v, + }) + + const dispatch = useAppDispatch() + const postPayMessage = Form.useWatch('postPayMessage', form) + const postPayButtonText = Form.useWatch('postPayButtonText', form) + const postPayButtonLink = Form.useWatch('postPayButtonLink', form) + const postPayFormProps = useMemo( + () => + postPayMessage === undefined && + postPayButtonText === undefined && + postPayButtonLink === undefined + ? undefined + : { + postPayMessage, + postPayButtonText, + postPayButtonLink, + }, + [postPayButtonLink, postPayButtonText, postPayMessage], + ) + + useEffect(() => { + // This will occur when the page is loaded with the payment success popup collapsed. + if (postPayFormProps === undefined) return + if ( + postPayMessage === undefined && + postPayButtonText === undefined && + postPayButtonLink === undefined + ) { + dispatch(editingV2ProjectActions.setNftPostPayModalConfig(undefined)) + return + } + dispatch( + editingV2ProjectActions.setNftPostPayModalConfig({ + content: postPayMessage, + ctaText: postPayButtonText, + ctaLink: withHttps(postPayButtonLink), + }), + ) + }, [ + dispatch, + form, + postPayButtonLink, + postPayButtonText, + postPayFormProps, + postPayMessage, + ]) + return { form, initialValues } +} diff --git a/src/packages/v4/components/Create/components/pages/PayoutsPage/PayoutsPage.tsx b/src/packages/v4/components/Create/components/pages/PayoutsPage/PayoutsPage.tsx new file mode 100644 index 0000000000..8cba9c606d --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/PayoutsPage/PayoutsPage.tsx @@ -0,0 +1,21 @@ +import { useContext } from 'react' +import { useSetCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { Wizard } from '../../Wizard/Wizard' +import { PageContext } from '../../Wizard/contexts/PageContext' +import { CreateFlowPayoutsTable } from './components/CreateFlowPayoutsTable' +import { TreasuryOptionsRadio } from './components/TreasuryOptionsRadio' + +export const PayoutsPage = () => { + useSetCreateFurthestPageReached('payouts') + const { goToNextPage } = useContext(PageContext) + + return ( + { + goToNextPage?.() + }} + okButton={} + topAccessory={} + /> + ) +} diff --git a/src/packages/v4/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx new file mode 100644 index 0000000000..1cafe219a4 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx @@ -0,0 +1,81 @@ +import { Form } from 'antd' +import { CURRENCY_METADATA, CurrencyName } from 'constants/currency' +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 { usePayoutsForm } from '../hooks/usePayoutsForm' + +const DEFAULT_CURRENCY_NAME = CURRENCY_METADATA.ETH.name + +export function CreateFlowPayoutsTable({ + onFinish, + topAccessory, + okButton, + addPayoutsDisabled, +}: { + onFinish?: VoidFunction + okButton?: ReactNode + topAccessory?: ReactNode + addPayoutsDisabled?: boolean +}) { + const [ + editingDistributionLimit, + , + setDistributionLimitAmount, + setDistributionLimitCurrency, + ] = useEditingDistributionLimit() + + const { form, initialValues } = usePayoutsForm() + const distributionLimit = !editingDistributionLimit + ? 0 + : editingDistributionLimit.amount.eq(MAX_DISTRIBUTION_LIMIT) + ? undefined + : parseFloat(fromWad(editingDistributionLimit?.amount)) + + const splits: Split[] = + form.getFieldValue('payoutsList')?.map(allocationToSplit) ?? [] + + const setDistributionLimit = (amount: number | undefined) => { + setDistributionLimitAmount( + amount === undefined ? MAX_DISTRIBUTION_LIMIT : parseWad(amount), + ) + } + const setCurrency = (currency: CurrencyName) => { + setDistributionLimitCurrency(getV2V3CurrencyOption(currency)) + } + + const setSplits = (splits: Split[]) => { + form.setFieldsValue({ payoutsList: splits.map(splitToAllocation) }) + } + + return ( +
+ + {/* Empty form item just to keep AntD useWatch happy */} + + {okButton} + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/PayoutsPage/components/RadioCard.tsx b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/RadioCard.tsx new file mode 100644 index 0000000000..c46dbe67be --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/RadioCard.tsx @@ -0,0 +1,47 @@ +import { CheckedCircle } from 'packages/v2v3/components/Create/components/Selection/components/CheckedCircle' +import { ReactNode } from 'react' +import { twMerge } from 'tailwind-merge' + +export const RadioCard: React.FC< + React.PropsWithChildren<{ + icon?: ReactNode + title: ReactNode + checked?: boolean + }> +> = ({ icon, title, checked }) => { + const selectable = !checked + return ( +
+ + + {icon} + + + {title} + + + +
+ ) +} diff --git a/src/packages/v4/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx new file mode 100644 index 0000000000..0389eae6df --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx @@ -0,0 +1,174 @@ +import { StopOutlined } from '@ant-design/icons' +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 { 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 { RadioCard } from './RadioCard' + +const treasuryOptions = () => [ + { name: t`None`, value: 'zero', icon: }, + { name: t`Limited`, value: 'amount', icon: }, + { name: t`Unlimited`, value: 'unlimited', icon: }, +] + +export function TreasuryOptionsRadio() { + const initialTreasurySelection = useAppSelector( + state => state.editingV2Project.treasurySelection, + ) + + const [treasuryOption, setTreasuryOption] = useState( + initialTreasurySelection ?? 'zero', + ) + + const { + distributionLimit, + setDistributionLimit, + payoutSplits, + setCurrency, + setPayoutSplits, + } = usePayoutsTable() + + const switchingToAmountsModal = useModal() + const switchingToUnlimitedModal = useModal() + const switchingToZeroAmountsModal = useModal() + + const calloutText = useMemo(() => { + switch (treasuryOption) { + case 'amount': + return t`A fixed amount of ETH can be paid out from your project each cycle. You can send specific ETH amounts (or ETH amounts based on USD values) to one or more recipients. Any remaining ETH will stay in your project for token redemptions or use in future cycles.` + case 'unlimited': + return t`All of your project's ETH can be paid out at any time. You can send percentages of that ETH to one or more recipients.` + case 'zero': + return t`None of your project's ETH can be paid out. All ETH will stay in your project for token redemptions or use in future cycles.` + } + }, [treasuryOption]) + + const switchToAmountsPayoutSelection = useCallback( + (newDistributionLimit: ReduxDistributionLimit) => { + setDistributionLimit(parseInt(fromWad(newDistributionLimit.amount))) + setCurrency(newDistributionLimit.currency) + setTreasuryOption('amount') + switchingToAmountsModal.close() + }, + [setDistributionLimit, switchingToAmountsModal, setCurrency], + ) + + const switchToUnlimitedPayouts = useCallback(() => { + setDistributionLimit(undefined) + setTreasuryOption('unlimited') + switchingToUnlimitedModal.close() + }, [switchingToUnlimitedModal, setDistributionLimit]) + + const switchToZeroPayoutSelection = useCallback(() => { + setPayoutSplits([]) + setDistributionLimit(0) + setTreasuryOption('zero') + switchingToZeroAmountsModal.close() + }, [setDistributionLimit, setPayoutSplits, switchingToZeroAmountsModal]) + + const onTreasuryOptionChange = useCallback( + (option: TreasurySelection) => { + const currentOption = treasuryOption + const payoutsCreated = Boolean(payoutSplits.length) + if (option === currentOption) return + if (option === 'amount' && payoutsCreated) { + switchingToAmountsModal.open() + return + } else if (option === 'amount' && !payoutsCreated) { + setDistributionLimit(0) + } + + if (option === 'unlimited' && payoutsCreated) { + switchingToUnlimitedModal.open() + return + } else if (option === 'unlimited' && !payoutsCreated) { + switchToUnlimitedPayouts() + } + + if (option === 'zero' && payoutsCreated) { + switchingToZeroAmountsModal.open() + return + } else if (option === 'zero' && !payoutsCreated) { + switchToZeroPayoutSelection() + } + + setTreasuryOption(option) + }, + [ + treasuryOption, + payoutSplits.length, + switchingToAmountsModal, + switchingToUnlimitedModal, + setDistributionLimit, + switchingToZeroAmountsModal, + switchToZeroPayoutSelection, + switchToUnlimitedPayouts, + ], + ) + + useEffect(() => { + if (distributionLimit === undefined) { + setTreasuryOption('unlimited') + } else if (distributionLimit > 0) { + setTreasuryOption('amount') + } + }, [distributionLimit]) + + return ( + <> + + {treasuryOptions().map(option => ( + + {({ checked }) => ( + + )} + + ))} + + {calloutText && ( + + {calloutText} + + )} + + + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/PayoutsPage/hooks/useAvailablePayoutsSelections.ts b/src/packages/v4/components/Create/components/pages/PayoutsPage/hooks/useAvailablePayoutsSelections.ts new file mode 100644 index 0000000000..993611ee99 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/PayoutsPage/hooks/useAvailablePayoutsSelections.ts @@ -0,0 +1,8 @@ +import { PayoutsSelection } from 'models/payoutsSelection' +import { determineAvailablePayoutsSelections } from 'packages/v2v3/components/Create/utils/determineAvailablePayoutsSelections' +import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' + +export const useAvailablePayoutsSelections = (): Set => { + const [distributionLimit] = useEditingDistributionLimit() + return determineAvailablePayoutsSelections(distributionLimit?.amount) +} diff --git a/src/packages/v4/components/Create/components/pages/PayoutsPage/hooks/usePayoutsForm.ts b/src/packages/v4/components/Create/components/pages/PayoutsPage/hooks/usePayoutsForm.ts new file mode 100644 index 0000000000..8a7b6c80fe --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/PayoutsPage/hooks/usePayoutsForm.ts @@ -0,0 +1,39 @@ +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' + +type PayoutsFormProps = Partial<{ + selection: TreasurySelection + payoutsList: AllocationSplit[] +}> + +export const usePayoutsForm = () => { + const [form] = Form.useForm() + const { treasurySelection } = useAppSelector(state => state.editingV2Project) + const [splits, setSplits] = useEditingPayoutSplits() + useDebugValue(form.getFieldsValue()) + + const initialValues: PayoutsFormProps | undefined = useMemo(() => { + const selection = treasurySelection ?? 'zero' + if (!splits.length) { + return { selection } + } + const payoutsList: AllocationSplit[] = splits.map(splitToAllocation) + return { payoutsList, selection } + }, [splits, treasurySelection]) + + const dispatch = useAppDispatch() + const payoutsList = Form.useWatch('payoutsList', form) + const selection = Form.useWatch('selection', form) + + useEffect(() => { + setSplits(payoutsList?.map(allocationToSplit) ?? []) + }, [dispatch, payoutsList, selection, setSplits]) + + return { initialValues, form } +} diff --git a/src/packages/v4/components/Create/components/pages/ProjectDetails/ProjectDetailsPage.tsx b/src/packages/v4/components/Create/components/pages/ProjectDetails/ProjectDetailsPage.tsx new file mode 100644 index 0000000000..ae8d94bb48 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ProjectDetails/ProjectDetailsPage.tsx @@ -0,0 +1,314 @@ +import { RightOutlined } from '@ant-design/icons' +import { t, Trans } from '@lingui/macro' +import { Col, Form, Row } from 'antd' +import { Callout } from 'components/Callout/Callout' +import { FormItems } from 'components/formItems' +import { EthAddressInput } from 'components/inputs/EthAddressInput' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import { FormImageUploader } from 'components/inputs/FormImageUploader' +import { JuiceTextArea } from 'components/inputs/JuiceTextArea' +import { JuiceInput } from 'components/inputs/JuiceTextInput' +import { RichEditor } from 'components/RichEditor' +import { CREATE_FLOW } from 'constants/fathomEvents' +import { constants } from 'ethers' +import { useWallet } from 'hooks/Wallet' +import { trackFathomGoal } from 'lib/fathom' +import Link from 'next/link' +import { useLockPageRulesWrapper } from 'packages/v2v3/components/Create/hooks/useLockPageRulesWrapper' +import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' +import { + V2V3_CURRENCY_ETH, + V2V3_CURRENCY_USD, +} from 'packages/v2v3/utils/currency' +import { useCallback, useContext, useMemo, useState } from 'react' +import { useSetCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { inputMustBeEthAddressRule, inputMustExistRule } from 'utils/antdRules' +import { inputIsLengthRule } from 'utils/antdRules/inputIsLengthRule' +import { CreateCollapse } from '../../CreateCollapse/CreateCollapse' +import { OptionalHeader } from '../../OptionalHeader' +import { PageContext } from '../../Wizard/contexts/PageContext' +import { Wizard } from '../../Wizard/Wizard' +import { useProjectDetailsForm } from './hooks/useProjectDetailsForm' + +export const ProjectDetailsPage: React.FC< + React.PropsWithChildren +> = () => { + useSetCreateFurthestPageReached('projectDetails') + + const { goToNextPage } = useContext(PageContext) + const formProps = useProjectDetailsForm() + const lockPageRulesWrapper = useLockPageRulesWrapper() + const wallet = useWallet() + + const inputWalletAddress = Form.useWatch('inputProjectOwner', formProps.form) + + const projectOwnerDifferentThanWalletAddress = + inputWalletAddress && wallet.userAddress !== inputWalletAddress + + const startTimestamp = Form.useWatch('startTimestamp', formProps.form) + + // just for juicecrowd + const launchDate = useMemo(() => { + if (!startTimestamp) { + return null + } + const number = Number(startTimestamp) + if (isNaN(number)) { + return null + } + + let date + if (number > 1000000000000) { + date = new Date(number) + } else { + date = new Date(number * 1000) + } + + // format in local timezone + return { + local: date.toLocaleString(), + utc: date.toUTCString(), + } + }, [startTimestamp]) + + return ( +
{ + goToNextPage?.() + trackFathomGoal(CREATE_FLOW.DETAILS_NEXT_CTA) + }} + scrollToFirstError + > +
+ + + + + + + + + + + + + + + + + } + hideDivider + > + {/* Adding paddingBottom is a bit of a hack, but horizontal gutters not working */} + + + + {/* Set placeholder as url string origin without port */} + + + + + + + + + + + + + + + + + + + + + + + } + hideDivider + > + + + + {projectOwnerDifferentThanWalletAddress && ( + + + Warning: Only the project owner can edit a project. If you + don't have access to the address above, you will lose access + to your project. + + + )} + + } + hideDivider + > + + + } + hideDivider + > + + + + + Payment notice} + tooltip={t`Show a disclosure, a message, or a warning to supporters before they pay your project`} + > + + + + +
+ + + +
+ Need help? +
+ + Contact a contributor{' '} + + +
+
+ + ) +} + +// Only relevant to Juicecrowd + +export type AmountInputValue = { + amount: string + currency: V2V3CurrencyOption +} + +const AmountInput = ({ + value, + onChange, +}: { + value?: AmountInputValue + onChange?: (input: AmountInputValue | undefined) => void +}) => { + const [_amount, _setAmount] = useState({ + amount: '', + currency: V2V3_CURRENCY_USD, + }) + const amount = value ?? _amount + const setAmount = onChange ?? _setAmount + + const onAmountInputChange = useCallback( + (value: AmountInputValue | undefined) => { + if (value && !isNaN(parseFloat(value.amount))) { + setAmount(value) + return + } + }, + [setAmount], + ) + + return ( +
+ + onAmountInputChange( + val ? { amount: val, currency: amount.currency } : undefined, + ) + } + accessory={ + {amount.currency === V2V3_CURRENCY_ETH ? 'ETH' : 'USD'} + } + /> +
+ ) +} + +// Exists just to solve an issue where a user might paste a twitter url instead of just the handle +export const TwitterHandleInputWrapper = ({ + value, + onChange, +}: { + value?: string + onChange?: (val: string) => void +}) => { + const [_value, _setValue] = useState(value ?? '') + const setValue = onChange ?? _setValue + value = value ?? _value + + const onInputChange = useCallback( + (value: string | undefined) => { + const httpOrHttpsRegex = /^(http|https):\/\// + if (value?.length && value.match(httpOrHttpsRegex)) { + const handle = value.split('/').pop() + if (handle) { + setValue(handle) + return + } + } + setValue(value ?? '') + }, + [setValue], + ) + + return ( + onInputChange(e.target.value)} + prefix="@" + /> + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ProjectDetails/hooks/useProjectDetailsForm.ts b/src/packages/v4/components/Create/components/pages/ProjectDetails/hooks/useProjectDetailsForm.ts new file mode 100644 index 0000000000..6bdefa459e --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ProjectDetails/hooks/useProjectDetailsForm.ts @@ -0,0 +1,237 @@ +import { Form } from 'antd' +import { useForm } from 'antd/lib/form/Form' +import { ProjectTagName } from 'models/project-tags' +import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' +import { V2V3_CURRENCY_USD } from 'packages/v2v3/utils/currency' +import { useEffect, useMemo } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { + DEFAULT_MUST_START_AT_OR_AFTER, + editingV2ProjectActions, +} from 'redux/slices/editingV2Project' +import { useFormDispatchWatch } from '../../hooks/useFormDispatchWatch' +import { AmountInputValue } from '../ProjectDetailsPage' + +type ProjectDetailsFormProps = Partial<{ + projectName: string + projectTagline: string + projectDescription: string + logo: string + coverImage: string + projectWebsite: string + projectTwitter: string + projectTelegram: string + projectDiscord: string + projectRequiredOFACCheck?: boolean + payButtonText: string + payDisclosure: string + inputProjectOwner: string + tags: ProjectTagName[] + // Only relevant to Juicecrowd + introVideoUrl: string + // Only relevant to Juicecrowd + introImageUri: string + // Only relevant to Juicecrowd + softTarget: AmountInputValue + startTimestamp: string +}> + +export const useProjectDetailsForm = () => { + const [form] = useForm() + const { projectMetadata, inputProjectOwner, mustStartAtOrAfter } = + useAppSelector(state => state.editingV2Project) + + const initialValues: ProjectDetailsFormProps = useMemo( + () => ({ + projectName: projectMetadata.name, + projectTagline: projectMetadata.projectTagline, + projectDescription: projectMetadata.description, + logo: projectMetadata.logoUri, + coverImage: projectMetadata.coverImageUri, + projectWebsite: projectMetadata.infoUri, + projectTwitter: projectMetadata.twitter, + projectTelegram: projectMetadata.telegram, + projectDiscord: projectMetadata.discord, + projectRequiredOFACCheck: projectMetadata.projectRequiredOFACCheck, + payButtonText: projectMetadata.payButton, + payDisclosure: projectMetadata.payDisclosure, + inputProjectOwner, + tags: projectMetadata.tags, + // Only relevant to Juicecrowd + introVideoUrl: projectMetadata.introVideoUrl, + introImageUri: projectMetadata.introImageUri, + startTimestamp: + mustStartAtOrAfter !== DEFAULT_MUST_START_AT_OR_AFTER && + !isNaN(parseInt(mustStartAtOrAfter)) + ? mustStartAtOrAfter + : '', + softTarget: + projectMetadata.softTargetAmount && projectMetadata.softTargetCurrency + ? { + amount: projectMetadata.softTargetAmount, + currency: parseInt( + projectMetadata.softTargetCurrency, + ) as V2V3CurrencyOption, + } + : undefined, + }), + [ + projectMetadata.name, + projectMetadata.projectTagline, + projectMetadata.description, + projectMetadata.logoUri, + projectMetadata.coverImageUri, + projectMetadata.infoUri, + projectMetadata.twitter, + projectMetadata.telegram, + projectMetadata.discord, + projectMetadata.payButton, + projectMetadata.payDisclosure, + projectMetadata.tags, + projectMetadata.introVideoUrl, + projectMetadata.introImageUri, + projectMetadata.softTargetAmount, + projectMetadata.softTargetCurrency, + projectMetadata.projectRequiredOFACCheck, + inputProjectOwner, + mustStartAtOrAfter, + ], + ) + + useFormDispatchWatch({ + form, + fieldName: 'projectName', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setName, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'projectTagline', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setProjectTagline, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'projectDescription', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setDescription, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'tags', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setTags, + formatter: v => v ?? [], + }) + useFormDispatchWatch({ + form, + fieldName: 'logo', + dispatchFunction: editingV2ProjectActions.setLogoUri, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'coverImage', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setCoverImageUri, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'projectWebsite', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setInfoUri, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'projectTwitter', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setTwitter, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'projectDiscord', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setDiscord, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'projectTelegram', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setTelegram, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'inputProjectOwner', + ignoreUndefined: false, + dispatchFunction: editingV2ProjectActions.setInputProjectOwner, + formatter: v => v, + }) + useFormDispatchWatch({ + form, + fieldName: 'payButtonText', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setPayButton, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'payDisclosure', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setPayDisclosure, + formatter: v => v ?? '', + }) + + useFormDispatchWatch({ + form, + fieldName: 'introVideoUrl', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setIntroVideoUrl, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'introImageUri', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setIntroImageUri, + formatter: v => v ?? '', + }) + useFormDispatchWatch({ + form, + fieldName: 'softTarget', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setSoftTarget, + formatter: v => v ?? { amount: '', currency: V2V3_CURRENCY_USD }, + }) + + const startTimestamp = Form.useWatch('startTimestamp', form) + const dispatch = useAppDispatch() + + useEffect(() => { + if (!startTimestamp) return + const launchDate = parseInt(startTimestamp) + if (isNaN(launchDate)) return + // check if launch date is in ms or seconds + if (launchDate > 1000000000000) { + dispatch( + editingV2ProjectActions.setMustStartAtOrAfter( + (launchDate / 1000).toString(), + ), + ) + } else { + dispatch( + editingV2ProjectActions.setMustStartAtOrAfter(launchDate.toString()), + ) + } + }, [dispatch, startTimestamp]) + + return { form, initialValues } +} diff --git a/src/packages/v4/components/Create/components/pages/ProjectToken/ProjectTokenPage.tsx b/src/packages/v4/components/Create/components/pages/ProjectToken/ProjectTokenPage.tsx new file mode 100644 index 0000000000..f28ba94cac --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/ProjectTokenPage.tsx @@ -0,0 +1,105 @@ +import { SettingOutlined } from '@ant-design/icons' +import { t, Trans } from '@lingui/macro' +import { Form } from 'antd' +import { useWatch } from 'antd/lib/form/Form' +import { Callout } from 'components/Callout/Callout' +import { CREATE_FLOW } from 'constants/fathomEvents' +import { trackFathomGoal } from 'lib/fathom' +import { useContext, useEffect } from 'react' +import { useSetCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { CreateBadge } from '../../CreateBadge' +import { Icons } from '../../Icons' +import { Selection } from '../../Selection/Selection' +import { PageContext } from '../../Wizard/contexts/PageContext' +import { Wizard } from '../../Wizard/Wizard' +import { CustomTokenSettings } from './components/CustomTokenSettings/CustomTokenSettings' +import { DefaultSettings } from './components/DefaultSettings' +import { useProjectTokensForm } from './hooks/useProjectTokenForm' + +export const ProjectTokenPage: React.FC< + React.PropsWithChildren +> = () => { + useSetCreateFurthestPageReached('projectToken') + const { goToNextPage, lockPageProgress, unlockPageProgress } = + useContext(PageContext) + const { form, initialValues } = useProjectTokensForm() + + const selection = useWatch('selection', form) + const isNextEnabled = !!selection + + // A bit of a workaround to soft lock the page when the user edits data. + useEffect(() => { + if (!selection) { + lockPageProgress?.() + return + } + if (selection === 'custom') { + try { + form.validateFields().catch(e => { + lockPageProgress?.() + throw e + }) + } catch (e) { + return + } + } + unlockPageProgress?.() + }, [form, lockPageProgress, selection, unlockPageProgress]) + + return ( +
{ + goToNextPage?.() + trackFathomGoal(CREATE_FLOW.TOKEN_NEXT_CTA) + }} + scrollToFirstError + > +
+ + + + Basic Token Rules +
+ } + icon={} + description={ + + Simple token rules that will work for most projects. You can + edit these rules in future cycles. + + } + > + + + } + description={ + Set up custom rules for your project's tokens. + } + > + + + + + + + Your project's tokens are not ERC-20 tokens by default. After you + create your project, you can create an ERC-20 for your token holders + to claim. This is optional. + + + + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx b/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx new file mode 100644 index 0000000000..1a245d56ef --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx @@ -0,0 +1,254 @@ +import { t, Trans } from '@lingui/macro' +import { Divider, Form } from 'antd' +import { Callout } from 'components/Callout/Callout' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import { JuiceSwitch } from 'components/inputs/JuiceSwitch' +import NumberSlider from 'components/inputs/NumberSlider' +import { + MINT_RATE_EXPLANATION, + OWNER_MINTING_EXPLANATION, + OWNER_MINTING_RISK, + PAUSE_TRANSFERS_EXPLANATION, + REDEMPTION_RATE_EXPLANATION, +} from 'components/strings' +import { TokenRedemptionRateGraph } from 'components/TokenRedemptionRateGraph/TokenRedemptionRateGraph' +import useMobile from 'hooks/useMobile' +import { formatFundingCycleDuration } from 'packages/v2v3/components/Create/utils/formatFundingCycleDuration' +import { ReservedTokensList } from 'packages/v2v3/components/shared/ReservedTokensList' +import { MAX_DISTRIBUTION_LIMIT, MAX_MINT_RATE } from 'packages/v2v3/utils/math' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' +import { inputMustExistRule } from 'utils/antdRules' +import { formatAmount } from 'utils/format/formatAmount' +import * as ProjectTokenForm from '../../hooks/useProjectTokenForm' +import { ProjectTokensFormProps } from '../../hooks/useProjectTokenForm' +import { ReservedTokenRateCallout } from './ReservedTokenRateCallout' + +const calculateMintRateAfterDiscount = ({ + mintRate, + discountRate, +}: { + mintRate: number + discountRate: number +}) => { + return mintRate * (1 - discountRate / 100) +} + +export const CustomTokenSettings = () => { + const isMobile = useMobile() + const duration = useAppSelector( + state => state.editingV2Project.fundingCycleData.duration, + ) + const [distributionLimit] = useEditingDistributionLimit() + const form = Form.useFormInstance() + const discountRate = + Form.useWatch('discountRate', form) ?? + ProjectTokenForm.DefaultSettings.discountRate + const initialMintRate = parseInt( + Form.useWatch('initialMintRate', form) ?? + ProjectTokenForm.DefaultSettings.initialMintRate, + ) + const tokenMinting = Form.useWatch('tokenMinting', form) ?? false + + const discountRateDisabled = !parseInt(duration) + + const redemptionRateDisabled = distributionLimit?.amount.eq( + MAX_DISTRIBUTION_LIMIT, + ) + + const initalMintRateAccessory = ( + + Tokens per ETH contributed + + ) + + const secondFundingCycleMintRate = calculateMintRateAfterDiscount({ + mintRate: initialMintRate, + discountRate, + }) + const thirdFundingCycleMintRate = calculateMintRateAfterDiscount({ + mintRate: secondFundingCycleMintRate, + discountRate, + }) + + return ( + <> + + + + + + + +
+ + Set aside a percentage of token issuance for the wallets and + Juicebox projects of your choosing. + + + + + +
+
+ +
+ + Send a percentage of reserved tokens to the wallets and Juicebox + projects of your choosing. By default, reserved tokens are sent to + the project owner. + + + + +
+
+ + + + +
+ + The issuance rate is reduced by this percentage every cycle (every{' '} + {formatFundingCycleDuration(duration)}). The higher this rate, the + more incentive to pay this project earlier. + + + + + {discountRateDisabled ? ( + + + The issuance reduction rate is disabled if you are using + unlocked cycles (because they have no duration). + + + ) : ( + + {discountRate === 0 ? ( + + The issuance rate will not change unless you edit it. There + will be less of an incentive to support this project early on. + + ) : discountRate === 100 ? ( + + After {formatFundingCycleDuration(duration)} (your first + cycle), your project will not issue any tokens unless you edit + the issuance rate. + + ) : ( + <> +

+ + Each cycle, the project will issue {discountRate}% fewer + tokens per ETH.{' '} + +

+

+ + Next cycle, the project will issue{' '} + {formatAmount(secondFundingCycleMintRate)} tokens per 1 + ETH. The cycle after that, the project will issue{' '} + {formatAmount(thirdFundingCycleMintRate)} tokens per 1 + ETH. + +

+ + )} +
+ )} +
+
+ + + + +
+ {REDEMPTION_RATE_EXPLANATION} + + + + {redemptionRateDisabled ? ( + + + Redemptions are disabled when all of the project's ETH is being + used for payouts (when payouts are unlimited). + + + ) : ( + !isMobile && ( + + + + ) + )} +
+
+ + + +
+
+ + + + {tokenMinting && ( + + {OWNER_MINTING_RISK} + + )} +
+ + + + +
+ + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/ReservedTokenRateCallout.tsx b/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/ReservedTokenRateCallout.tsx new file mode 100644 index 0000000000..f3a54bd0d6 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/ReservedTokenRateCallout.tsx @@ -0,0 +1,44 @@ +import { Trans } from '@lingui/macro' +import { Form } from 'antd' +import { Callout } from 'components/Callout/Callout' +import { useMemo } from 'react' +import { formattedNum } from 'utils/format/formatNumber' +import { ProjectTokensFormProps } from '../../hooks/useProjectTokenForm' + +export const ReservedTokenRateCallout: React.FC< + React.PropsWithChildren +> = () => { + const form = Form.useFormInstance() + const initialMintRate = Form.useWatch('initialMintRate', form) + const reservedTokensPercentage = Form.useWatch( + 'reservedTokensPercentage', + form, + ) + + const reservedTokens = useMemo(() => { + if (!initialMintRate) return 0 + const imr = parseFloat(initialMintRate) + return (imr * (reservedTokensPercentage ?? 0)) / 100 + }, [initialMintRate, reservedTokensPercentage]) + + const contributorTokens = useMemo(() => { + if (!initialMintRate) return 0 + return parseFloat(initialMintRate) - reservedTokens + }, [initialMintRate, reservedTokens]) + + return ( + + When someone pays your project 1 ETH: +
    +
  • + + {formattedNum(contributorTokens)} tokens will be sent to the payer. + +
  • +
  • + {formattedNum(reservedTokens)} tokens will be reserved. +
  • +
+
+ ) +} diff --git a/src/packages/v4/components/Create/components/pages/ProjectToken/components/DefaultSettings.tsx b/src/packages/v4/components/Create/components/pages/ProjectToken/components/DefaultSettings.tsx new file mode 100644 index 0000000000..ac3008a5e6 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/components/DefaultSettings.tsx @@ -0,0 +1,60 @@ +import { t } from '@lingui/macro' +import { Divider } from 'antd' +import TooltipLabel from 'components/TooltipLabel' +import { + DISCOUNT_RATE_EXPLANATION, + MINT_RATE_EXPLANATION, + OWNER_MINTING_EXPLANATION, + REDEMPTION_RATE_EXPLANATION, + RESERVED_RATE_EXPLANATION, +} from 'components/strings' +import { ReactNode, useMemo } from 'react' +import { formatAmount } from 'utils/format/formatAmount' +import { formatBoolean } from 'utils/format/formatBoolean' +import * as ProjectTokenForm from '../hooks/useProjectTokenForm' + +export const DefaultSettings: React.FC< + React.PropsWithChildren +> = () => { + const data: Record = useMemo( + () => ({ + [t`Total issuance rate`]: { + data: `${formatAmount( + ProjectTokenForm.DefaultSettings.initialMintRate, + )} tokens / ETH`, + tooltip: MINT_RATE_EXPLANATION, + }, + [t`Reserved rate`]: { + data: `${ProjectTokenForm.DefaultSettings.reservedTokensPercentage}%`, + tooltip: RESERVED_RATE_EXPLANATION, + }, + [t`Issuance reduction rate`]: { + data: `${ProjectTokenForm.DefaultSettings.discountRate}%`, + tooltip: DISCOUNT_RATE_EXPLANATION, + }, + [t`Redemption rate`]: { + data: `${ProjectTokenForm.DefaultSettings.redemptionRate}%`, + tooltip: REDEMPTION_RATE_EXPLANATION, + }, + [t`Owner token minting`]: { + data: formatBoolean(ProjectTokenForm.DefaultSettings.tokenMinting), + tooltip: OWNER_MINTING_EXPLANATION, + }, + }), + [], + ) + return ( + <> + {Object.entries(data).map(([key, { data: text, tooltip }], i) => ( +
+ {i === 0 && } +
+ + {text} +
+ {i < Object.entries(data).length - 1 && } +
+ ))} + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts b/src/packages/v4/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts new file mode 100644 index 0000000000..9e743f07aa --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts @@ -0,0 +1,225 @@ +import { Form } from 'antd' +import { useWatch } from 'antd/lib/form/Form' +import { ONE_MILLION } from 'constants/numbers' +import { ProjectTokensSelection } from 'models/projectTokenSelection' +import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' +import { + MAX_DISTRIBUTION_LIMIT, + discountRateFrom, + formatDiscountRate, + formatIssuanceRate, + formatRedemptionRate, + formatReservedRate, + issuanceRateFrom, + 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 { useFormDispatchWatch } from '../../hooks/useFormDispatchWatch' + +export type ProjectTokensFormProps = Partial<{ + selection: ProjectTokensSelection + initialMintRate: string | undefined + reservedTokensPercentage: number | undefined + reservedTokenAllocation: AllocationSplit[] | undefined + discountRate: number | undefined + redemptionRate: number | undefined + tokenMinting: boolean | undefined + pauseTransfers: boolean | undefined +}> + +export const DefaultSettings: Required< + Omit +> = { + initialMintRate: ONE_MILLION.toString(), + reservedTokensPercentage: 0, + reservedTokenAllocation: [], + discountRate: 0, + redemptionRate: 100, + tokenMinting: false, + pauseTransfers: false, +} + +/** + * There is a lot of witchcraft going on here. Maintainers beware. + */ +export const useProjectTokensForm = () => { + const [form] = Form.useForm() + const { fundingCycleMetadata, fundingCycleData, projectTokensSelection } = + useAppSelector(state => state.editingV2Project) + const [tokenSplits] = useEditingReservedTokensSplits() + useDebugValue(form.getFieldsValue()) + const [distributionLimit] = useEditingDistributionLimit() + + const redemptionRateDisabled = + !distributionLimit || distributionLimit.amount.eq(MAX_DISTRIBUTION_LIMIT) + const discountRateDisabled = !parseInt(fundingCycleData.duration) + + const initialValues: ProjectTokensFormProps | undefined = useMemo(() => { + const selection = projectTokensSelection + const initialMintRate = fundingCycleData?.weight + ? formatIssuanceRate(fundingCycleData.weight) + : DefaultSettings.initialMintRate + const reservedTokensPercentage = fundingCycleMetadata.reservedRate + ? parseFloat(formatReservedRate(fundingCycleMetadata.reservedRate)) + : DefaultSettings.reservedTokensPercentage + const reservedTokenAllocation: AllocationSplit[] = + tokenSplits.map(splitToAllocation) + const discountRate = + !discountRateDisabled && fundingCycleData.discountRate + ? parseFloat(formatDiscountRate(fundingCycleData.discountRate)) + : DefaultSettings.discountRate + const redemptionRate = + !redemptionRateDisabled && fundingCycleMetadata.redemptionRate + ? parseFloat(formatRedemptionRate(fundingCycleMetadata.redemptionRate)) + : DefaultSettings.redemptionRate + const tokenMinting = + fundingCycleMetadata.allowMinting !== undefined + ? fundingCycleMetadata.allowMinting + : DefaultSettings.tokenMinting + const pauseTransfers = + fundingCycleMetadata.global.pauseTransfers !== undefined + ? fundingCycleMetadata.global.pauseTransfers + : DefaultSettings.pauseTransfers + + return { + selection, + initialMintRate, + reservedTokensPercentage, + reservedTokenAllocation, + discountRate, + redemptionRate, + tokenMinting, + pauseTransfers, + } + }, [ + discountRateDisabled, + fundingCycleData.discountRate, + fundingCycleData.weight, + fundingCycleMetadata.allowMinting, + fundingCycleMetadata.redemptionRate, + fundingCycleMetadata.reservedRate, + fundingCycleMetadata.global.pauseTransfers, + projectTokensSelection, + redemptionRateDisabled, + tokenSplits, + ]) + + const dispatch = useAppDispatch() + const selection = useWatch('selection', form) + + useEffect(() => { + // We only want to update changes when selection is set + if (selection === undefined) return + dispatch(editingV2ProjectActions.setProjectTokensSelection(selection)) + + if (selection === 'default') { + form.setFieldsValue({ ...DefaultSettings }) + dispatch(editingV2ProjectActions.setTokenSettings(DefaultSettings)) + return + } + dispatch( + editingV2ProjectActions.setTokenSettings({ + ...DefaultSettings, + ...form.getFieldsValue(), + }), + ) + }, [dispatch, form, selection]) + + useFormDispatchWatch({ + form, + fieldName: 'initialMintRate', + dispatchFunction: editingV2ProjectActions.setWeight, + formatter: v => { + if (v === undefined || typeof v !== 'string') + return issuanceRateFrom(DefaultSettings.initialMintRate) + return issuanceRateFrom(v) + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'reservedTokensPercentage', + dispatchFunction: editingV2ProjectActions.setReservedRate, + formatter: v => { + if (v === undefined || typeof v !== 'number') + return reservedRateFrom( + DefaultSettings.reservedTokensPercentage, + ).toHexString() + return reservedRateFrom(v).toHexString() + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'reservedTokenAllocation', + ignoreUndefined: true, // Needed to stop an infinite loop + currentValue: tokenSplits, // Needed to stop an infinite loop + dispatchFunction: editingV2ProjectActions.setReservedTokensSplits, + formatter: v => { + if (v === undefined || typeof v !== 'object') return [] + return v.map(allocationToSplit) + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'discountRate', + dispatchFunction: editingV2ProjectActions.setDiscountRate, + formatter: v => { + if (v === undefined || typeof v !== 'number') + return discountRateFrom(DefaultSettings.discountRate).toHexString() + return discountRateFrom(v).toHexString() + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'redemptionRate', + dispatchFunction: editingV2ProjectActions.setRedemptionRate, + formatter: v => { + if (v === undefined || typeof v !== 'number') + return redemptionRateFrom(DefaultSettings.redemptionRate).toHexString() + return redemptionRateFrom(v).toHexString() + }, + }) + useFormDispatchWatch({ + form, + fieldName: 'redemptionRate', + dispatchFunction: editingV2ProjectActions.setBallotRedemptionRate, + formatter: v => { + if (v === undefined || typeof v !== 'number') + return redemptionRateFrom(DefaultSettings.redemptionRate).toHexString() + return redemptionRateFrom(v).toHexString() + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'tokenMinting', + dispatchFunction: editingV2ProjectActions.setAllowMinting, + formatter: v => { + if (typeof v !== 'boolean') return false + return v + }, + }) + + useFormDispatchWatch({ + form, + fieldName: 'pauseTransfers', + dispatchFunction: editingV2ProjectActions.setPauseTransfers, + ignoreUndefined: true, + formatter: v => { + if (typeof v !== 'boolean') return false + return v + }, + }) + + return { form, initialValues } +} diff --git a/src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx new file mode 100644 index 0000000000..bf24fbeb3a --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx @@ -0,0 +1,142 @@ +import { t, Trans } from '@lingui/macro' +import { Form } from 'antd' +import { useWatch } from 'antd/lib/form/Form' +import { Callout } from 'components/Callout/Callout' +import { JuiceSwitch } from 'components/inputs/JuiceSwitch' +import { + CONTROLLER_CONFIG_EXPLANATION, + CONTROLLER_MIGRATION_EXPLANATION, + HOLD_FEES_EXPLANATION, + PAUSE_PAYMENTS_EXPLANATION, + RECONFIG_RULES_WARN, + TERMINAL_CONFIG_EXPLANATION, + TERMINAL_MIGRATION_EXPLANATION, +} from 'components/strings' +import { CREATE_FLOW } from 'constants/fathomEvents' +import { FEATURE_FLAGS } from 'constants/featureFlags' +import { readNetwork } from 'constants/networks' +import { trackFathomGoal } from 'lib/fathom' +import { Selection } from 'packages/v2v3/components/Create/components/Selection/Selection' +import { useAvailableReconfigurationStrategies } from 'packages/v2v3/components/Create/hooks/useAvailableReconfigurationStrategies' +import { useContext } from 'react' +import { useSetCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { featureFlagEnabled } from 'utils/featureFlags' +import { CreateCollapse } from '../../CreateCollapse/CreateCollapse' +import { PageContext } from '../../Wizard/contexts/PageContext' +import { Wizard } from '../../Wizard/Wizard' +import { CustomRuleCard } from './components/CustomRuleCard' +import { RuleCard } from './components/RuleCard' +import { useReconfigurationRulesForm } from './hooks/useReconfigurationRulesForm' + +export const ReconfigurationRulesPage = () => { + useSetCreateFurthestPageReached('reconfigurationRules') + const { form, initialValues } = useReconfigurationRulesForm() + + const { goToNextPage } = useContext(PageContext) + + const selection = useWatch('selection', form) + const isNextEnabled = !!selection + + const reconfigurationStrategies = useAvailableReconfigurationStrategies( + readNetwork.name, + ) + + return ( +
{ + goToNextPage?.() + trackFathomGoal(CREATE_FLOW.RULES_NEXT_CTA) + }} + scrollToFirstError + > +
+
+ + + {reconfigurationStrategies.map(strategy => ( + + ))} + + + +
+ + {selection === 'none' && ( + {RECONFIG_RULES_WARN} + )} + + + + + + + + + + + + {featureFlagEnabled(FEATURE_FLAGS.OFAC) ? ( + + + + ) : null} + + +

+ Configuration rules +

+
+ + + + + + +
+

+ Migration rules +

+
+ + + + + + +
+
+
+
+ + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReconfigurationRules/components/CustomRuleCard.tsx b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/components/CustomRuleCard.tsx new file mode 100644 index 0000000000..65204798ab --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/components/CustomRuleCard.tsx @@ -0,0 +1,32 @@ +import { t } from '@lingui/macro' +import { Form } from 'antd' +import { CustomStrategyInput } from 'components/inputs/ReconfigurationStrategy/CustomStrategyInput' +import { Selection } from 'packages/v2v3/components/Create/components/Selection/Selection' +import { inputMustBeEthAddressRule, inputMustExistRule } from 'utils/antdRules' + +export const CustomRuleCard = () => { + return ( + + e.stopPropagation()} /> +
+ } + /> + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReconfigurationRules/components/RuleCard.tsx b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/components/RuleCard.tsx new file mode 100644 index 0000000000..897f28df90 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/components/RuleCard.tsx @@ -0,0 +1,41 @@ +import EthereumAddress from 'components/EthereumAddress' +import { CreateBadge } from 'packages/v2v3/components/Create/components/CreateBadge' +import { Selection } from 'packages/v2v3/components/Create/components/Selection/Selection' +import { AvailableReconfigurationStrategy } from 'packages/v2v3/components/Create/hooks/useAvailableReconfigurationStrategies' + +export const RuleCard = ({ + strategy, +}: { + strategy: AvailableReconfigurationStrategy +}) => { + return ( + + {strategy.name} + {strategy.isDefault && ( + <> + {' '} + + + )} + + } + description={ + <> + {strategy.description} +
+ Contract address:{' '} + e.stopPropagation()} + /> +
+ + } + /> + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReconfigurationRules/hooks/useReconfigurationRulesForm.ts b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/hooks/useReconfigurationRulesForm.ts new file mode 100644 index 0000000000..23434ae661 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/hooks/useReconfigurationRulesForm.ts @@ -0,0 +1,191 @@ +import { Form } from 'antd' +import { useForm } from 'antd/lib/form/Form' +import { readNetwork } from 'constants/networks' +import { ReconfigurationStrategy } from 'models/reconfigurationStrategy' +import { useAvailableReconfigurationStrategies } from 'packages/v2v3/components/Create/hooks/useAvailableReconfigurationStrategies' +import { useEffect, useMemo } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { editingV2ProjectActions } from 'redux/slices/editingV2Project' +import { isEqualAddress, isZeroAddress } from 'utils/address' +import { useFormDispatchWatch } from '../../hooks/useFormDispatchWatch' + +type ReconfigurationRulesFormProps = Partial<{ + selection: ReconfigurationStrategy + customAddress?: string + pausePayments: boolean + holdFees: boolean + pauseTransfers: boolean + allowTerminalConfiguration: boolean + allowControllerConfiguration: boolean + allowTerminalMigration: boolean + allowControllerMigration: boolean + projectRequiredOFACCheck: boolean +}> + +export const useReconfigurationRulesForm = () => { + const [form] = useForm() + const strategies = useAvailableReconfigurationStrategies( + readNetwork.name, + ).map(({ address, id, isDefault }) => ({ address, name: id, isDefault })) + const defaultStrategy = useMemo( + () => strategies.find(s => s.isDefault), + [strategies], + ) + + if (defaultStrategy === undefined) { + console.error( + 'Unexpected error - default strategy for reconfiguration is undefined', + { defaultStrategy, strategies }, + ) + throw new Error( + 'Unexpected error - default strategy for reconfiguration is undefined', + ) + } + + const { + fundingCycleData: { ballot }, + reconfigurationRuleSelection, + fundingCycleMetadata, + } = useAppSelector(state => state.editingV2Project) + const initialValues: ReconfigurationRulesFormProps | undefined = + useMemo(() => { + const pausePayments = fundingCycleMetadata.pausePay + const allowTerminalConfiguration = + fundingCycleMetadata.global.allowSetTerminals + const allowControllerConfiguration = + fundingCycleMetadata.global.allowSetController + const allowTerminalMigration = fundingCycleMetadata.allowTerminalMigration + const allowControllerMigration = + fundingCycleMetadata.allowControllerMigration + const pauseTransfers = fundingCycleMetadata.global.pauseTransfers + const holdFees = fundingCycleMetadata.holdFees + // By default, ballot is addressZero + if (!reconfigurationRuleSelection && isZeroAddress(ballot)) + return { + selection: defaultStrategy.name, + pausePayments, + pauseTransfers, + allowTerminalConfiguration, + allowControllerConfiguration, + allowTerminalMigration, + allowControllerMigration, + } + + const found = strategies.find(({ address }) => + isEqualAddress(address, ballot), + ) + if (!found) { + return { + selection: 'custom', + customAddress: ballot, + pausePayments, + allowTerminalConfiguration, + allowControllerConfiguration, + pauseTransfers, + holdFees, + } + } + + return { + selection: found.name, + pausePayments, + pauseTransfers, + holdFees, + allowTerminalConfiguration, + allowControllerConfiguration, + allowTerminalMigration, + allowControllerMigration, + } + }, [ + fundingCycleMetadata.pausePay, + fundingCycleMetadata.global, + fundingCycleMetadata.holdFees, + fundingCycleMetadata.allowTerminalMigration, + fundingCycleMetadata.allowControllerMigration, + reconfigurationRuleSelection, + ballot, + defaultStrategy.name, + strategies, + ]) + + const selection = Form.useWatch('selection', form) + const customAddress = Form.useWatch('customAddress', form) + const dispatch = useAppDispatch() + + useEffect(() => { + let address: string | undefined + switch (selection) { + case 'threeDay': + case 'oneDay': + address = strategies.find(s => s.name === selection)?.address + break + case 'none': + case 'sevenDay': + address = strategies.find(s => s.name === selection)?.address + break + case 'custom': + address = customAddress + break + } + dispatch(editingV2ProjectActions.setBallot(address ?? '')) + dispatch(editingV2ProjectActions.setReconfigurationRuleSelection(selection)) + }, [customAddress, dispatch, selection, strategies]) + + useFormDispatchWatch({ + form, + fieldName: 'pausePayments', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setPausePay, + formatter: v => !!v, + }) + + useFormDispatchWatch({ + form, + fieldName: 'holdFees', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setHoldFees, + formatter: v => !!v, + }) + + useFormDispatchWatch({ + form, + fieldName: 'allowTerminalConfiguration', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setAllowSetTerminals, + formatter: v => !!v, + }) + + useFormDispatchWatch({ + form, + fieldName: 'allowControllerConfiguration', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setAllowSetController, + formatter: v => !!v, + }) + + useFormDispatchWatch({ + form, + fieldName: 'allowTerminalMigration', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setAllowTerminalMigration, + formatter: v => !!v, + }) + + useFormDispatchWatch({ + form, + fieldName: 'allowControllerMigration', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setAllowControllerMigration, + formatter: v => !!v, + }) + useFormDispatchWatch({ + form, + fieldName: 'projectRequiredOFACCheck', + ignoreUndefined: true, + dispatchFunction: editingV2ProjectActions.setRequiredOFACCheck, + formatter: v => v, + }) + + return { form, initialValues } +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx new file mode 100644 index 0000000000..12b411967e --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx @@ -0,0 +1,257 @@ +import { CheckCircleFilled } from '@ant-design/icons' +import { Trans } from '@lingui/macro' +import { Checkbox, Form } from 'antd' +import { Callout } from 'components/Callout/Callout' +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 { useDeployProject } from 'packages/v2v3/components/Create/hooks/DeployProject/useDeployProject' +import { useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { useDispatch } from 'react-redux' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { useSetCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' +import { editingV2ProjectActions } from 'redux/slices/editingV2Project' +import { helpPagePath } from 'utils/helpPagePath' +import { CreateBadge } from '../../CreateBadge' +import { CreateCollapse } from '../../CreateCollapse/CreateCollapse' +import { Wizard } from '../../Wizard/Wizard' +import { WizardContext } from '../../Wizard/contexts/WizardContext' +import { FundingConfigurationReview } from './components/FundingConfigurationReview/FundingConfigurationReview' +import { ProjectDetailsReview } from './components/ProjectDetailsReview/ProjectDetailsReview' +import { ProjectTokenReview } from './components/ProjectTokenReview/ProjectTokenReview' +import { RewardsReview } from './components/RewardsReview/RewardsReview' +import { RulesReview } from './components/RulesReview/RulesReview' + +enum ReviewDeployKey { + ProjectDetails = 0, + FundingConfiguration = 1, + ProjectToken = 2, + Rewards = 3, + Rules = 4, +} + +const Header: React.FC> = ({ + children, + skipped = false, +}) => { + return ( +

+ {children} + {skipped ? ( +
+ +
+ ) : ( + + )} +

+ ) +} + +export const ReviewDeployPage = () => { + useSetCreateFurthestPageReached('reviewDeploy') + const { goToPage } = useContext(WizardContext) + const isMobile = useMobile() + const { chainUnsupported, changeNetworks, isConnected, connect } = useWallet() + const router = useRouter() + const [form] = Form.useForm<{ termsAccepted: boolean }>() + const termsAccepted = Form.useWatch('termsAccepted', form) + const transactionModal = useModal() + const { deployProject, isDeploying, deployTransactionPending } = + useDeployProject() + const nftRewards = useAppSelector( + state => state.editingV2Project.nftRewards.rewardTiers, + ) + + const nftRewardsAreSet = useMemo( + () => nftRewards && nftRewards?.length > 0, + [nftRewards], + ) + + const dispatch = useDispatch() + + const handleStartOverClicked = useCallback(() => { + router.push('/create') + goToPage?.('projectDetails') + dispatch(editingV2ProjectActions.resetState()) + }, [dispatch, goToPage, router]) + + const onFinish = useCallback(async () => { + if (chainUnsupported) { + await changeNetworks() + return + } + if (!isConnected) { + await connect() + return + } + + transactionModal.open() + await deployProject({ + onProjectDeployed: deployedProjectId => { + router.push({ query: { deployedProjectId } }, '/create', { + shallow: true, + }) + transactionModal.close() + }, + }) + }, [ + chainUnsupported, + changeNetworks, + connect, + deployProject, + isConnected, + router, + transactionModal, + ]) + + const [activeKey, setActiveKey] = useState( + !isMobile ? [ReviewDeployKey.ProjectDetails] : [], + ) + + const handleOnChange = (key: string | string[]) => { + if (typeof key === 'string') { + setActiveKey([parseInt(key)]) + } else { + setActiveKey(key.map(k => parseInt(k))) + } + } + + // Remove the nft rewards panel if there are no nft rewards + useEffect(() => { + if (!nftRewardsAreSet) { + setActiveKey(activeKey.filter(k => k !== ReviewDeployKey.Rewards)) + } + // Only run this effect when the nft rewards are set + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nftRewardsAreSet]) + + const isNextEnabled = termsAccepted + return ( + <> + + + Project Details + + } + > + + + + Cycles & Payouts + + } + > + + + + Token + + } + > + + + + NFTs + + } + > + + + + Other Rules + + } + > + + + +
+ +
+ + + + +
+
+ + + + + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/DeploySuccess.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/DeploySuccess.tsx new file mode 100644 index 0000000000..1a26890e68 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/DeploySuccess.tsx @@ -0,0 +1,110 @@ +import { ShareAltOutlined, TwitterOutlined } from '@ant-design/icons' +import { ArrowRightIcon } from '@heroicons/react/24/solid' +import { Trans, t } from '@lingui/macro' +import { Button } from 'antd' +import ExternalLink from 'components/ExternalLink' +import { XLButton } from 'components/buttons/XLButton' +import { readNetwork } from 'constants/networks' +import { useWallet } from 'hooks/Wallet' +import { NetworkName } from 'models/networkName' +import Image from "next/legacy/image" +import { useRouter } from 'next/router' +import { useCallback, useMemo, useState } from 'react' +import DeploySuccessHero from '/public/assets/images/create-success-hero.webp' + +const NEW_DEPLOY_QUERY_PARAM = 'np' + +export const DeploySuccess = ({ projectId }: { projectId: number }) => { + console.info('Deploy: SUCCESS', projectId) + const router = useRouter() + const { chain } = useWallet() + let deployGreeting = t`Your project was successfully created!` + if (chain?.name) { + deployGreeting = t`Your project was successfully created on ${chain.name}!` + } + + const [gotoProjectClicked, setGotoProjectClicked] = useState(false) + + /** + * Generate a twitter share link based on the project id. + */ + const twitterShareUrl = useMemo(() => { + const juiceboxUrl = + readNetwork.name === NetworkName.mainnet + ? `https://juicebox.money/v2/p/${projectId}` + : `https://${readNetwork.name}.juicebox.money/v2/p/${projectId}` + + const message = `Check out my project on ${ + chain?.name ? `${chain.name} ` : '' + }Juicebox!\n${juiceboxUrl}` + return `https://twitter.com/intent/tweet?text=${encodeURIComponent( + message, + )}` + }, [chain, projectId]) + + const handleGoToProject = useCallback(() => { + setGotoProjectClicked(true) + router.push( + `/v2/p/${projectId}?${NEW_DEPLOY_QUERY_PARAM}=1`, + `/v2/p/${projectId}`, + ) + }, [projectId, router]) + + return ( +
+ Project created successfully image +
+ Congratulations! +
+
+ {deployGreeting} +
+ + + Go to project + + + + + +
+ + + + + + +
+
+ ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx new file mode 100644 index 0000000000..b3ed62e039 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx @@ -0,0 +1,50 @@ +import { t, Trans } from '@lingui/macro' +import { Tooltip } from 'antd' +import { CreateFlowPayoutsTable } from '../../../PayoutsPage/components/CreateFlowPayoutsTable' +import { ReviewDescription } from '../ReviewDescription' +import { useFundingConfigurationReview } from './hooks/useFundingConfigurationReview' + +export const FundingConfigurationReview = () => { + const { duration, fundingCycles, launchDate } = + useFundingConfigurationReview() + + return ( + <> +
+ {fundingCycles}
} + /> + {duration} + ) : null + } + /> + + {launchDate ? ( + + {launchDate.clone().format('MMMM Do YYYY, h:mma z')} + + ) : ( + Immediately + )} + + } + /> + + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts new file mode 100644 index 0000000000..83b7f6a8e3 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts @@ -0,0 +1,75 @@ +import { t } from '@lingui/macro' +import moment from 'moment' +import { useAvailablePayoutsSelections } from 'packages/v2v3/components/Create/components/pages/PayoutsPage/hooks/useAvailablePayoutsSelections' +import { formatFundingCycleDuration } from 'packages/v2v3/components/Create/utils/formatFundingCycleDuration' +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' + +export const useFundingConfigurationReview = () => { + const { fundingCycleData, payoutsSelection, mustStartAtOrAfter } = + useAppSelector(state => state.editingV2Project) + const [distributionLimit] = useEditingDistributionLimit() + const [payoutSplits, setPayoutSplits] = useEditingPayoutSplits() + + const fundingCycles = useMemo( + () => (fundingCycleData.duration == '0' ? t`Manual` : t`Automated`), + [fundingCycleData.duration], + ) + + const duration = useMemo( + () => formatFundingCycleDuration(fundingCycleData.duration), + [fundingCycleData.duration], + ) + + const fundingTarget = useMemo( + () => + formatFundingTarget({ + distributionLimitWad: distributionLimit?.amount, + distributionLimitCurrency: distributionLimit?.currency, + }), + [distributionLimit?.amount, distributionLimit?.currency], + ) + + const availableSelections = useAvailablePayoutsSelections() + const selection = useMemo(() => { + const overrideSelection = + availableSelections.size === 1 ? [...availableSelections][0] : undefined + return overrideSelection || payoutsSelection + }, [availableSelections, payoutsSelection]) + + const launchDate = useMemo( + () => + mustStartAtOrAfter && + mustStartAtOrAfter !== DEFAULT_MUST_START_AT_OR_AFTER && + !isNaN(parseFloat(mustStartAtOrAfter)) + ? moment.unix(parseFloat(mustStartAtOrAfter)) + : undefined, + [mustStartAtOrAfter], + ) + + const allocationSplits = useMemo( + () => payoutSplits.map(splitToAllocation), + [payoutSplits], + ) + const setAllocationSplits = useCallback( + (allocations: AllocationSplit[]) => + setPayoutSplits(allocations.map(allocationToSplit)), + [setPayoutSplits], + ) + + return { + selection, + fundingCycles, + duration, + fundingTarget, + allocationSplits, + setAllocationSplits, + launchDate, + } +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectDetailsReview/ProjectDetailsReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectDetailsReview/ProjectDetailsReview.tsx new file mode 100644 index 0000000000..b7b74c3504 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectDetailsReview/ProjectDetailsReview.tsx @@ -0,0 +1,171 @@ +import { Trans, t } from '@lingui/macro' +import EthereumAddress from 'components/EthereumAddress' +import ProjectLogo from 'components/ProjectLogo' +import { ProjectTagsList } from 'components/ProjectTags/ProjectTagsList' +import { RichPreview } from 'components/RichPreview/RichPreview' +import { useWallet } from 'hooks/Wallet' +import { useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { ipfsUriToGatewayUrl } from 'utils/ipfs' +import { wrapNonAnchorsInAnchor } from 'utils/wrapNonAnchorsInAnchor' +import { ReviewDescription } from '../ReviewDescription' + +export const ProjectDetailsReview = () => { + const { userAddress } = useWallet() + const { + projectMetadata: { + description, + discord, + telegram, + logoUri, + coverImageUri, + infoUri, + name, + payDisclosure, + twitter, + projectTagline, + tags, + introVideoUrl, + introImageUri, + softTargetAmount, + softTargetCurrency, + }, + inputProjectOwner, + } = useAppSelector(state => state.editingV2Project) + + const youtubeUrl = useMemo(() => { + if (!introVideoUrl) return undefined + const url = new URL(introVideoUrl) + const videoId = url.searchParams.get('v') + if (!videoId) return undefined + return `https://www.youtube.com/embed/${videoId}` + }, [introVideoUrl]) + + const ownerAddress = inputProjectOwner ?? userAddress + + const wrappedDescription = useMemo(() => { + if (!description) return undefined + return wrapNonAnchorsInAnchor(description) + }, [description]) + + const coverImageSrc = coverImageUri + ? ipfsUriToGatewayUrl(coverImageUri) + : undefined + + const introImageSrc = introImageUri + ? ipfsUriToGatewayUrl(introImageUri) + : undefined + + return ( +
+ {/* START: Top */} + + {name} +
+ } + /> + + {projectTagline} + + ) : null + } + /> + } + /> + {/* END: Top */} + + {/* START: Bottom */} + } + /> + + {twitter} + + ) : null + } + /> + + {discord} + + ) : null + } + /> + + {telegram} + + ) : null + } + /> + + {infoUri} + + ) : null + } + /> + : t`No tags`} + /> + + {payDisclosure} + + ) : null + } + /> + {coverImageSrc ? ( + + } + /> + ) : null} + {/* END: Bottom */} + + ) : ( + Wallet not connected + ) + } + /> + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/ProjectTokenReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/ProjectTokenReview.tsx new file mode 100644 index 0000000000..15b5802bda --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/ProjectTokenReview.tsx @@ -0,0 +1,97 @@ +import { t } from '@lingui/macro' +import { ReservedTokensList } from 'packages/v2v3/components/shared/ReservedTokensList' +import { + formatDiscountRate, + formatIssuanceRate, + formatRedemptionRate, + formatReservedRate, +} from 'packages/v2v3/utils/math' +import { formatAmount } from 'utils/format/formatAmount' +import * as ProjectTokenForm from '../../../ProjectToken/hooks/useProjectTokenForm' +import { ReviewDescription } from '../ReviewDescription' +import { useProjectTokenReview } from './hooks/useProjectTokenReview' + +export const ProjectTokenReview = () => { + const { + allocationSplits, + allowTokenMinting, + pauseTransfers, + discountRate, + redemptionRate, + reservedRate, + setAllocationSplits, + weight, + } = useProjectTokenReview() + + return ( +
+ + {formatAmount( + weight + ? formatIssuanceRate(weight) + : ProjectTokenForm.DefaultSettings.initialMintRate, + )} +
+ } + /> + + {formatReservedRate( + reservedRate + ? reservedRate + : ProjectTokenForm.DefaultSettings.reservedTokensPercentage.toString(), + ) + '%'} + + } + /> + + } + /> + + {formatDiscountRate( + discountRate + ? discountRate + : ProjectTokenForm.DefaultSettings.discountRate.toString(), + ) + '%'} + + } + /> + + {formatRedemptionRate( + redemptionRate + ? redemptionRate + : ProjectTokenForm.DefaultSettings.redemptionRate.toString(), + ) + '%'} + + } + /> + {allowTokenMinting}} + /> + + {pauseTransfers}} + /> + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts new file mode 100644 index 0000000000..c5f4b5da21 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts @@ -0,0 +1,50 @@ +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' + +export const useProjectTokenReview = () => { + const { + fundingCycleData: { weight, discountRate }, + fundingCycleMetadata: { + allowMinting, + reservedRate, + redemptionRate, + global, + }, + } = useAppSelector(state => state.editingV2Project) + const [tokenSplits, setTokenSplits] = useEditingReservedTokensSplits() + + const allocationSplits = useMemo( + () => tokenSplits.map(splitToAllocation), + [tokenSplits], + ) + const setAllocationSplits = useCallback( + (allocations: AllocationSplit[]) => + setTokenSplits(allocations.map(allocationToSplit)), + [setTokenSplits], + ) + + const allowTokenMinting = useMemo( + () => formatEnabled(allowMinting), + [allowMinting], + ) + + const pauseTransfers = useMemo( + () => formatPaused(global.pauseTransfers), + [global.pauseTransfers], + ) + + return { + weight, + discountRate, + allowTokenMinting, + pauseTransfers, + reservedRate, + redemptionRate, + allocationSplits, + setAllocationSplits, + } +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ReviewDescription.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ReviewDescription.tsx new file mode 100644 index 0000000000..11604f1d32 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ReviewDescription.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react' +import { twMerge } from 'tailwind-merge' + +export const ReviewDescription = ({ + className, + title, + desc, + placeholder = '-', +}: { + className?: string + title: ReactNode + desc: ReactNode + placeholder?: ReactNode +}) => { + return ( +
+
+ {title} +
+
{desc ? desc : {placeholder}}
+
+ ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RewardsReview/RewardsReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RewardsReview/RewardsReview.tsx new file mode 100644 index 0000000000..23c08e274d --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RewardsReview/RewardsReview.tsx @@ -0,0 +1,90 @@ +import { t } from '@lingui/macro' +import { RewardsList } from 'components/NftRewards/RewardsList/RewardsList' +import { NftRewardTier } from 'models/nftRewards' +import { useCallback, useMemo } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { editingV2ProjectActions } from 'redux/slices/editingV2Project' +import { formatEnabled } from 'utils/format/formatBoolean' +import { v4 } from 'uuid' +import { ReviewDescription } from '../ReviewDescription' + +export const RewardsReview = () => { + const { + nftRewards: { rewardTiers, flags }, + fundingCycleMetadata, + } = useAppSelector(state => state.editingV2Project) + + const dispatch = useAppDispatch() + + const rewards: NftRewardTier[] = useMemo(() => { + return ( + rewardTiers?.map(t => ({ + id: parseInt(v4()), + name: t.name, + contributionFloor: t.contributionFloor, + description: t.description, + maxSupply: t.maxSupply, + remainingSupply: t.maxSupply, + externalLink: t.externalLink, + fileUrl: t.fileUrl, + beneficiary: t.beneficiary, + reservedRate: t.reservedRate, + votingWeight: t.votingWeight, + })) ?? [] + ) + }, [rewardTiers]) + + const setRewards = useCallback( + (rewards: NftRewardTier[]) => { + dispatch( + editingV2ProjectActions.setNftRewardTiers( + rewards.map(reward => ({ + contributionFloor: reward.contributionFloor, + maxSupply: reward.maxSupply, + remainingSupply: reward.maxSupply, + id: reward.id, + fileUrl: reward.fileUrl, + name: reward.name, + externalLink: reward.externalLink, + description: reward.description, + beneficiary: reward.beneficiary, + reservedRate: reward.reservedRate, + votingWeight: reward.votingWeight, + })), + ), + ) + }, + [dispatch], + ) + + const shouldUseDataSourceForRedeem = useMemo(() => { + return formatEnabled(fundingCycleMetadata.useDataSourceForRedeem) + }, [fundingCycleMetadata.useDataSourceForRedeem]) + + const preventOverspending = useMemo(() => { + return formatEnabled(flags.preventOverspending) + }, [flags.preventOverspending]) + + return ( +
+ +
+ + {shouldUseDataSourceForRedeem} +
+ } + /> + {preventOverspending}
+ } + /> + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/RulesReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/RulesReview.tsx new file mode 100644 index 0000000000..12ef73ebd2 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/RulesReview.tsx @@ -0,0 +1,76 @@ +import { t } from '@lingui/macro' +import EthereumAddress from 'components/EthereumAddress' +import { FEATURE_FLAGS } from 'constants/featureFlags' +import { featureFlagEnabled } from 'utils/featureFlags' +import { ReviewDescription } from '../ReviewDescription' +import { useRulesReview } from './hooks/useRulesReview' + +export const RulesReview = () => { + const { + customAddress, + pausePayments, + strategy, + terminalConfiguration, + controllerConfiguration, + terminalMigration, + controllerMigration, + holdFees, + ofac, + } = useRulesReview() + + return ( +
+ + {strategy ? ( + strategy + ) : customAddress ? ( + + ) : ( + '??' + )} +
+ } + /> + {pausePayments}} + /> + {holdFees}} + /> + {terminalConfiguration} + } + /> + {controllerConfiguration} + } + /> + {terminalMigration}} + /> + {controllerMigration} + } + /> + {featureFlagEnabled(FEATURE_FLAGS.OFAC) ? ( + {ofac}} + /> + ) : null} + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/hooks/useRulesReview.ts b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/hooks/useRulesReview.ts new file mode 100644 index 0000000000..d80302184d --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/hooks/useRulesReview.ts @@ -0,0 +1,66 @@ +import { readNetwork } from 'constants/networks' +import { useAvailableReconfigurationStrategies } from 'packages/v2v3/components/Create/hooks/useAvailableReconfigurationStrategies' +import { useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { + formatAllowed, + formatBoolean, + formatPaused, +} from 'utils/format/formatBoolean' + +export const useRulesReview = () => { + const availableBallotStrategies = useAvailableReconfigurationStrategies( + readNetwork.name, + ) + const { + fundingCycleData: { ballot: customAddress }, + reconfigurationRuleSelection, + fundingCycleMetadata, + projectMetadata, + } = useAppSelector(state => state.editingV2Project) + + const pausePayments = useMemo(() => { + return formatPaused(fundingCycleMetadata.pausePay) + }, [fundingCycleMetadata.pausePay]) + + const terminalConfiguration = useMemo(() => { + return formatAllowed(fundingCycleMetadata.global.allowSetTerminals) + }, [fundingCycleMetadata.global.allowSetTerminals]) + + const controllerConfiguration = useMemo(() => { + return formatAllowed(fundingCycleMetadata.global.allowSetController) + }, [fundingCycleMetadata.global.allowSetController]) + + const terminalMigration = useMemo(() => { + return formatAllowed(fundingCycleMetadata.allowTerminalMigration) + }, [fundingCycleMetadata.allowTerminalMigration]) + + const controllerMigration = useMemo(() => { + return formatAllowed(fundingCycleMetadata.allowControllerMigration) + }, [fundingCycleMetadata.allowControllerMigration]) + + const strategy = useMemo(() => { + return availableBallotStrategies.find( + strategy => strategy.id === reconfigurationRuleSelection, + )?.name + }, [availableBallotStrategies, reconfigurationRuleSelection]) + + const holdFees = useMemo(() => { + return formatBoolean(fundingCycleMetadata.holdFees) + }, [fundingCycleMetadata.holdFees]) + const ofac = useMemo(() => { + return formatBoolean(projectMetadata.projectRequiredOFACCheck) + }, [projectMetadata.projectRequiredOFACCheck]) + + return { + customAddress, + pausePayments, + terminalConfiguration, + controllerConfiguration, + terminalMigration, + controllerMigration, + strategy, + holdFees, + ofac, + } +} diff --git a/src/packages/v4/components/Create/components/pages/hooks/useFormDispatchWatch.ts b/src/packages/v4/components/Create/components/pages/hooks/useFormDispatchWatch.ts new file mode 100644 index 0000000000..2f5fdccdaf --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/hooks/useFormDispatchWatch.ts @@ -0,0 +1,48 @@ +import { ActionCreatorWithPayload } from '@reduxjs/toolkit' +import { FormInstance } from 'antd' +import { useWatch } from 'antd/lib/form/Form' +import isEqual from 'lodash/isEqual' +import { useEffect } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' + +/** + * Watches a form field and updates a redux state when it changes. + */ +export const useFormDispatchWatch = < + FormValues extends Record, + FieldName extends keyof FormValues, + Payload = FormValues[FieldName], +>({ + form, + fieldName, + ignoreUndefined, + currentValue, + dispatchFunction, + formatter, +}: { + form: FormInstance + fieldName: FieldName + ignoreUndefined?: boolean + currentValue?: Payload + dispatchFunction: ActionCreatorWithPayload + formatter: (v: FormValues[FieldName]) => Payload +}) => { + const fieldValue = useWatch(fieldName, form) + const dispatch = useAppDispatch() + + useEffect(() => { + if (ignoreUndefined && fieldValue === undefined) return + + const v = formatter(fieldValue) + if (isEqual(v, currentValue)) return + + dispatch(dispatchFunction(v)) + }, [ + currentValue, + dispatch, + dispatchFunction, + fieldValue, + formatter, + ignoreUndefined, + ]) +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useDeployNftProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useDeployNftProject.ts new file mode 100644 index 0000000000..75ab2342d8 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useDeployNftProject.ts @@ -0,0 +1,137 @@ +import { TransactionCallbacks } from 'models/transaction' +import { useLaunchProjectWithNftsTx } from 'packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx' +import { DEFAULT_JB_721_DELEGATE_VERSION } from 'packages/v2v3/hooks/defaultContracts/useDefaultJB721Delegate' +import { useCallback, useMemo } from 'react' +import { + useAppSelector, + useEditingV2V3FundAccessConstraintsSelector, + useEditingV2V3FundingCycleDataSelector, + useEditingV2V3FundingCycleMetadataSelector, +} from 'redux/hooks/useAppSelector' +import { DEFAULT_NFT_FLAGS } from 'redux/slices/editingV2Project' +import { NFT_FUNDING_CYCLE_METADATA_OVERRIDES } from 'utils/nftFundingCycleMetadataOverrides' +import { buildJB721TierParams } from 'utils/nftRewards' + +/** + * Hook that returns a function that deploys a project with NFT rewards. + + * The distinction is made between standard and NFT projects because the NFT + * project contract uses more gas. + * @returns A function that deploys a project with NFT rewards. + */ +export const useDeployNftProject = () => { + const launchProjectWithNftsTx = useLaunchProjectWithNftsTx() + const { + projectMetadata, + nftRewards, + payoutGroupedSplits, + reservedTokensGroupedSplits, + inputProjectOwner, + mustStartAtOrAfter, + } = useAppSelector(state => state.editingV2Project) + const fundingCycleMetadata = useEditingV2V3FundingCycleMetadataSelector() + const fundingCycleData = useEditingV2V3FundingCycleDataSelector() + const fundAccessConstraints = useEditingV2V3FundAccessConstraintsSelector() + + const collectionName = useMemo( + () => + nftRewards.collectionMetadata.name + ? nftRewards.collectionMetadata.name + : projectMetadata.name, + [nftRewards.collectionMetadata.name, projectMetadata.name], + ) + const collectionSymbol = useMemo( + () => nftRewards.collectionMetadata.symbol ?? '', + [nftRewards.collectionMetadata.symbol], + ) + const nftFlags = useMemo( + () => nftRewards.flags ?? DEFAULT_NFT_FLAGS, + [nftRewards.flags], + ) + const governanceType = nftRewards.governanceType + const currency = nftRewards.pricing.currency + + /** + * Deploy a project with NFT rewards. + * @param metadataCid IPFS CID of the project metadata. + * @param rewardTierCids IPFS CIDs of the reward tiers. + * @param nftCollectionMetadataCid IPFS CID of the NFT collection metadata. + */ + const deployNftProjectCallback = useCallback( + async ({ + metadataCid, + rewardTierCids, + nftCollectionMetadataUri, + + onDone, + onConfirmed, + onCancelled, + onError, + }: { + metadataCid: string + rewardTierCids: string[] + nftCollectionMetadataUri: string + } & TransactionCallbacks) => { + if (!collectionName) throw new Error('No collection name or project name') + if (!(rewardTierCids.length && nftRewards.rewardTiers)) + throw new Error('No NFTs') + + const groupedSplits = [payoutGroupedSplits, reservedTokensGroupedSplits] + const tiers = buildJB721TierParams({ + cids: rewardTierCids, + rewardTiers: nftRewards.rewardTiers, + version: DEFAULT_JB_721_DELEGATE_VERSION, + }) + + return await launchProjectWithNftsTx( + { + tiered721DelegateData: { + collectionUri: nftCollectionMetadataUri, + collectionName, + collectionSymbol, + currency, + governanceType, + tiers, + flags: nftFlags, + }, + projectData: { + owner: inputProjectOwner?.length ? inputProjectOwner : undefined, + projectMetadataCID: metadataCid, + fundingCycleData, + mustStartAtOrAfter, + fundingCycleMetadata: { + ...fundingCycleMetadata, + ...NFT_FUNDING_CYCLE_METADATA_OVERRIDES, + }, + fundAccessConstraints, + groupedSplits, + }, + }, + { + onDone, + onConfirmed, + onCancelled, + onError, + }, + ) + }, + [ + collectionName, + nftRewards.rewardTiers, + currency, + payoutGroupedSplits, + reservedTokensGroupedSplits, + launchProjectWithNftsTx, + collectionSymbol, + governanceType, + inputProjectOwner, + fundingCycleData, + mustStartAtOrAfter, + fundingCycleMetadata, + fundAccessConstraints, + nftFlags, + ], + ) + + return deployNftProjectCallback +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useIsNftProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useIsNftProject.ts new file mode 100644 index 0000000000..5d38d3d9d3 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useIsNftProject.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' + +/** + * Hook that returns whether the project to be deployed is an NFT project. + * @returns Whether the project to be deployed is an NFT project. + */ +export const useIsNftProject = (): boolean => { + const { nftRewards } = useAppSelector(state => state.editingV2Project) + + return useMemo( + () => + Boolean(nftRewards?.rewardTiers && nftRewards?.rewardTiers.length > 0), + [nftRewards.rewardTiers], + ) +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useUploadNftRewards.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useUploadNftRewards.ts new file mode 100644 index 0000000000..5b4193e286 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useUploadNftRewards.ts @@ -0,0 +1,62 @@ +import { useCallback } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { + defaultNftCollectionDescription, + pinNftCollectionMetadata, + pinNftRewards, +} from 'utils/nftRewards' + +/** + * Hook that returns a function that uploads NFT rewards to IPFS. + * @returns A function that uploads NFT rewards to IPFS. + */ +export const useUploadNftRewards = () => { + const { + nftRewards, + projectMetadata: { name: projectName, logoUri, infoUri }, + } = useAppSelector(state => state.editingV2Project) + + return useCallback(async () => { + if (!nftRewards?.rewardTiers || !nftRewards?.collectionMetadata) return + + const [rewardTiersCids, nftCollectionMetadataCid] = await Promise.all([ + pinNftRewards(nftRewards.rewardTiers), + pinNftCollectionMetadata({ + collectionName: + nftRewards.collectionMetadata.name ?? + defaultNftCollectionDescription(projectName), + collectionDescription: + nftRewards.collectionMetadata.description ?? + defaultNftCollectionDescription(projectName), + collectionLogoUri: logoUri, + collectionInfoUri: infoUri, + }), + ]) + + if (rewardTiersCids.length !== nftRewards.rewardTiers.length) { + console.error('Failed to upload all NFT tiers', { + rewardTiersCids, + inputRewardTiers: nftRewards.rewardTiers, + }) + throw new Error('Failed to upload all NFT tiers') + } + if (!nftCollectionMetadataCid.length) { + console.error('Failed to upload NFT collection metadata', { + nftCollectionMetadataCid, + inputNftCollectionMetadata: nftRewards.collectionMetadata, + }) + throw new Error('Failed to upload NFT collection metadata') + } + + return { + rewardTiers: rewardTiersCids, + nfCollectionMetadata: nftCollectionMetadataCid, + } + }, [ + infoUri, + logoUri, + nftRewards.collectionMetadata, + nftRewards.rewardTiers, + projectName, + ]) +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts new file mode 100644 index 0000000000..a3dc8d3be4 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts @@ -0,0 +1,74 @@ +import { TransactionCallbacks } from 'models/transaction' +import { useLaunchProjectTx } from 'packages/v2v3/hooks/transactor/useLaunchProjectTx' +import { useCallback } from 'react' +import { + useAppSelector, + useEditingV2V3FundAccessConstraintsSelector, + useEditingV2V3FundingCycleDataSelector, + useEditingV2V3FundingCycleMetadataSelector, +} from 'redux/hooks/useAppSelector' + +/** + * Hook that returns a function that deploys a project. + * + * The distinction is made between standard and NFT projects because the NFT + * project contract uses more gas. + * @returns A function that deploys a project. + */ +export const useDeployStandardProject = () => { + const launchProject = useLaunchProjectTx() + const { + payoutGroupedSplits, + reservedTokensGroupedSplits, + inputProjectOwner, + mustStartAtOrAfter, + } = useAppSelector(state => state.editingV2Project) + const fundingCycleMetadata = useEditingV2V3FundingCycleMetadataSelector() + const fundingCycleData = useEditingV2V3FundingCycleDataSelector() + const fundAccessConstraints = useEditingV2V3FundAccessConstraintsSelector() + + const deployStandardProjectCallback = useCallback( + async ({ + metadataCid, + + onDone, + onConfirmed, + onCancelled, + }: { + metadataCid: string + } & Pick< + TransactionCallbacks, + 'onCancelled' | 'onConfirmed' | 'onDone' + >) => { + const groupedSplits = [payoutGroupedSplits, reservedTokensGroupedSplits] + return await launchProject( + { + owner: inputProjectOwner?.length ? inputProjectOwner : undefined, + projectMetadataCID: metadataCid, + fundingCycleData, + fundingCycleMetadata, + mustStartAtOrAfter, + fundAccessConstraints, + groupedSplits, + }, + { + onDone, + onConfirmed, + onCancelled, + }, + ) + }, + [ + payoutGroupedSplits, + reservedTokensGroupedSplits, + launchProject, + inputProjectOwner, + fundingCycleData, + fundingCycleMetadata, + mustStartAtOrAfter, + fundAccessConstraints, + ], + ) + + return deployStandardProjectCallback +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts new file mode 100644 index 0000000000..cb09e58ba1 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts @@ -0,0 +1,253 @@ +import { readProvider } from 'constants/readProvider' +import { BigNumber, providers } from 'ethers' +import { uploadProjectMetadata } from 'lib/api/ipfs' +import { TransactionCallbacks } from 'models/transaction' +import { useCallback, useState } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { + useAppSelector, + useEditingV2V3FundAccessConstraintsSelector, + useEditingV2V3FundingCycleDataSelector, + useEditingV2V3FundingCycleMetadataSelector, +} from 'redux/hooks/useAppSelector' +import { editingV2ProjectActions } from 'redux/slices/editingV2Project' +import { emitErrorNotification } from 'utils/notifications' +import { useDeployNftProject } from './hooks/NFT/useDeployNftProject' +import { useIsNftProject } from './hooks/NFT/useIsNftProject' +import { useUploadNftRewards } from './hooks/NFT/useUploadNftRewards' +import { useDeployStandardProject } from './hooks/useDeployStandardProject' + +const CREATE_EVENT_IDX = 2 +const NFT_CREATE_EVENT_IDX = 3 +const PROJECT_ID_TOPIC_IDX = 1 + +const JUICEBOX_DOMAIN = 'juicebox' +const JUICECROWD_DOMAIN = 'juicecrowd' + +/** + * Return the project ID created from a `launchProjectFor` transaction. + * @param txReceipt receipt of `launchProjectFor` transaction + */ +const getProjectIdFromNftLaunchReceipt = ( + txReceipt: providers.TransactionReceipt, +): number => { + const projectIdHex: unknown | undefined = + txReceipt?.logs[NFT_CREATE_EVENT_IDX]?.topics?.[PROJECT_ID_TOPIC_IDX] + const projectId = BigNumber.from(projectIdHex).toNumber() + + return projectId +} + +/** + * Return the project ID created from a `launchProjectFor` transaction. + * @param txReceipt receipt of `launchProjectFor` transaction + */ +const getProjectIdFromLaunchReceipt = ( + txReceipt: providers.TransactionReceipt, +): number => { + const projectIdHex: unknown | undefined = + txReceipt?.logs[CREATE_EVENT_IDX]?.topics?.[PROJECT_ID_TOPIC_IDX] + const projectId = BigNumber.from(projectIdHex).toNumber() + + return projectId +} + +/** + * Attempt to find the transaction receipt from a transaction hash. + + * Will retry up to 5 times with a 2 second delay between each attempt. If no + * receipt is found after 5 attempts, undefined is returned. + * + * @param txHash transaction hash + * @returns transaction receipt or undefined + */ +const findTransactionReceipt = async (txHash: string) => { + let retries = 5 + let receipt + while (retries > 0 && !receipt) { + receipt = await readProvider.getTransactionReceipt(txHash) + if (receipt) break + + retries -= 1 + // wait 2s + await new Promise(r => setTimeout(r, 2000)) + console.info('Retrying tx receipt lookup...') + } + + return receipt +} + +/** + * Hook that returns a function that deploys a project. + * @returns A function that deploys a project. + */ +export const useDeployProject = () => { + const [isDeploying, setIsDeploying] = useState(false) + const [transactionPending, setTransactionPending] = useState() + + const isNftProject = useIsNftProject() + const uploadNftRewards = useUploadNftRewards() + const deployNftProject = useDeployNftProject() + + const deployStandardProject = useDeployStandardProject() + + const { + projectMetadata, + nftRewards: { postPayModal }, + } = useAppSelector(state => state.editingV2Project) + const fundingCycleMetadata = useEditingV2V3FundingCycleMetadataSelector() + const fundingCycleData = useEditingV2V3FundingCycleDataSelector() + const fundAccessConstraints = useEditingV2V3FundAccessConstraintsSelector() + + const dispatch = useAppDispatch() + + const handleDeployFailure = useCallback((error: unknown) => { + console.error(error) + emitErrorNotification(`Error deploying project: ${error}`) + setIsDeploying(false) + setTransactionPending(false) + }, []) + + const operationCallbacks = useCallback( + ( + onProjectDeployed?: (projectId: number) => void, + ): Pick< + TransactionCallbacks, + 'onCancelled' | 'onConfirmed' | 'onDone' | 'onError' + > => ({ + onDone: () => { + console.info('Project transaction executed. Await confirmation...') + setTransactionPending(true) + }, + onConfirmed: async result => { + const hash = result?.hash + if (!hash) { + return // TODO error notification + } + const txReceipt = await findTransactionReceipt(hash) + if (!txReceipt) { + return // TODO error notification + } + + const projectId = isNftProject + ? getProjectIdFromNftLaunchReceipt(txReceipt) + : getProjectIdFromLaunchReceipt(txReceipt) + + if (projectId === undefined) { + return // TODO error notification + } + + // Reset the project state + dispatch(editingV2ProjectActions.resetState()) + onProjectDeployed?.(projectId) + }, + onError: error => { + console.error(error) + emitErrorNotification(`Error deploying project: ${error}`) + }, + onCancelled: () => { + setIsDeploying(false) + setTransactionPending(false) + }, + }), + [dispatch, isNftProject], + ) + + /** + * Deploy a project. + * @param onProjectDeployed Callback to be called when the project is deployed. + * @returns The project ID of the deployed project. + */ + const deployProject = useCallback( + async ({ + onProjectDeployed, + }: { + onProjectDeployed?: (projectId: number) => void + }) => { + setIsDeploying(true) + if ( + !( + projectMetadata.name && + fundingCycleData && + fundingCycleMetadata && + fundAccessConstraints + ) + ) { + setIsDeploying(false) + throw new Error('Error deploying project.') + } + let nftCids: Awaited> | undefined + try { + if (isNftProject) { + nftCids = await uploadNftRewards() + } + } catch (error) { + handleDeployFailure(error) + return + } + + let softTargetAmount: string | undefined + let softTargetCurrency: string | undefined + let domain = JUICEBOX_DOMAIN + + let projectMetadataCid: string | undefined + try { + projectMetadataCid = ( + await uploadProjectMetadata({ + ...projectMetadata, + domain, + softTargetAmount, + softTargetCurrency, + nftPaymentSuccessModal: postPayModal, + }) + ).Hash + } catch (error) { + handleDeployFailure(error) + return + } + + try { + let tx + if (isNftProject) { + tx = await deployNftProject({ + metadataCid: projectMetadataCid, + rewardTierCids: nftCids!.rewardTiers, + nftCollectionMetadataUri: nftCids!.nfCollectionMetadata, + ...operationCallbacks(onProjectDeployed), + }) + } else { + tx = await deployStandardProject({ + metadataCid: projectMetadataCid, + ...operationCallbacks(onProjectDeployed), + }) + } + if (!tx) { + setIsDeploying(false) + setTransactionPending(false) + return + } + } catch (error) { + handleDeployFailure(error) + return + } + }, + [ + deployNftProject, + deployStandardProject, + fundAccessConstraints, + fundingCycleData, + fundingCycleMetadata, + handleDeployFailure, + isNftProject, + operationCallbacks, + postPayModal, + projectMetadata, + uploadNftRewards, + ], + ) + return { + isDeploying, + deployTransactionPending: transactionPending, + deployProject, + } +} diff --git a/src/packages/v4/components/Create/hooks/useAvailableReconfigurationStrategies.ts b/src/packages/v4/components/Create/hooks/useAvailableReconfigurationStrategies.ts new file mode 100644 index 0000000000..535913f719 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/useAvailableReconfigurationStrategies.ts @@ -0,0 +1,31 @@ +import { NetworkName } from 'models/networkName' +import { ballotStrategiesFn } from 'packages/v2v3/constants/ballotStrategies' +import { ArrayElement } from 'utils/arrayElement' + +export const useAvailableReconfigurationStrategies = (network: NetworkName) => { + const strategies = ballotStrategiesFn({ network }).map(s => + s.id === 'threeDay' + ? { ...s, isDefault: true } + : { ...s, isDefault: false }, + ) + const threeDay = strategies.find(s => s.id === 'threeDay') + const oneDay = strategies.find(s => s.id === 'oneDay') + const sevenDay = strategies.find(s => s.id === 'sevenDay') + const none = strategies.find(s => s.id === 'none') + + if (!threeDay || !oneDay || !sevenDay || !none) { + console.error( + 'Unexpected error occurred - missing field in edit deadlines', + { threeDay, oneDay, sevenDay, none }, + ) + throw new Error( + 'Unexpected error occurred - missing field in edit deadlines', + ) + } + + return [threeDay, oneDay, sevenDay, none] +} + +export type AvailableReconfigurationStrategy = ArrayElement< + ReturnType +> diff --git a/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts new file mode 100644 index 0000000000..1b917f4b2d --- /dev/null +++ b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts @@ -0,0 +1,178 @@ +import { useJBContractContext } from 'juice-sdk-react' +import isEqual from 'lodash/isEqual' +import { CreatePage } from 'models/createPage' +import { ProjectTokensSelection } from 'models/projectTokenSelection' +import { TreasurySelection } from 'models/treasurySelection' +import { useRouter } from 'next/router' +import { ballotStrategiesFn } from 'packages/v2v3/constants/ballotStrategies' +import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' +import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' +import { useEffect, useState } from 'react' +import { useDispatch } from 'react-redux' +import { + DEFAULT_REDUX_STATE, + INITIAL_REDUX_STATE, + editingV2ProjectActions, +} from 'redux/slices/editingV2Project' +import { CreateState, ProjectState } from 'redux/slices/editingV2Project/types' +import { isEqualAddress } from 'utils/address' +import { parseWad } from 'utils/format/formatNumber' +import { DefaultSettings as DefaultTokenSettings } from '../components/pages/ProjectToken/hooks/useProjectTokenForm' +import { projectTokenSettingsToReduxFormat } from '../utils/projectTokenSettingsToReduxFormat' + +const ReduxDefaultTokenSettings = + projectTokenSettingsToReduxFormat(DefaultTokenSettings) + +const parseCreateFlowStateFromInitialState = ( + initialState: ProjectState, +): CreateState => { + const duration = initialState.fundingCycleData.duration + + let fundingCyclesPageSelection: 'manual' | 'automated' | undefined = undefined + switch (duration) { + case '': + fundingCyclesPageSelection = undefined + break + case '0': + fundingCyclesPageSelection = 'manual' + break + default: + fundingCyclesPageSelection = 'automated' + } + + const distributionLimit = initialState.fundAccessConstraints[0] + ?.distributionLimit + ? parseWad(initialState.fundAccessConstraints[0]?.distributionLimit) + : undefined + + let treasurySelection: TreasurySelection | undefined + + if (distributionLimit === undefined) { + treasurySelection = undefined + } else if (distributionLimit.eq(MAX_DISTRIBUTION_LIMIT)) { + treasurySelection = 'unlimited' + } else if (distributionLimit.eq(0)) { + treasurySelection = 'zero' + } else { + treasurySelection = 'amount' + } + + let projectTokensSelection: ProjectTokensSelection | undefined + const initialTokenData = { + weight: initialState.fundingCycleData.weight, + reservedRate: initialState.fundingCycleMetadata.reservedRate, + reservedTokensGroupedSplits: initialState.reservedTokensGroupedSplits, + discountRate: initialState.fundingCycleData.discountRate, + redemptionRate: initialState.fundingCycleMetadata.redemptionRate, + allowMinting: initialState.fundingCycleMetadata.allowMinting, + } + if (isEqual(initialTokenData, ReduxDefaultTokenSettings)) { + projectTokensSelection = 'default' + } else { + projectTokensSelection = 'custom' + } + + const reconfigurationRuleSelection = + ballotStrategiesFn({}).find(s => + isEqualAddress(s.address, initialState.fundingCycleData.ballot), + )?.id ?? 'threeDay' + + let createFurthestPageReached: CreatePage = 'projectDetails' + if (fundingCyclesPageSelection) { + createFurthestPageReached = 'fundingCycles' + } + if (treasurySelection) { + createFurthestPageReached = 'payouts' + } + if (projectTokensSelection) { + createFurthestPageReached = 'projectToken' + } + if (reconfigurationRuleSelection) { + createFurthestPageReached = 'reconfigurationRules' + } + if ( + fundingCyclesPageSelection && + treasurySelection && + projectTokensSelection && + reconfigurationRuleSelection + ) { + createFurthestPageReached = 'reviewDeploy' + } + + return { + fundingCyclesPageSelection, + treasurySelection, + fundingTargetSelection: undefined, // TODO: Remove + payoutsSelection: undefined, // TODO: Remove + projectTokensSelection, + reconfigurationRuleSelection, + createFurthestPageReached, + createSoftLockPageQueue: undefined, // Not supported, this feature is used only for fully fledged projects. + } +} + +/** + * Load redux state from a URL query parameter. + */ +export function useLoadingInitialStateFromQuery() { + const dispatch = useDispatch() + const router = useRouter() + const { contracts } = useJBContractContext() + + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!router.isReady || !contracts.primaryNativeTerminal.data) return + + const { initialState } = router.query + if (!initialState) { + setLoading(false) + return + } + + try { + // TODO we can probably validate this object better in future. + // But worst case, if it's invalid, we'll just ignore it. + const parsedInitialState = JSON.parse( + initialState as string, + ) as ProjectState + const createFlowState = + parseCreateFlowStateFromInitialState(parsedInitialState) + + dispatch( + editingV2ProjectActions.setState({ + ...INITIAL_REDUX_STATE, + ...createFlowState, + ...parsedInitialState, + ...{ + projectMetadata: { + ...DEFAULT_REDUX_STATE.projectMetadata, + ...parsedInitialState.projectMetadata, + }, + fundingCycleMetadata: { + ...DEFAULT_REDUX_STATE.fundingCycleMetadata, + ...parsedInitialState.fundingCycleMetadata, + }, + fundingCycleData: { + ...DEFAULT_REDUX_STATE.fundingCycleData, + ...parsedInitialState.fundingCycleData, + }, + fundAccessConstraints: [ + { + ...DEFAULT_REDUX_STATE.fundAccessConstraints[0], + ...parsedInitialState.fundAccessConstraints[0], + terminal: contracts.primaryNativeTerminal.data, + token: ETH_TOKEN_ADDRESS, + }, + ], + }, + }), + ) + } catch (e) { + console.warn('Error parsing initialState:', e) + } + setLoading(false) + }, [router, dispatch, contracts.primaryNativeTerminal.data]) + + return loading +} diff --git a/src/packages/v4/components/Create/hooks/useLockPageRulesWrapper.ts b/src/packages/v4/components/Create/hooks/useLockPageRulesWrapper.ts new file mode 100644 index 0000000000..ec4c2120c1 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/useLockPageRulesWrapper.ts @@ -0,0 +1,36 @@ +import { Rule, RuleObject } from 'antd/lib/form' +import { useCallback, useContext } from 'react' +import { PageContext } from '../components/Wizard/contexts/PageContext' + +/** + * A hook that returns a function wrapper that wraps all rules in a array and + * soft locks the page if any of the rules fail. + */ +export const useLockPageRulesWrapper = () => { + const { lockPageProgress, unlockPageProgress } = useContext(PageContext) + + const ruleWrapper = useCallback( + (rule: Rule) => { + return { + ...rule, + validator: async (r: RuleObject, v: unknown) => { + if (typeof rule === 'function') { + throw new Error('Unsupported use of RuleRender') + } + try { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const validator = await rule.validator?.(r, v, () => {}) + unlockPageProgress?.() + return validator + } catch (e) { + lockPageProgress?.() + throw e + } + }, + } + }, + [lockPageProgress, unlockPageProgress], + ) + + return useCallback((rules: Rule[]) => rules.map(ruleWrapper), [ruleWrapper]) +} diff --git a/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts b/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts new file mode 100644 index 0000000000..2f8718bb04 --- /dev/null +++ b/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts @@ -0,0 +1,18 @@ +import { BigNumber } from 'ethers' +import { PayoutsSelection } from 'models/payoutsSelection' +import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' + +export const determineAvailablePayoutsSelections = ( + distributionLimit: BigNumber | undefined, +): Set => { + if (!distributionLimit) { + return new Set() + } + if (distributionLimit.eq(0)) { + return new Set(['amounts']) + } + if (distributionLimit.eq(MAX_DISTRIBUTION_LIMIT)) { + return new Set(['percentages']) + } + return new Set(['amounts', 'percentages']) +} diff --git a/src/packages/v4/components/Create/utils/formatFundingCycleDuration.ts b/src/packages/v4/components/Create/utils/formatFundingCycleDuration.ts new file mode 100644 index 0000000000..93986f4c44 --- /dev/null +++ b/src/packages/v4/components/Create/utils/formatFundingCycleDuration.ts @@ -0,0 +1,31 @@ +import { deriveDurationUnit, secondsToOtherUnit } from 'utils/format/formatTime' + +const formatUnit = ({ unit, plural }: { unit: string; plural: boolean }) => { + let formatted = unit.charAt(0).toUpperCase() + unit.slice(1) + if (formatted.endsWith('s')) { + if (!plural) { + formatted = formatted.slice(0, -1) + } + } + return formatted +} + +export const formatFundingCycleDuration = (duration: string) => { + const durationAsNumber = parseInt(duration) + if (isNaN(durationAsNumber)) return 'No duration' + + const derivedUnit = deriveDurationUnit(durationAsNumber) + const formattedDuration = secondsToOtherUnit({ + duration: durationAsNumber, + unit: derivedUnit, + }) + + const formattedUnit = formatUnit({ + unit: derivedUnit, + plural: formattedDuration > 1, + }) + return `${secondsToOtherUnit({ + duration: durationAsNumber, + unit: derivedUnit, + })} ${formattedUnit}` +} diff --git a/src/packages/v4/components/Create/utils/projectTokenSettingsToReduxFormat.ts b/src/packages/v4/components/Create/utils/projectTokenSettingsToReduxFormat.ts new file mode 100644 index 0000000000..de6f55ff2d --- /dev/null +++ b/src/packages/v4/components/Create/utils/projectTokenSettingsToReduxFormat.ts @@ -0,0 +1,40 @@ +import { + discountRateFrom, + formatIssuanceRate, + 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 { ProjectTokensFormProps } from '../components/pages/ProjectToken/hooks/useProjectTokenForm' + +export const projectTokenSettingsToReduxFormat = ( + projectTokenSettings: Required>, +) => { + const weight = formatIssuanceRate(projectTokenSettings.initialMintRate) + const reservedRate = reservedRateFrom( + projectTokenSettings.reservedTokensPercentage, + ).toHexString() + const reservedTokensGroupedSplits = { + ...EMPTY_RESERVED_TOKENS_GROUPED_SPLITS, + splits: projectTokenSettings.reservedTokenAllocation.map(allocationToSplit), + } + const discountRate = discountRateFrom( + projectTokenSettings.discountRate, + ).toHexString() + const redemptionRate = redemptionRateFrom( + projectTokenSettings.redemptionRate, + ).toHexString() + const allowMinting = projectTokenSettings.tokenMinting + const pauseTransfers = projectTokenSettings.pauseTransfers + + return { + weight, + reservedRate, + reservedTokensGroupedSplits, + discountRate, + redemptionRate, + allowMinting, + pauseTransfers, + } +} diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 7a29a18eef..4c283e303a 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -1,16 +1,51 @@ import { AppWrapper } from 'components/common/CoreAppWrapper/CoreAppWrapper' import { Head } from 'components/common/Head/Head' import { CV_V3 } from 'constants/cv' +import { FEATURE_FLAGS } from 'constants/featureFlags' +import { OPEN_IPFS_GATEWAY_HOSTNAME } from 'constants/ipfs' +import { NETWORKS_BY_NAME } from 'constants/networks' import { SiteBaseUrl } from 'constants/url' import { TransactionProvider } from 'contexts/Transaction/TransactionProvider' -import { Create } from 'packages/v2v3/components/Create/Create' +import { JBProjectProvider } from 'juice-sdk-react' +import { NetworkName } from 'models/networkName' +import { Create as V2V3Create } from 'packages/v2v3/components/Create/Create' import { V2V3ContractsProvider } from 'packages/v2v3/contexts/Contracts/V2V3ContractsProvider' import { V2V3CurrencyProvider } from 'packages/v2v3/contexts/V2V3CurrencyProvider' +import { Create as V4Create } from 'packages/v4/components/Create/Create' +import { wagmiConfig } from 'packages/v4/wagmiConfig' import { Provider } from 'react-redux' import store from 'redux/store' +import { featureFlagEnabled } from 'utils/featureFlags' import globalGetServerSideProps from 'utils/next-server/globalGetServerSideProps' +import { WagmiProvider } from 'wagmi' -export default function V2CreatePage() { +export default function CreatePage() { + let contentByVersion = ( + + + + + + + + ) + + if (featureFlagEnabled(FEATURE_FLAGS.V4)) { + contentByVersion = ( + + {/* Hardcode V4 create to Sepoila for now */} + + + + + ) + } return ( <> - {/* New projects will be launched using V3 contracts. */} - - - - - - - + {contentByVersion} From 495f91d4a3a2c66d42a8e00014812b09b078dc42 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 30 Aug 2024 13:18:17 +0800 Subject: [PATCH 2/3] feat: v4 create tx [2/n] (#4445) --- src/components/strings.tsx | 15 ++ .../v2v3 => }/constants/juiceboxTokens.ts | 0 src/locales/messages.pot | 81 +++++++++ .../hooks/DeployProject/useDeployProject.ts | 2 +- .../hooks/useLoadInitialStateFromQuery.ts | 2 +- .../hooks/useInitialEditingData.ts | 2 +- .../hooks/usePrepareSaveEditCycleData.tsx | 2 +- .../transactor/useLaunchProjectWithNftsTx.ts | 4 +- .../useProjectDistributionLimit.ts | 2 +- .../useProjectPrimaryEthTerminalAddress.ts | 2 +- .../AddToBalanceTx/useAddToBalanceArgsV3.ts | 2 +- .../AddToBalanceTx/useAddToBalanceArgsV3_1.ts | 2 +- .../hooks/transactor/useDistributePayouts.ts | 2 +- .../hooks/transactor/useLaunchProjectTx.ts | 4 +- .../transactor/usePayETHPaymentTerminalTx.ts | 2 +- .../useReconfigureV2V3FundingCycleTx.ts | 4 +- src/packages/v4/components/Create/Create.tsx | 19 +- .../components/Wizard/hooks/useSteps.ts | 4 +- .../pages/FundingCycles/FundingCyclesPage.tsx | 38 ++-- .../hooks/useFundingCyclesForm.ts | 2 +- .../components/TreasuryOptionsRadio.tsx | 4 +- .../pages/ProjectToken/ProjectTokenPage.tsx | 2 +- .../CustomTokenSettings.tsx | 26 +-- .../components/DefaultSettings.tsx | 4 +- .../ReconfigurationRulesPage.tsx | 9 +- .../pages/ReviewDeploy/ReviewDeployPage.tsx | 11 +- .../ReviewDeploy/components/DeploySuccess.tsx | 11 +- .../FundingConfigurationReview.tsx | 2 +- .../ProjectTokenReview/ProjectTokenReview.tsx | 4 +- .../hooks/useDeployStandardProject.ts | 32 ++-- .../hooks/DeployProject/useDeployProject.ts | 167 +++++------------- .../hooks/useLoadInitialStateFromQuery.ts | 2 +- src/packages/v4/hooks/useLaunchProjectTx.ts | 140 +++++++++++++++ src/packages/v4/utils/launchProject.ts | 111 ++++++++++++ .../hooks/useEditingDistributionLimit.ts | 13 +- 35 files changed, 489 insertions(+), 240 deletions(-) rename src/{packages/v2v3 => }/constants/juiceboxTokens.ts (100%) create mode 100644 src/packages/v4/hooks/useLaunchProjectTx.ts create mode 100644 src/packages/v4/utils/launchProject.ts diff --git a/src/components/strings.tsx b/src/components/strings.tsx index 36d781c32f..2d80c61a48 100644 --- a/src/components/strings.tsx +++ b/src/components/strings.tsx @@ -35,6 +35,21 @@ export const CYCLE_EXPLANATION = ( ) +export const RULESET_EXPLANATION = ( + +

With unlocked rulesets, you can edit your project's rules at any time.

+

+ With locked rulesets, you can lock your project's rules for a period of time + (like 3 minutes, 2 years, or 14 days), helping you build trust with your + supporters. +

+

+ This choice isn't permanent — you can switch between locked and unlocked + rulesets in the future. +

+
+) + export const LOCKED_PAYOUT_EXPLANATION = ( If locked, this payout can't be edited or removed until the lock expires or diff --git a/src/packages/v2v3/constants/juiceboxTokens.ts b/src/constants/juiceboxTokens.ts similarity index 100% rename from src/packages/v2v3/constants/juiceboxTokens.ts rename to src/constants/juiceboxTokens.ts diff --git a/src/locales/messages.pot b/src/locales/messages.pot index 1dfce92901..5f24b83532 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -179,6 +179,9 @@ msgstr "" msgid "Payout recipients:" msgstr "" +msgid "Your project's first ruleset will start on <0>{0} at {1}. Your project will be visible on <1>juicebox.money once you finish setting your project up, but supporters won't be able to pay or interact with it until the first ruleset begins." +msgstr "" + msgid "Lock until" msgstr "" @@ -437,6 +440,9 @@ msgstr "" msgid "Payer issuance rate" msgstr "" +msgid "None of your project's ETH can be paid out. All ETH will stay in your project for token redemptions or use in future rulesets." +msgstr "" + msgid "Project Details" msgstr "" @@ -542,6 +548,9 @@ msgstr "" msgid "This cycle has upcoming changes" msgstr "" +msgid "<0/> Your project's rules cannot be edited during the first ruleset." +msgstr "" + msgid "While enabled, the project owner can change the project's <0>payment terminals at any time." msgstr "" @@ -557,6 +566,9 @@ msgstr "" msgid "You would receive <0/>" msgstr "" +msgid "Simple token rules that will work for most projects. You can edit these rules in future rulesets." +msgstr "" + msgid "The issuance reduction rate is disabled if you are using unlocked cycles (because they have no duration)." msgstr "" @@ -833,6 +845,9 @@ msgstr "" msgid "Automated" msgstr "" +msgid "Ruleset" +msgstr "" + msgid "Back to settings" msgstr "" @@ -1046,6 +1061,9 @@ msgstr "" msgid "No overflow" msgstr "" +msgid "A fixed amount of ETH can be paid out from your project each ruleset. You can send specific ETH amounts (or ETH amounts based on USD values) to one or more recipients. Any remaining ETH will stay in your project for token redemptions or use in future rulesets." +msgstr "" + msgid "Export tokens CSV" msgstr "" @@ -1181,6 +1199,9 @@ msgstr "" msgid "No results" msgstr "" +msgid "The project's owner can edit the project's rules and start new rulesets at any time." +msgstr "" + msgid "ETH transfers to project" msgstr "" @@ -1202,6 +1223,9 @@ msgstr "" msgid "Fee from <0><1/>" msgstr "" +msgid "Reserved percent" +msgstr "" + msgid "Later" msgstr "" @@ -1214,6 +1238,9 @@ msgstr "" msgid "{receivedTokenSymbolText} Token" msgstr "" +msgid "Ruleset #1 starts when you create your project. With unlocked rulesets, you can edit your project's rules at any time. This gives you more flexibility, but may appear risky to supporters. Switching to locked rulesets will help you build supporter confidence." +msgstr "" + msgid "Issuance reduction rate:" msgstr "" @@ -1418,6 +1445,9 @@ msgstr "" msgid "<0>Juicebox is a <1>governance-minimal protocol. There are only a few levers that can be tuned, none of which impose changes for users without their consent. The Juicebox governance smart contract can adjust these levers.<2>The Juicebox protocol is governed by a community of JBX token holders who vote on proposals fortnightly.<3>Juicebox is on-chain and non-custodial. Project creators actually own their projects, and JuiceboxDAO has no way to access project's ETH or change their rules." msgstr "" +msgid "<0>With unlocked rulesets, you can edit your project's rules at any time.<1>With locked rulesets, you can lock your project's rules for a period of time (like 3 minutes, 2 years, or 14 days), helping you build trust with your supporters.<2>This choice isn't permanent — you can switch between locked and unlocked rulesets in the future." +msgstr "" + msgid "Pay {projectTitle}" msgstr "" @@ -1784,6 +1814,9 @@ msgstr "" msgid "Created project" msgstr "" +msgid "Next ruleset, the project will issue {0} tokens per 1 ETH. The ruleset after that, the project will issue {1} tokens per 1 ETH." +msgstr "" + msgid "An address is required" msgstr "" @@ -1802,6 +1835,9 @@ msgstr "" msgid "Project rules" msgstr "" +msgid "Set a duration for locked rulesets." +msgstr "" + msgid "Upload" msgstr "" @@ -2105,6 +2141,9 @@ msgstr "" msgid "Reset website" msgstr "" +msgid "Decay percent" +msgstr "" + msgid "Payout allocated to this project's {versionName} payment terminal. <0>Learn more." msgstr "" @@ -2420,6 +2459,9 @@ msgstr "" msgid "While enabled, the project owner can change the project's <0>controller at any time." msgstr "" +msgid "<0>With Locked Rulesets, your project's rules are locked for a period of time.<1><2>This helps build trust with your contributors." +msgstr "" + msgid "Ruleset #" msgstr "" @@ -2501,6 +2543,9 @@ msgstr "" msgid "Disclose any details to your contributors before they pay your project." msgstr "" +msgid "The issuance rate is reduced by this percentage every ruleset (every <0>{0}). The higher this rate, the more incentive to pay this project earlier." +msgstr "" + msgid "One or more reserved token recipients" msgstr "" @@ -2615,6 +2660,9 @@ msgstr "" msgid "Your edits will take effect in <0>cycle #{0}. The current cycle (#{currentFCNumber}) won't be altered." msgstr "" +msgid "Leave this blank to start your first ruleset immediately after you finish setting up your project." +msgstr "" + msgid "All of this project's ETH will be paid out. Token holders will receive <0>no ETH when redeeming their tokens." msgstr "" @@ -2642,6 +2690,9 @@ msgstr "" msgid "Give permissions to {0} on project #{projectId}" msgstr "" +msgid "Pay out ETH from your project to any Ethereum wallet or Juicebox project. ETH which <0>isn't paid out will be available for token redemptions, or for use in future rulesets. Payouts reset each ruleset." +msgstr "" + msgid "<0>Juicebox has had <1>multiple security audits, and has handled tens of thousands of ETH through its protocol.<2>However, Juicebox is still experimental software. Although the Juicebox contract team have done their part to shape the smart contracts for public use and have tested the code thoroughly, the risk of exploits is never 0%.<3>Due to their public nature, any exploits to the contracts may have irreversible consequences, including loss of ETH. Please use Juicebox with caution.<4><5>Learn more about the risks." msgstr "" @@ -2855,6 +2906,9 @@ msgstr "" msgid "Paying another Juicebox project may mint its tokens. Select an address to receive these tokens." msgstr "" +msgid "Set a future date & time to start your project's first ruleset." +msgstr "" + msgid "Get help planning or setting up my project." msgstr "" @@ -3071,6 +3125,9 @@ msgstr "" msgid "Connect wallet to deploy" msgstr "" +msgid "Unlocked Rulesets" +msgstr "" + msgid "Your wallet isn't allowed to process held fees." msgstr "" @@ -3086,6 +3143,9 @@ msgstr "" msgid "Check User Wallet Address" msgstr "" +msgid "Rulesets" +msgstr "" + msgid "Set ENS text record for {ensName}" msgstr "" @@ -3140,6 +3200,9 @@ msgstr "" msgid "Yes, start over" msgstr "" +msgid "Ruleset #1 starts when you create your project. With locked rulesets, if you edit your project's rules during Ruleset #1, those edits will be <0>queued for the next ruleset." +msgstr "" + msgid "All {tokensText} will go to the project owner:" msgstr "" @@ -3314,6 +3377,9 @@ msgstr "" msgid "{0} is not a valid integer" msgstr "" +msgid "Rulesets & Payouts" +msgstr "" + msgid "The unallocated portion of your total will go to the wallet that owns the project by default." msgstr "" @@ -3431,6 +3497,9 @@ msgstr "" msgid "No changes" msgstr "" +msgid "After {0} (your first ruleset), your project will not issue any tokens unless you edit the issuance rate." +msgstr "" + msgid "Project ENS name" msgstr "" @@ -3446,6 +3515,9 @@ msgstr "" msgid "After {0} (your first cycle), your project will not issue any tokens unless you edit the issuance rate." msgstr "" +msgid "In other words: instead of taking effect immediately, those edits will take effect when the next ruleset starts (Ruleset #2). If you need more flexibility, switch to unlocked rulesets." +msgstr "" + msgid "New NFTs will available on your project page shortly." msgstr "" @@ -3560,6 +3632,9 @@ msgstr "" msgid "Redeem {tokensLabel} for ETH" msgstr "" +msgid "Locked Rulesets" +msgstr "" + msgid "Made a mistake?" msgstr "" @@ -4223,6 +4298,9 @@ msgstr "" msgid "The maximum supply of this NFT in circulation." msgstr "" +msgid "Each ruleset, the project will issue {discountRate}% fewer tokens per ETH." +msgstr "" + msgid "Payout and reserved token recipients cannot exceed 100%" msgstr "" @@ -4301,6 +4379,9 @@ msgstr "" msgid "End" msgstr "" +msgid "The issuance reduction rate is disabled if you are using unlocked rulesets (because they have no duration)." +msgstr "" + msgid "Message sent!" msgstr "" diff --git a/src/packages/v2v3/components/Create/hooks/DeployProject/useDeployProject.ts b/src/packages/v2v3/components/Create/hooks/DeployProject/useDeployProject.ts index cb09e58ba1..fa363ac619 100644 --- a/src/packages/v2v3/components/Create/hooks/DeployProject/useDeployProject.ts +++ b/src/packages/v2v3/components/Create/hooks/DeployProject/useDeployProject.ts @@ -56,7 +56,7 @@ const getProjectIdFromLaunchReceipt = ( * Attempt to find the transaction receipt from a transaction hash. * Will retry up to 5 times with a 2 second delay between each attempt. If no - * receipt is found after 5 attempts, undefined is returned. + * receipt is not found after 5 attempts, undefined is returned. * * @param txHash transaction hash * @returns transaction receipt or undefined diff --git a/src/packages/v2v3/components/Create/hooks/useLoadInitialStateFromQuery.ts b/src/packages/v2v3/components/Create/hooks/useLoadInitialStateFromQuery.ts index ae6e947017..a2d3097086 100644 --- a/src/packages/v2v3/components/Create/hooks/useLoadInitialStateFromQuery.ts +++ b/src/packages/v2v3/components/Create/hooks/useLoadInitialStateFromQuery.ts @@ -1,10 +1,10 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import isEqual from 'lodash/isEqual' import { CreatePage } from 'models/createPage' import { ProjectTokensSelection } from 'models/projectTokenSelection' import { TreasurySelection } from 'models/treasurySelection' import { useRouter } from 'next/router' import { ballotStrategiesFn } from 'packages/v2v3/constants/ballotStrategies' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { useDefaultJBETHPaymentTerminal } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBETHPaymentTerminal' import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' import { useEffect, useState } from 'react' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts index e5a7cb07bc..d2a9075db0 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts @@ -1,9 +1,9 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { ETH_PAYOUT_SPLIT_GROUP, RESERVED_TOKEN_SPLIT_GROUP, } from 'constants/splits' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' -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' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' 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 54a83857fc..4153ce30b5 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,5 +1,5 @@ import { BigNumber } from '@ethersproject/bignumber' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { V2V3FundAccessConstraint, diff --git a/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts b/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts index c28f7ef2e0..cbbeba406d 100644 --- a/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts +++ b/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts @@ -20,7 +20,7 @@ import { useJBPrices } from 'packages/v2v3/hooks/JBPrices' import { DEFAULT_JB_721_DELEGATE_VERSION } from 'packages/v2v3/hooks/defaultContracts/useDefaultJB721Delegate' import { useDefaultJBController } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBController' import { useDefaultJBETHPaymentTerminal } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBETHPaymentTerminal' -import { LaunchProjectData } from 'packages/v2v3/hooks/transactor/useLaunchProjectTx' +import { LaunchV2V3ProjectData } from 'packages/v2v3/hooks/transactor/useLaunchProjectTx' import { useV2ProjectTitle } from 'packages/v2v3/hooks/useProjectTitle' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' import { @@ -61,7 +61,7 @@ interface JB721DelegateLaunchFundingCycleData { interface LaunchProjectWithNftsTxArgs { tiered721DelegateData: DeployTiered721DelegateData - projectData: LaunchProjectData + projectData: LaunchV2V3ProjectData } type JB721DelegateLaunchProjectData = JB721DelegateLaunchFundingCycleData & { diff --git a/src/packages/v2v3/hooks/contractReader/useProjectDistributionLimit.ts b/src/packages/v2v3/hooks/contractReader/useProjectDistributionLimit.ts index 8654993b24..2bba1d8a25 100644 --- a/src/packages/v2v3/hooks/contractReader/useProjectDistributionLimit.ts +++ b/src/packages/v2v3/hooks/contractReader/useProjectDistributionLimit.ts @@ -1,5 +1,5 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { BigNumber } from 'ethers' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { V2V3ContractName } from 'packages/v2v3/models/contracts' import { useContext } from 'react' diff --git a/src/packages/v2v3/hooks/contractReader/useProjectPrimaryEthTerminalAddress.ts b/src/packages/v2v3/hooks/contractReader/useProjectPrimaryEthTerminalAddress.ts index 000b2d67e9..93f34c7b29 100644 --- a/src/packages/v2v3/hooks/contractReader/useProjectPrimaryEthTerminalAddress.ts +++ b/src/packages/v2v3/hooks/contractReader/useProjectPrimaryEthTerminalAddress.ts @@ -1,4 +1,4 @@ -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { V2V3ContractName } from 'packages/v2v3/models/contracts' import useV2ContractReader from './useV2ContractReader' diff --git a/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3.ts b/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3.ts index 9fd936041a..7b597cf2ca 100644 --- a/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3.ts +++ b/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3.ts @@ -1,6 +1,6 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { DEFAULT_MEMO, DEFAULT_METADATA } from 'constants/transactionDefaults' import { BigNumber } from 'ethers' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' export function getAddToBalanceArgsV3({ projectId, diff --git a/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3_1.ts b/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3_1.ts index 06f2c558c8..bf643fad7f 100644 --- a/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3_1.ts +++ b/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3_1.ts @@ -1,6 +1,6 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { DEFAULT_MEMO, DEFAULT_METADATA } from 'constants/transactionDefaults' import { BigNumber } from 'ethers' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' export function getAddToBalanceArgsV3_1({ projectId, diff --git a/src/packages/v2v3/hooks/transactor/useDistributePayouts.ts b/src/packages/v2v3/hooks/transactor/useDistributePayouts.ts index 1c299b93e1..250fac7d28 100644 --- a/src/packages/v2v3/hooks/transactor/useDistributePayouts.ts +++ b/src/packages/v2v3/hooks/transactor/useDistributePayouts.ts @@ -1,4 +1,5 @@ import { t } from '@lingui/macro' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { DEFAULT_MEMO, DEFAULT_METADATA, @@ -8,7 +9,6 @@ import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { TransactionContext } from 'contexts/Transaction/TransactionContext' import { BigNumber } from 'ethers' import { TransactorInstance } from 'hooks/useTransactor' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { PaymentTerminalVersion, diff --git a/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts b/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts index 4a427fc026..83384ddcf6 100644 --- a/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts +++ b/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts @@ -21,7 +21,7 @@ import { useContext } from 'react' import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' import { useV2ProjectTitle } from '../useProjectTitle' -export interface LaunchProjectData { +export interface LaunchV2V3ProjectData { projectMetadataCID: string fundingCycleData: V2V3FundingCycleData fundingCycleMetadata: V2V3FundingCycleMetadata @@ -31,7 +31,7 @@ export interface LaunchProjectData { owner?: string // If not provided, the current user's address will be used. } -export function useLaunchProjectTx(): TransactorInstance { +export function useLaunchProjectTx(): TransactorInstance { const { transactor } = useContext(TransactionContext) const { contracts } = useContext(V2V3ContractsContext) const defaultJBController = useDefaultJBController() diff --git a/src/packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx.ts b/src/packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx.ts index e31c3b08b7..94ac58f052 100644 --- a/src/packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx.ts +++ b/src/packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx.ts @@ -1,11 +1,11 @@ import { t } from '@lingui/macro' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { DEFAULT_MIN_RETURNED_TOKENS } from 'constants/transactionDefaults' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { TransactionContext } from 'contexts/Transaction/TransactionContext' import { BigNumber } from 'ethers' import { TransactorInstance } from 'hooks/useTransactor' import { useProjectIsOFACListed } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectIsOFACListed' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { useContext } from 'react' import { useV2V3BlockedProject } from '../useBlockedProject' diff --git a/src/packages/v2v3/hooks/transactor/useReconfigureV2V3FundingCycleTx.ts b/src/packages/v2v3/hooks/transactor/useReconfigureV2V3FundingCycleTx.ts index 53f1cec29c..ca4b0c94ac 100644 --- a/src/packages/v2v3/hooks/transactor/useReconfigureV2V3FundingCycleTx.ts +++ b/src/packages/v2v3/hooks/transactor/useReconfigureV2V3FundingCycleTx.ts @@ -8,10 +8,10 @@ import { isValidMustStartAtOrAfter } from 'packages/v2v3/utils/fundingCycle' import { useContext } from 'react' import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' import { useV2ProjectTitle } from '../useProjectTitle' -import { LaunchProjectData } from './useLaunchProjectTx' +import { LaunchV2V3ProjectData } from './useLaunchProjectTx' export type ReconfigureFundingCycleTxParams = Omit< - LaunchProjectData, + LaunchV2V3ProjectData, 'projectMetadataCID' > & { memo?: string diff --git a/src/packages/v4/components/Create/Create.tsx b/src/packages/v4/components/Create/Create.tsx index 6e67c424ec..2657e4b7c6 100644 --- a/src/packages/v4/components/Create/Create.tsx +++ b/src/packages/v4/components/Create/Create.tsx @@ -1,15 +1,14 @@ import { t, Trans } from '@lingui/macro' import { DeployButtonText } from 'components/buttons/DeployProjectButtonText' +import Loading from 'components/Loading' import { - CYCLE_EXPLANATION, RECONFIG_RULES_EXPLANATION, + RULESET_EXPLANATION, } from 'components/strings' import { readNetwork } from 'constants/networks' import { NetworkName } from 'models/networkName' import { useRouter } from 'next/router' -import { CreateBadge } from './components/CreateBadge' import { FundingCyclesPage } from './components/pages/FundingCycles/FundingCyclesPage' -import { NftRewardsPage } from './components/pages/NftRewards/NftRewardsPage' import { PayoutsPage } from './components/pages/PayoutsPage/PayoutsPage' import { ProjectDetailsPage } from './components/pages/ProjectDetails/ProjectDetailsPage' import { ProjectTokenPage } from './components/pages/ProjectToken/ProjectTokenPage' @@ -24,7 +23,7 @@ export function Create() { const deployedProjectId = router.query.deployedProjectId as string const initialStateLoading = useLoadingInitialStateFromQuery() - if (initialStateLoading) return <>XX// + if (initialStateLoading) return if (deployedProjectId) { const projectId = parseInt(deployedProjectId) @@ -48,8 +47,8 @@ export function Create() { @@ -60,8 +59,8 @@ export function Create() { Pay out ETH from your project to any Ethereum wallet or Juicebox project. ETH which isn't paid out will be available for - token redemptions, or for use in future cycles. Payouts reset - each cycle. + token redemptions, or for use in future rulesets. Payouts reset + each ruleset. } > @@ -82,7 +81,7 @@ export function Create() { > - @@ -95,7 +94,7 @@ export function Create() { } > - + */} Edit Deadline} diff --git a/src/packages/v4/components/Create/components/Wizard/hooks/useSteps.ts b/src/packages/v4/components/Create/components/Wizard/hooks/useSteps.ts index 1947d1ff9c..2c58a52f47 100644 --- a/src/packages/v4/components/Create/components/Wizard/hooks/useSteps.ts +++ b/src/packages/v4/components/Create/components/Wizard/hooks/useSteps.ts @@ -8,10 +8,10 @@ import { WizardContext } from '../contexts/WizardContext' const stepNames = (): Record => { return { projectDetails: t`Details`, - fundingCycles: t`Cycles`, + fundingCycles: t`Rulesets`, payouts: t`Payouts`, projectToken: t`Token`, - nftRewards: t`NFTs`, + // nftRewards: t`NFTs`, reconfigurationRules: t`Deadline`, reviewDeploy: t`Deploy`, } diff --git a/src/packages/v4/components/Create/components/pages/FundingCycles/FundingCyclesPage.tsx b/src/packages/v4/components/Create/components/pages/FundingCycles/FundingCyclesPage.tsx index 7caef86242..0bda8587e0 100644 --- a/src/packages/v4/components/Create/components/pages/FundingCycles/FundingCyclesPage.tsx +++ b/src/packages/v4/components/Create/components/pages/FundingCycles/FundingCyclesPage.tsx @@ -41,16 +41,16 @@ const FundingCycleCallout: React.FC> = () => {

- Cycle #1 starts when you create your project. With locked cycles, - if you edit your project's rules during Cycle #1, those edits will - be queued for the next cycle. + Ruleset #1 starts when you create your project. With locked rulesets, + if you edit your project's rules during Ruleset #1, those edits will + be queued for the next ruleset.

In other words: instead of taking effect immediately, those edits - will take effect when the next cycle starts (Cycle #2). If you - need more flexibility, switch to unlocked cycles. + will take effect when the next ruleset starts (Ruleset #2). If you + need more flexibility, switch to unlocked rulesets.

@@ -59,10 +59,10 @@ const FundingCycleCallout: React.FC> = () => { return ( - Cycle #1 starts when you create your project. With unlocked cycles, + Ruleset #1 starts when you create your project. With unlocked rulesets, you can edit your project's rules at any time. This gives you more flexibility, but may appear risky to supporters. Switching to locked - cycles will help you build supporter confidence. + rulesets will help you build supporter confidence. ) @@ -117,13 +117,13 @@ export const FundingCyclesPage = () => { name="automated" title={
- Locked Cycles{' '} + Locked Rulesets{' '}

- With Locked Cycles, your project's rules are + With Locked Rulesets, your project's rules are locked for a period of time.

@@ -137,20 +137,20 @@ export const FundingCyclesPage = () => { />

} - description={t`Set a duration for locked cycles.`} + description={t`Set a duration for locked rulesets.`} icon={} > Your project's rules cannot be - edited during the first cycle. + edited during the first ruleset. } rules={lockPageRulesWrapper([ - durationMustExistRule({ label: t`Cycle duration` }), + durationMustExistRule({ label: t`Ruleset duration` }), ])} > @@ -158,8 +158,8 @@ export const FundingCyclesPage = () => {
} /> @@ -183,14 +183,14 @@ export const FundingCyclesPage = () => { Set a future date & time to start your project's first - cycle. + ruleset. } extra={ launchDate ? ( - Your project's first cycle will start on{' '} + Your project's first ruleset will start on{' '} { . Your project will be visible on{' '} juicebox.money once you finish setting your project up, but supporters won't be able to - pay or interact with it until the first cycle begins. + pay or interact with it until the first ruleset begins. ) : ( - Leave this blank to start your first cycle immediately + Leave this blank to start your first ruleset immediately after you finish setting up your project. ) diff --git a/src/packages/v4/components/Create/components/pages/FundingCycles/hooks/useFundingCyclesForm.ts b/src/packages/v4/components/Create/components/pages/FundingCycles/hooks/useFundingCyclesForm.ts index 31f668ec79..50593d7694 100644 --- a/src/packages/v4/components/Create/components/pages/FundingCycles/hooks/useFundingCyclesForm.ts +++ b/src/packages/v4/components/Create/components/pages/FundingCycles/hooks/useFundingCyclesForm.ts @@ -35,7 +35,7 @@ export const useFundingCyclesForm = () => { : undefined if (!fundingCycleData.duration?.length || selection !== 'automated') { - // Return default values if the user hasn't selected a funding cycle type yet. + // Return default values if the user hasn't selected a funding ruleset type yet. return { duration: { duration: 14, unit: 'days' }, selection, launchDate } } diff --git a/src/packages/v4/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx index 0389eae6df..b595c6d095 100644 --- a/src/packages/v4/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx +++ b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/TreasuryOptionsRadio.tsx @@ -45,11 +45,11 @@ export function TreasuryOptionsRadio() { const calloutText = useMemo(() => { switch (treasuryOption) { case 'amount': - return t`A fixed amount of ETH can be paid out from your project each cycle. You can send specific ETH amounts (or ETH amounts based on USD values) to one or more recipients. Any remaining ETH will stay in your project for token redemptions or use in future cycles.` + return t`A fixed amount of ETH can be paid out from your project each ruleset. You can send specific ETH amounts (or ETH amounts based on USD values) to one or more recipients. Any remaining ETH will stay in your project for token redemptions or use in future rulesets.` case 'unlimited': return t`All of your project's ETH can be paid out at any time. You can send percentages of that ETH to one or more recipients.` case 'zero': - return t`None of your project's ETH can be paid out. All ETH will stay in your project for token redemptions or use in future cycles.` + return t`None of your project's ETH can be paid out. All ETH will stay in your project for token redemptions or use in future rulesets.` } }, [treasuryOption]) diff --git a/src/packages/v4/components/Create/components/pages/ProjectToken/ProjectTokenPage.tsx b/src/packages/v4/components/Create/components/pages/ProjectToken/ProjectTokenPage.tsx index f28ba94cac..1201c3c2eb 100644 --- a/src/packages/v4/components/Create/components/pages/ProjectToken/ProjectTokenPage.tsx +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/ProjectTokenPage.tsx @@ -73,7 +73,7 @@ export const ProjectTokenPage: React.FC< description={ Simple token rules that will work for most projects. You can - edit these rules in future cycles. + edit these rules in future rulesets. } > diff --git a/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx b/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx index 1a245d56ef..c3489e99c5 100644 --- a/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx @@ -88,7 +88,7 @@ export const CustomTokenSettings = () => { - +
Set aside a percentage of token issuance for the wallets and @@ -128,13 +128,15 @@ export const CustomTokenSettings = () => { - +
- - The issuance rate is reduced by this percentage every cycle (every{' '} - {formatFundingCycleDuration(duration)}). The higher this rate, the - more incentive to pay this project earlier. - + + + The issuance rate is reduced by this percentage every ruleset (every{' '} + {formatFundingCycleDuration(duration)}). The higher this rate, the + more incentive to pay this project earlier. + + { The issuance reduction rate is disabled if you are using - unlocked cycles (because they have no duration). + unlocked rulesets (because they have no duration). ) : ( @@ -168,22 +170,22 @@ export const CustomTokenSettings = () => { ) : discountRate === 100 ? ( After {formatFundingCycleDuration(duration)} (your first - cycle), your project will not issue any tokens unless you edit + ruleset), your project will not issue any tokens unless you edit the issuance rate. ) : ( <>

- Each cycle, the project will issue {discountRate}% fewer + Each ruleset, the project will issue {discountRate}% fewer tokens per ETH.{' '}

- Next cycle, the project will issue{' '} + Next ruleset, the project will issue{' '} {formatAmount(secondFundingCycleMintRate)} tokens per 1 - ETH. The cycle after that, the project will issue{' '} + ETH. The ruleset after that, the project will issue{' '} {formatAmount(thirdFundingCycleMintRate)} tokens per 1 ETH. diff --git a/src/packages/v4/components/Create/components/pages/ProjectToken/components/DefaultSettings.tsx b/src/packages/v4/components/Create/components/pages/ProjectToken/components/DefaultSettings.tsx index ac3008a5e6..9aafd3154e 100644 --- a/src/packages/v4/components/Create/components/pages/ProjectToken/components/DefaultSettings.tsx +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/components/DefaultSettings.tsx @@ -24,11 +24,11 @@ export const DefaultSettings: React.FC< )} tokens / ETH`, tooltip: MINT_RATE_EXPLANATION, }, - [t`Reserved rate`]: { + [t`Reserved percent`]: { data: `${ProjectTokenForm.DefaultSettings.reservedTokensPercentage}%`, tooltip: RESERVED_RATE_EXPLANATION, }, - [t`Issuance reduction rate`]: { + [t`Decay percent`]: { data: `${ProjectTokenForm.DefaultSettings.discountRate}%`, tooltip: DISCOUNT_RATE_EXPLANATION, }, diff --git a/src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx index bf24fbeb3a..2ca930ce45 100644 --- a/src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx +++ b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx @@ -5,12 +5,11 @@ import { Callout } from 'components/Callout/Callout' import { JuiceSwitch } from 'components/inputs/JuiceSwitch' import { CONTROLLER_CONFIG_EXPLANATION, - CONTROLLER_MIGRATION_EXPLANATION, HOLD_FEES_EXPLANATION, PAUSE_PAYMENTS_EXPLANATION, RECONFIG_RULES_WARN, TERMINAL_CONFIG_EXPLANATION, - TERMINAL_MIGRATION_EXPLANATION, + TERMINAL_MIGRATION_EXPLANATION } from 'components/strings' import { CREATE_FLOW } from 'constants/fathomEvents' import { FEATURE_FLAGS } from 'constants/featureFlags' @@ -125,12 +124,6 @@ export const ReconfigurationRulesPage = () => { > - - -

diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx index 12b411967e..17d5d5554e 100644 --- a/src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx @@ -10,13 +10,13 @@ import { emitConfirmationDeletionModal } from 'hooks/emitConfirmationDeletionMod import useMobile from 'hooks/useMobile' import { useModal } from 'hooks/useModal' import { useRouter } from 'next/router' -import { useDeployProject } from 'packages/v2v3/components/Create/hooks/DeployProject/useDeployProject' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useDispatch } from 'react-redux' import { useAppSelector } from 'redux/hooks/useAppSelector' import { useSetCreateFurthestPageReached } from 'redux/hooks/useEditingCreateFurthestPageReached' import { editingV2ProjectActions } from 'redux/slices/editingV2Project' import { helpPagePath } from 'utils/helpPagePath' +import { useDeployProject } from '../../../hooks/DeployProject/useDeployProject' import { CreateBadge } from '../../CreateBadge' import { CreateCollapse } from '../../CreateCollapse/CreateCollapse' import { Wizard } from '../../Wizard/Wizard' @@ -24,7 +24,6 @@ import { WizardContext } from '../../Wizard/contexts/WizardContext' import { FundingConfigurationReview } from './components/FundingConfigurationReview/FundingConfigurationReview' import { ProjectDetailsReview } from './components/ProjectDetailsReview/ProjectDetailsReview' import { ProjectTokenReview } from './components/ProjectTokenReview/ProjectTokenReview' -import { RewardsReview } from './components/RewardsReview/RewardsReview' import { RulesReview } from './components/RulesReview/RulesReview' enum ReviewDeployKey { @@ -93,7 +92,7 @@ export const ReviewDeployPage = () => { transactionModal.open() await deployProject({ - onProjectDeployed: deployedProjectId => { + onProjectDeployed: (deployedProjectId: number) => { router.push({ query: { deployedProjectId } }, '/create', { shallow: true, }) @@ -149,7 +148,7 @@ export const ReviewDeployPage = () => { key={ReviewDeployKey.FundingConfiguration} header={
- Cycles & Payouts + Rulesets & Payouts
} > @@ -165,7 +164,7 @@ export const ReviewDeployPage = () => { > - { } > - + */} { console.info('Deploy: SUCCESS', projectId) const router = useRouter() const { chain } = useWallet() + const chainId = useChainId() let deployGreeting = t`Your project was successfully created!` if (chain?.name) { deployGreeting = t`Your project was successfully created on ${chain.name}!` } + const projectRoute = v4ProjectRoute({ projectId, chainId }) + const [gotoProjectClicked, setGotoProjectClicked] = useState(false) /** @@ -45,10 +50,10 @@ export const DeploySuccess = ({ projectId }: { projectId: number }) => { const handleGoToProject = useCallback(() => { setGotoProjectClicked(true) router.push( - `/v2/p/${projectId}?${NEW_DEPLOY_QUERY_PARAM}=1`, - `/v2/p/${projectId}`, + `${projectRoute}?${NEW_DEPLOY_QUERY_PARAM}=1`, + projectRoute, ) - }, [projectId, router]) + }, [router, projectRoute]) return (
diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx index b3ed62e039..40a6578c03 100644 --- a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx @@ -12,7 +12,7 @@ export const FundingConfigurationReview = () => { <>
{fundingCycles}
} /> { } /> {formatReservedRate( @@ -60,7 +60,7 @@ export const ProjectTokenReview = () => { } /> {formatDiscountRate( diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts index a3dc8d3be4..2ec6c7a771 100644 --- a/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts @@ -1,5 +1,4 @@ -import { TransactionCallbacks } from 'models/transaction' -import { useLaunchProjectTx } from 'packages/v2v3/hooks/transactor/useLaunchProjectTx' +import { LaunchTxOpts, useLaunchProjectTx } from 'packages/v4/hooks/useLaunchProjectTx' import { useCallback } from 'react' import { useAppSelector, @@ -9,14 +8,13 @@ import { } from 'redux/hooks/useAppSelector' /** - * Hook that returns a function that deploys a project. + * Hook that returns a function that deploys a v4 project. * - * The distinction is made between standard and NFT projects because the NFT - * project contract uses more gas. + * Takes data from the redux store built for v2v3 projects, data is converted to v4 format in useLaunchProjectTx. * @returns A function that deploys a project. */ export const useDeployStandardProject = () => { - const launchProject = useLaunchProjectTx() + const launchProjectTx = useLaunchProjectTx() const { payoutGroupedSplits, reservedTokensGroupedSplits, @@ -30,18 +28,14 @@ export const useDeployStandardProject = () => { const deployStandardProjectCallback = useCallback( async ({ metadataCid, - - onDone, - onConfirmed, - onCancelled, + onTransactionPending, + onTransactionConfirmed, + onTransactionError }: { metadataCid: string - } & Pick< - TransactionCallbacks, - 'onCancelled' | 'onConfirmed' | 'onDone' - >) => { + } & LaunchTxOpts) => { const groupedSplits = [payoutGroupedSplits, reservedTokensGroupedSplits] - return await launchProject( + return await launchProjectTx( { owner: inputProjectOwner?.length ? inputProjectOwner : undefined, projectMetadataCID: metadataCid, @@ -52,16 +46,16 @@ export const useDeployStandardProject = () => { groupedSplits, }, { - onDone, - onConfirmed, - onCancelled, + onTransactionPending, + onTransactionConfirmed, + onTransactionError }, ) }, [ payoutGroupedSplits, reservedTokensGroupedSplits, - launchProject, + launchProjectTx, inputProjectOwner, fundingCycleData, fundingCycleMetadata, diff --git a/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts index cb09e58ba1..a0de85a48a 100644 --- a/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts +++ b/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts @@ -1,7 +1,5 @@ -import { readProvider } from 'constants/readProvider' -import { BigNumber, providers } from 'ethers' import { uploadProjectMetadata } from 'lib/api/ipfs' -import { TransactionCallbacks } from 'models/transaction' +import { LaunchTxOpts } from 'packages/v4/hooks/useLaunchProjectTx' import { useCallback, useState } from 'react' import { useAppDispatch } from 'redux/hooks/useAppDispatch' import { @@ -12,70 +10,9 @@ import { } from 'redux/hooks/useAppSelector' import { editingV2ProjectActions } from 'redux/slices/editingV2Project' import { emitErrorNotification } from 'utils/notifications' -import { useDeployNftProject } from './hooks/NFT/useDeployNftProject' -import { useIsNftProject } from './hooks/NFT/useIsNftProject' -import { useUploadNftRewards } from './hooks/NFT/useUploadNftRewards' import { useDeployStandardProject } from './hooks/useDeployStandardProject' -const CREATE_EVENT_IDX = 2 -const NFT_CREATE_EVENT_IDX = 3 -const PROJECT_ID_TOPIC_IDX = 1 - const JUICEBOX_DOMAIN = 'juicebox' -const JUICECROWD_DOMAIN = 'juicecrowd' - -/** - * Return the project ID created from a `launchProjectFor` transaction. - * @param txReceipt receipt of `launchProjectFor` transaction - */ -const getProjectIdFromNftLaunchReceipt = ( - txReceipt: providers.TransactionReceipt, -): number => { - const projectIdHex: unknown | undefined = - txReceipt?.logs[NFT_CREATE_EVENT_IDX]?.topics?.[PROJECT_ID_TOPIC_IDX] - const projectId = BigNumber.from(projectIdHex).toNumber() - - return projectId -} - -/** - * Return the project ID created from a `launchProjectFor` transaction. - * @param txReceipt receipt of `launchProjectFor` transaction - */ -const getProjectIdFromLaunchReceipt = ( - txReceipt: providers.TransactionReceipt, -): number => { - const projectIdHex: unknown | undefined = - txReceipt?.logs[CREATE_EVENT_IDX]?.topics?.[PROJECT_ID_TOPIC_IDX] - const projectId = BigNumber.from(projectIdHex).toNumber() - - return projectId -} - -/** - * Attempt to find the transaction receipt from a transaction hash. - - * Will retry up to 5 times with a 2 second delay between each attempt. If no - * receipt is found after 5 attempts, undefined is returned. - * - * @param txHash transaction hash - * @returns transaction receipt or undefined - */ -const findTransactionReceipt = async (txHash: string) => { - let retries = 5 - let receipt - while (retries > 0 && !receipt) { - receipt = await readProvider.getTransactionReceipt(txHash) - if (receipt) break - - retries -= 1 - // wait 2s - await new Promise(r => setTimeout(r, 2000)) - console.info('Retrying tx receipt lookup...') - } - - return receipt -} /** * Hook that returns a function that deploys a project. @@ -85,9 +22,9 @@ export const useDeployProject = () => { const [isDeploying, setIsDeploying] = useState(false) const [transactionPending, setTransactionPending] = useState() - const isNftProject = useIsNftProject() - const uploadNftRewards = useUploadNftRewards() - const deployNftProject = useDeployNftProject() + // const isNftProject = useIsNftProject() + // const uploadNftRewards = useUploadNftRewards() + // const deployNftProject = useDeployNftProject() const deployStandardProject = useDeployStandardProject() @@ -111,46 +48,22 @@ export const useDeployProject = () => { const operationCallbacks = useCallback( ( onProjectDeployed?: (projectId: number) => void, - ): Pick< - TransactionCallbacks, - 'onCancelled' | 'onConfirmed' | 'onDone' | 'onError' - > => ({ - onDone: () => { + ): LaunchTxOpts => ({ + onTransactionPending: () => { console.info('Project transaction executed. Await confirmation...') setTransactionPending(true) }, - onConfirmed: async result => { - const hash = result?.hash - if (!hash) { - return // TODO error notification - } - const txReceipt = await findTransactionReceipt(hash) - if (!txReceipt) { - return // TODO error notification - } - - const projectId = isNftProject - ? getProjectIdFromNftLaunchReceipt(txReceipt) - : getProjectIdFromLaunchReceipt(txReceipt) - - if (projectId === undefined) { - return // TODO error notification - } - + onTransactionConfirmed: async (hash, projectId) => { // Reset the project state dispatch(editingV2ProjectActions.resetState()) onProjectDeployed?.(projectId) }, - onError: error => { + onTransactionError: error => { console.error(error) emitErrorNotification(`Error deploying project: ${error}`) }, - onCancelled: () => { - setIsDeploying(false) - setTransactionPending(false) - }, }), - [dispatch, isNftProject], + [dispatch], ) /** @@ -176,15 +89,15 @@ export const useDeployProject = () => { setIsDeploying(false) throw new Error('Error deploying project.') } - let nftCids: Awaited> | undefined - try { - if (isNftProject) { - nftCids = await uploadNftRewards() - } - } catch (error) { - handleDeployFailure(error) - return - } + // let nftCids: Awaited> | undefined + // try { + // if (isNftProject) { + // nftCids = await uploadNftRewards() + // } + // } catch (error) { + // handleDeployFailure(error) + // return + // } let softTargetAmount: string | undefined let softTargetCurrency: string | undefined @@ -207,42 +120,42 @@ export const useDeployProject = () => { } try { - let tx - if (isNftProject) { - tx = await deployNftProject({ - metadataCid: projectMetadataCid, - rewardTierCids: nftCids!.rewardTiers, - nftCollectionMetadataUri: nftCids!.nfCollectionMetadata, - ...operationCallbacks(onProjectDeployed), - }) - } else { - tx = await deployStandardProject({ - metadataCid: projectMetadataCid, - ...operationCallbacks(onProjectDeployed), - }) - } - if (!tx) { - setIsDeploying(false) - setTransactionPending(false) - return - } + // let tx + // if (isNftProject) { + // tx = await deployNftProject({ + // metadataCid: projectMetadataCid, + // rewardTierCids: nftCids!.rewardTiers, + // nftCollectionMetadataUri: nftCids!.nfCollectionMetadata, + // ...operationCallbacks(onProjectDeployed), + // }) + // } else { + const tx = await deployStandardProject({ + metadataCid: projectMetadataCid, + ...operationCallbacks(onProjectDeployed), + }) + // } + // if (!tx) { + setIsDeploying(false) + setTransactionPending(false) + return + // } } catch (error) { handleDeployFailure(error) return } }, [ - deployNftProject, + // deployNftProject, deployStandardProject, fundAccessConstraints, fundingCycleData, fundingCycleMetadata, handleDeployFailure, - isNftProject, + // isNftProject, operationCallbacks, postPayModal, projectMetadata, - uploadNftRewards, + // uploadNftRewards, ], ) return { diff --git a/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts index 1b917f4b2d..c437b7a4fd 100644 --- a/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts +++ b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts @@ -1,3 +1,4 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { useJBContractContext } from 'juice-sdk-react' import isEqual from 'lodash/isEqual' import { CreatePage } from 'models/createPage' @@ -5,7 +6,6 @@ import { ProjectTokensSelection } from 'models/projectTokenSelection' import { TreasurySelection } from 'models/treasurySelection' import { useRouter } from 'next/router' import { ballotStrategiesFn } from 'packages/v2v3/constants/ballotStrategies' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' import { useEffect, useState } from 'react' import { useDispatch } from 'react-redux' diff --git a/src/packages/v4/hooks/useLaunchProjectTx.ts b/src/packages/v4/hooks/useLaunchProjectTx.ts new file mode 100644 index 0000000000..703340c40c --- /dev/null +++ b/src/packages/v4/hooks/useLaunchProjectTx.ts @@ -0,0 +1,140 @@ +import { waitForTransactionReceipt } from '@wagmi/core' +import { JUICEBOX_MONEY_PROJECT_METADATA_DOMAIN } from 'constants/metadataDomain' +import { DEFAULT_MEMO } from 'constants/transactionDefaults' +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { NATIVE_TOKEN } from 'juice-sdk-core' +import { useJBContractContext, useWriteJbControllerLaunchProjectFor } from 'juice-sdk-react' +import { LaunchV2V3ProjectData } from 'packages/v2v3/hooks/transactor/useLaunchProjectTx' +import { useCallback, useContext } from 'react' +import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' +import { WaitForTransactionReceiptReturnType } from 'viem' +import { LaunchV2V3ProjectArgs, transformV2V3CreateArgsToV4 } from '../utils/launchProject' +import { wagmiConfig } from '../wagmiConfig' + +const CREATE_EVENT_IDX = 2 +const PROJECT_ID_TOPIC_IDX = 1 +const HEX_BASE = 16 + +export interface LaunchTxOpts { + onTransactionPending: (hash: `0x${string}`) => void + onTransactionConfirmed: (hash: `0x${string}`, projectId: number) => void + onTransactionError: (error: Error) => void +} + +/** + * Return the project ID created from a `launchProjectFor` transaction. + * @param txReceipt receipt of `launchProjectFor` transaction + */ +const getProjectIdFromLaunchReceipt = ( + txReceipt: WaitForTransactionReceiptReturnType, +): number => { + const projectIdHex: string | undefined = + txReceipt?.logs[CREATE_EVENT_IDX]?.topics?.[PROJECT_ID_TOPIC_IDX] + if (!projectIdHex) return 0 + + const projectId = parseInt(projectIdHex, HEX_BASE); + return projectId +} + +/** + * Takes data in V2V3 format, converts it to v4 format and passes it to `writeLaunchProject` + * @returns A function that deploys a project. + */ +export function useLaunchProjectTx() { + const { writeContractAsync: writeLaunchProject } = useWriteJbControllerLaunchProjectFor() + const { contracts } = useJBContractContext() + + const { addTransaction } = useContext(TxHistoryContext) + + const { userAddress } = useWallet() + + return useCallback( + async ({ + owner, + projectMetadataCID, + fundingCycleData, + fundingCycleMetadata, + fundAccessConstraints, + groupedSplits = [], + mustStartAtOrAfter = DEFAULT_MUST_START_AT_OR_AFTER, + }: LaunchV2V3ProjectData, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: LaunchTxOpts + ) => { + if ( + !contracts.controller.data || + !contracts.primaryNativeTerminal.data || + !userAddress + ) { + return + } + + const _owner = owner && owner.length ? owner : userAddress + + const v2v3Args = [ + _owner, + [projectMetadataCID, JUICEBOX_MONEY_PROJECT_METADATA_DOMAIN], + fundingCycleData, + fundingCycleMetadata, + mustStartAtOrAfter, + groupedSplits, + fundAccessConstraints, + [contracts.primaryNativeTerminal.data], // _terminals, just supporting single for now + // Eventually should be something like: + // getTerminalsFromFundAccessConstraints( + // fundAccessConstraints, + // contracts.primaryNativeTerminal.data, + // ), + DEFAULT_MEMO, + ] as LaunchV2V3ProjectArgs + + const args = transformV2V3CreateArgsToV4({ + v2v3Args, + primaryNativeTerminal: contracts.primaryNativeTerminal.data, + tokenAddress: NATIVE_TOKEN + }) + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbControllerAbi, // ABI of the contract + // functionName: 'launchProjectFor', + // args, + // }) + + const hash = await writeLaunchProject({ + address: contracts.controller.data, + args, + }) + + onTransactionPendingCallback(hash) + addTransaction?.('Launch Project', { hash }) + const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( + wagmiConfig, + { + hash, + }, + ) + + const newProjectId = getProjectIdFromLaunchReceipt(transactionReceipt) + + onTransactionConfirmedCallback(hash, newProjectId) + } catch (e) { + onTransactionErrorCallback( + (e as Error) ?? new Error('Transaction failed'), + ) + } + }, + [ + contracts.controller.data, + userAddress, + writeLaunchProject, + contracts.primaryNativeTerminal.data, + addTransaction, + ], + ) +} diff --git a/src/packages/v4/utils/launchProject.ts b/src/packages/v4/utils/launchProject.ts new file mode 100644 index 0000000000..0be03d8a6d --- /dev/null +++ b/src/packages/v4/utils/launchProject.ts @@ -0,0 +1,111 @@ +import round from "lodash/round"; +import { V2FundingCycleMetadata } from "packages/v2/models/fundingCycle"; +import { V2V3FundAccessConstraint, V2V3FundingCycleData } from "packages/v2v3/models/fundingCycle"; +import { GroupedSplits, SplitGroup } from "packages/v2v3/models/splits"; +import { V3FundingCycleMetadata } from "packages/v3/models/fundingCycle"; + +export type LaunchV2V3ProjectArgs = [ + string, // _owner + [string, number], // _projectMetadata [projectMetadataCID, JUICEBOX_MONEY_PROJECT_METADATA_DOMAIN] + V2V3FundingCycleData, // _data + V2FundingCycleMetadata | V3FundingCycleMetadata, // _metadata + string, // _mustStartAtOrAfter + GroupedSplits[], // _groupedSplits + V2V3FundAccessConstraint[], // _fundAccessConstraints + string[], // _terminals + string // _memo +]; + +export function transformV2V3CreateArgsToV4({ + v2v3Args, + primaryNativeTerminal, + tokenAddress +}: { + v2v3Args: LaunchV2V3ProjectArgs, + primaryNativeTerminal: `0x${string}` + tokenAddress: `0x${string}` +}) { + const [ + _owner, + _projectMetadata, + _data, + _metadata, + _mustStartAtOrAfter, + _groupedSplits, + _fundAccessConstraints, + _terminals, + _memo + ] = v2v3Args; + + const mustStartAtOrAfterNum = parseInt(_mustStartAtOrAfter) + const now = round(new Date().getTime() / 1000) + + const rulesetConfigurations = [{ + mustStartAtOrAfter: mustStartAtOrAfterNum > now ? mustStartAtOrAfterNum : now, + duration: _data.duration.toNumber(), + weight: _data.weight.toBigInt(), + decayPercent: _data.discountRate.toNumber(), + approvalHook: _data.ballot as `0x${string}`, + + metadata: { + reservedPercent: _metadata.reservedRate.toNumber(), + redemptionRate: _metadata.redemptionRate.toNumber(), + baseCurrency: 1, // Not present in v2v3, passing 1 by default + pausePay: _metadata.pausePay, + pauseRedeem: _metadata.pauseRedeem, + pauseCreditTransfers: Boolean(_metadata.global.pauseTransfers), + allowOwnerMinting: _metadata.allowMinting, + allowSetCustomToken: false, // Assuming false by default + allowTerminalMigration: _metadata.allowTerminalMigration, + allowSetTerminals: _metadata.global.allowSetTerminals, + allowSetController: _metadata.global.allowSetController, + allowAddAccountingContext: false, // Not present in v2v3, passing false by default + allowAddPriceFeed: false, // Not present in v2v3, passing false by default + ownerMustSendPayouts: false, // Not present in v2v3, passing false by default + holdFees: _metadata.holdFees, + useTotalSurplusForRedemptions: _metadata.useTotalOverflowForRedemptions, + useDataHookForPay: _metadata.useDataSourceForPay, + useDataHookForRedeem: _metadata.useDataSourceForRedeem, + dataHook: _metadata.dataSource as `0x${string}`, + metadata: 0, + }, + + splitGroups: _groupedSplits.map(group => ({ + groupId: BigInt(group.group), + splits: group.splits.map(split => ({ + preferAddToBalance: Boolean(split.preferClaimed), + percent: split.percent, + projectId: BigInt(parseInt(split.projectId ?? '0x00', 16)), + beneficiary: split.beneficiary as `0x${string}`, + lockedUntil: split.lockedUntil ?? 0, + hook: split.allocator as `0x${string}`, + })), + })), + + fundAccessLimitGroups: _fundAccessConstraints.map(constraint => ({ + terminal: primaryNativeTerminal, + token: tokenAddress, + payoutLimits: [{ + amount: constraint.distributionLimit.toBigInt(), + currency: constraint.distributionLimitCurrency.toNumber(), + }] as const, + surplusAllowances: [{ + amount: constraint.overflowAllowance.toBigInt(), + currency: constraint.overflowAllowanceCurrency.toNumber(), + }] as const, + })) + }]; + + const terminalConfigurations = _terminals.map(terminal => ({ + terminal: terminal as `0x${string}`, + accountingContextsToAccept: [] as const, + })); + + return [ + _owner as `0x${string}`, + _projectMetadata[0], + rulesetConfigurations, + terminalConfigurations, + _memo, + ] as const; +} diff --git a/src/redux/hooks/useEditingDistributionLimit.ts b/src/redux/hooks/useEditingDistributionLimit.ts index e5afbd2700..b26a460968 100644 --- a/src/redux/hooks/useEditingDistributionLimit.ts +++ b/src/redux/hooks/useEditingDistributionLimit.ts @@ -1,5 +1,5 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { BigNumber } from 'ethers' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { useDefaultJBETHPaymentTerminal } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBETHPaymentTerminal' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' import { V2V3_CURRENCY_ETH } from 'packages/v2v3/utils/currency' @@ -9,6 +9,7 @@ import { useAppDispatch } from 'redux/hooks/useAppDispatch' import { useAppSelector } from 'redux/hooks/useAppSelector' import { editingV2ProjectActions } from 'redux/slices/editingV2Project' import { fromWad, parseWad } from 'utils/format/formatNumber' +import { zeroAddress } from 'viem' export interface ReduxDistributionLimit { amount: BigNumber @@ -51,7 +52,6 @@ export const useEditingDistributionLimit = (): [ const setDistributionLimit = useCallback( (input: ReduxDistributionLimit | undefined) => { - if (!defaultJBETHPaymentTerminal) return if (!input) { dispatch(editingV2ProjectActions.setFundAccessConstraints([])) return @@ -60,7 +60,7 @@ export const useEditingDistributionLimit = (): [ dispatch( editingV2ProjectActions.setFundAccessConstraints([ { - terminal: defaultJBETHPaymentTerminal?.address, + terminal: defaultJBETHPaymentTerminal?.address ?? zeroAddress, token: ETH_TOKEN_ADDRESS, distributionLimit: fromWad(input.amount), distributionLimitCurrency, @@ -75,10 +75,8 @@ export const useEditingDistributionLimit = (): [ const setDistributionLimitAmount = useCallback( (input: BigNumber) => { - if (!defaultJBETHPaymentTerminal) return - const currentFundAccessConstraint = fundAccessConstraints?.[0] ?? { - terminal: defaultJBETHPaymentTerminal?.address, + terminal: defaultJBETHPaymentTerminal?.address ?? zeroAddress, token: ETH_TOKEN_ADDRESS, distributionLimitCurrency: V2V3_CURRENCY_ETH.toString(), overflowAllowance: '0', @@ -100,12 +98,11 @@ export const useEditingDistributionLimit = (): [ const setDistributionLimitCurrency = useCallback( (input: V2V3CurrencyOption) => { - if (!defaultJBETHPaymentTerminal) return dispatch( editingV2ProjectActions.setDistributionLimitCurrency(input.toString()), ) }, - [defaultJBETHPaymentTerminal, dispatch], + [dispatch], ) return [ From dc790c32503f4b877bc5db7222ca01617f068861 Mon Sep 17 00:00:00 2001 From: johnnyd-eth Date: Fri, 30 Aug 2024 13:35:05 +0800 Subject: [PATCH 3/3] fix: tests --- src/redux/hooks/useEditingDistributionLimit.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/redux/hooks/useEditingDistributionLimit.ts b/src/redux/hooks/useEditingDistributionLimit.ts index b26a460968..8d707517c9 100644 --- a/src/redux/hooks/useEditingDistributionLimit.ts +++ b/src/redux/hooks/useEditingDistributionLimit.ts @@ -1,3 +1,4 @@ +import { AddressZero } from '@ethersproject/constants' import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { BigNumber } from 'ethers' import { useDefaultJBETHPaymentTerminal } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBETHPaymentTerminal' @@ -9,7 +10,6 @@ import { useAppDispatch } from 'redux/hooks/useAppDispatch' import { useAppSelector } from 'redux/hooks/useAppSelector' import { editingV2ProjectActions } from 'redux/slices/editingV2Project' import { fromWad, parseWad } from 'utils/format/formatNumber' -import { zeroAddress } from 'viem' export interface ReduxDistributionLimit { amount: BigNumber @@ -60,7 +60,7 @@ export const useEditingDistributionLimit = (): [ dispatch( editingV2ProjectActions.setFundAccessConstraints([ { - terminal: defaultJBETHPaymentTerminal?.address ?? zeroAddress, + terminal: defaultJBETHPaymentTerminal?.address ?? AddressZero, token: ETH_TOKEN_ADDRESS, distributionLimit: fromWad(input.amount), distributionLimitCurrency, @@ -76,7 +76,7 @@ export const useEditingDistributionLimit = (): [ const setDistributionLimitAmount = useCallback( (input: BigNumber) => { const currentFundAccessConstraint = fundAccessConstraints?.[0] ?? { - terminal: defaultJBETHPaymentTerminal?.address ?? zeroAddress, + terminal: defaultJBETHPaymentTerminal?.address ?? AddressZero, token: ETH_TOKEN_ADDRESS, distributionLimitCurrency: V2V3_CURRENCY_ETH.toString(), overflowAllowance: '0',