From bd533f0d3a74d30265bff406a41ce34ce586cace Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 30 Aug 2024 13:38:40 +0800 Subject: [PATCH 01/44] feat: V4 create behind feature flag [1/n] (#4444) --- 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 | 122 +++++++ .../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 | 256 ++++++++++++++ .../ReservedTokenRateCallout.tsx | 44 +++ .../components/DefaultSettings.tsx | 60 ++++ .../ProjectToken/hooks/useProjectTokenForm.ts | 225 +++++++++++++ .../ReconfigurationRulesPage.tsx | 135 ++++++++ .../components/CustomRuleCard.tsx | 32 ++ .../components/RuleCard.tsx | 41 +++ .../hooks/useReconfigurationRulesForm.ts | 191 +++++++++++ .../pages/ReviewDeploy/ReviewDeployPage.tsx | 256 ++++++++++++++ .../ReviewDeploy/components/DeploySuccess.tsx | 115 +++++++ .../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 | 68 ++++ .../hooks/DeployProject/useDeployProject.ts | 166 +++++++++ .../useAvailableReconfigurationStrategies.ts | 31 ++ .../hooks/useLoadInitialStateFromQuery.ts | 178 ++++++++++ .../Create/hooks/useLockPageRulesWrapper.ts | 36 ++ .../determineAvailablePayoutsSelections.ts | 18 + .../utils/formatFundingCycleDuration.ts | 31 ++ .../projectTokenSettingsToReduxFormat.ts | 40 +++ src/packages/v4/hooks/useLaunchProjectTx.ts | 140 ++++++++ src/packages/v4/utils/launchProject.ts | 111 +++++++ src/pages/create/index.tsx | 48 ++- .../hooks/useEditingDistributionLimit.ts | 13 +- 96 files changed, 6106 insertions(+), 34 deletions(-) rename src/{packages/v2v3 => }/constants/juiceboxTokens.ts (100%) 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 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 new file mode 100644 index 0000000000..2657e4b7c6 --- /dev/null +++ b/src/packages/v4/components/Create/Create.tsx @@ -0,0 +1,122 @@ +import { t, Trans } from '@lingui/macro' +import { DeployButtonText } from 'components/buttons/DeployProjectButtonText' +import Loading from 'components/Loading' +import { + RECONFIG_RULES_EXPLANATION, + RULESET_EXPLANATION, +} from 'components/strings' +import { readNetwork } from 'constants/networks' +import { NetworkName } from 'models/networkName' +import { useRouter } from 'next/router' +import { FundingCyclesPage } from './components/pages/FundingCycles/FundingCyclesPage' +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 + + 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 rulesets. Payouts reset + each ruleset. + + } + > + + + + 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..2c58a52f47 --- /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`Rulesets`, + 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..0bda8587e0 --- /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 ( + +

+ + 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 ruleset starts (Ruleset #2). If you + need more flexibility, switch to unlocked rulesets. + +

+
+ ) + case 'manual': + return ( + + + 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. + + + ) + } +} + +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 Rulesets{' '} + + +

+ With Locked Rulesets, 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 rulesets.`} + icon={} + > + + Your project's rules cannot be + edited during the first ruleset. + + } + rules={lockPageRulesWrapper([ + durationMustExistRule({ label: t`Ruleset duration` }), + ])} + > + + + + } + /> + + + {selection && ( + + + + {launchDate && ( + + )} +
+ } + key={0} + hideDivider + > + + + Set a future date & time to start your project's first + ruleset. + + + } + extra={ + launchDate ? ( + + Your project's first ruleset 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 ruleset begins. + + ) : ( + + Leave this blank to start your first ruleset 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..50593d7694 --- /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 ruleset 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..b595c6d095 --- /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 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 rulesets.` + } + }, [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..1201c3c2eb --- /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 rulesets. + + } + > + + + } + 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..c3489e99c5 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/components/CustomTokenSettings/CustomTokenSettings.tsx @@ -0,0 +1,256 @@ +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 ruleset (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 rulesets (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 + ruleset), your project will not issue any tokens unless you edit + the issuance rate. + + ) : ( + <> +

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

+

+ + Next ruleset, the project will issue{' '} + {formatAmount(secondFundingCycleMintRate)} tokens per 1 + ETH. The ruleset 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..9aafd3154e --- /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 percent`]: { + data: `${ProjectTokenForm.DefaultSettings.reservedTokensPercentage}%`, + tooltip: RESERVED_RATE_EXPLANATION, + }, + [t`Decay percent`]: { + 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..2ca930ce45 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReconfigurationRules/ReconfigurationRulesPage.tsx @@ -0,0 +1,135 @@ +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, + 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..17d5d5554e --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/ReviewDeployPage.tsx @@ -0,0 +1,256 @@ +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 { 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' +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 { 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: number) => { + 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 + + } + > + + + + Rulesets & 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..5adf7380ba --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/DeploySuccess.tsx @@ -0,0 +1,115 @@ +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 { v4ProjectRoute } from 'packages/v4/utils/routes' +import { useCallback, useMemo, useState } from 'react' +import { useChainId } from 'wagmi' +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() + 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) + + /** + * 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( + `${projectRoute}?${NEW_DEPLOY_QUERY_PARAM}=1`, + projectRoute, + ) + }, [router, projectRoute]) + + 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..40a6578c03 --- /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..09ec75dcf9 --- /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..2ec6c7a771 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts @@ -0,0 +1,68 @@ +import { LaunchTxOpts, useLaunchProjectTx } from 'packages/v4/hooks/useLaunchProjectTx' +import { useCallback } from 'react' +import { + useAppSelector, + useEditingV2V3FundAccessConstraintsSelector, + useEditingV2V3FundingCycleDataSelector, + useEditingV2V3FundingCycleMetadataSelector, +} from 'redux/hooks/useAppSelector' + +/** + * Hook that returns a function that deploys a v4 project. + * + * 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 launchProjectTx = useLaunchProjectTx() + const { + payoutGroupedSplits, + reservedTokensGroupedSplits, + inputProjectOwner, + mustStartAtOrAfter, + } = useAppSelector(state => state.editingV2Project) + const fundingCycleMetadata = useEditingV2V3FundingCycleMetadataSelector() + const fundingCycleData = useEditingV2V3FundingCycleDataSelector() + const fundAccessConstraints = useEditingV2V3FundAccessConstraintsSelector() + + const deployStandardProjectCallback = useCallback( + async ({ + metadataCid, + onTransactionPending, + onTransactionConfirmed, + onTransactionError + }: { + metadataCid: string + } & LaunchTxOpts) => { + const groupedSplits = [payoutGroupedSplits, reservedTokensGroupedSplits] + return await launchProjectTx( + { + owner: inputProjectOwner?.length ? inputProjectOwner : undefined, + projectMetadataCID: metadataCid, + fundingCycleData, + fundingCycleMetadata, + mustStartAtOrAfter, + fundAccessConstraints, + groupedSplits, + }, + { + onTransactionPending, + onTransactionConfirmed, + onTransactionError + }, + ) + }, + [ + payoutGroupedSplits, + reservedTokensGroupedSplits, + launchProjectTx, + 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..a0de85a48a --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts @@ -0,0 +1,166 @@ +import { uploadProjectMetadata } from 'lib/api/ipfs' +import { LaunchTxOpts } from 'packages/v4/hooks/useLaunchProjectTx' +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 { useDeployStandardProject } from './hooks/useDeployStandardProject' + +const JUICEBOX_DOMAIN = 'juicebox' + +/** + * 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, + ): LaunchTxOpts => ({ + onTransactionPending: () => { + console.info('Project transaction executed. Await confirmation...') + setTransactionPending(true) + }, + onTransactionConfirmed: async (hash, projectId) => { + // Reset the project state + dispatch(editingV2ProjectActions.resetState()) + onProjectDeployed?.(projectId) + }, + onTransactionError: error => { + console.error(error) + emitErrorNotification(`Error deploying project: ${error}`) + }, + }), + [dispatch], + ) + + /** + * 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 { + const 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..c437b7a4fd --- /dev/null +++ b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts @@ -0,0 +1,178 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' +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 { 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/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/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} diff --git a/src/redux/hooks/useEditingDistributionLimit.ts b/src/redux/hooks/useEditingDistributionLimit.ts index e5afbd2700..8d707517c9 100644 --- a/src/redux/hooks/useEditingDistributionLimit.ts +++ b/src/redux/hooks/useEditingDistributionLimit.ts @@ -1,5 +1,6 @@ +import { AddressZero } from '@ethersproject/constants' +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' @@ -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 ?? AddressZero, 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 ?? AddressZero, 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 20e7c008917a46e8e51db96582e346a35f14d0d0 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:01:49 +1000 Subject: [PATCH 02/44] fix: treasury balance when 0 --- src/packages/v4/components/Create/Create.tsx | 6 ++- .../hooks/useV4TreasuryStats.tsx | 39 ++++++------------- 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/src/packages/v4/components/Create/Create.tsx b/src/packages/v4/components/Create/Create.tsx index 2657e4b7c6..e641cd834c 100644 --- a/src/packages/v4/components/Create/Create.tsx +++ b/src/packages/v4/components/Create/Create.tsx @@ -1,4 +1,5 @@ import { t, Trans } from '@lingui/macro' +import { Badge } from 'components/Badge' import { DeployButtonText } from 'components/buttons/DeployProjectButtonText' import Loading from 'components/Loading' import { @@ -33,7 +34,10 @@ export function Create() { return (

- Create a project + + Create a project + Beta +

{/* TODO: Remove wizard-create once form item css override is replaced */}
diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx index 390ca45ed8..be32cd3419 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx @@ -1,5 +1,9 @@ import { t } from '@lingui/macro' -import { NativeTokenValue, useJBRulesetMetadata, useNativeTokenSurplus } from 'juice-sdk-react' +import { + NativeTokenValue, + useJBRulesetMetadata, + useNativeTokenSurplus, +} from 'juice-sdk-react' import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' import { useV4BalanceOfNativeTerminal } from 'packages/v4/hooks/useV4BalanceOfNativeTerminal' import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' @@ -15,37 +19,16 @@ export const useV4TreasuryStats = () => { const { data: payoutLimit } = usePayoutLimit() - const treasuryBalance = useMemo(() => { - if (!_treasuryBalance) return undefined - - return ( - - ) - }, [_treasuryBalance]) - + const treasuryBalance = const surplus = useMemo(() => { - if (payoutLimit && payoutLimit.amount === MAX_PAYOUT_LIMIT) return t`No surplus` + if (payoutLimit && payoutLimit.amount === MAX_PAYOUT_LIMIT) + return t`No surplus` - return ( - - ) - }, [ - surplusInNativeToken, - payoutLimit, - ]) + return + }, [surplusInNativeToken, payoutLimit]) - const availableToPayout = useMemo(() => { - return ( - - ) - }, [distributableAmount]) + const availableToPayout = return { treasuryBalance, availableToPayout, From 2c4b40c0e8176a934cb241e612e6ac4a99e30c71 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:14:05 +1000 Subject: [PATCH 03/44] fix: issuance rates in ruleset config table --- .../useV4FormatConfigurationTokenSection.ts | 113 +++++++++++------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts index 6f6cc9e49b..9d9c1a41de 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts @@ -24,22 +24,21 @@ export const useV4FormatConfigurationTokenSection = ({ upcomingRulesetLoading: boolean upcomingRulesetMetadata?: JBRulesetMetadata | undefined | null }): ConfigurationPanelTableData => { - const tokenSymbol = useMemo( - () => - tokenSymbolText({ - tokenSymbol: tokenSymbolRaw, - capitalize: false, - plural: true, - }), - [tokenSymbolRaw], - ) + const tokenSymbol = tokenSymbolText({ + tokenSymbol: tokenSymbolRaw, + capitalize: false, + plural: true, + }) + const decayPercentFloat = ruleset?.decayPercent.toFloat() const currentTotalIssuanceRate = ruleset?.weight.toFloat() - const queuedTotalIssuanceRate = upcomingRuleset ? - upcomingRuleset?.weight.toFloat() - : currentTotalIssuanceRate && decayPercentFloat ? - currentTotalIssuanceRate - (currentTotalIssuanceRate * decayPercentFloat) - : undefined + + const queuedTotalIssuanceRate = upcomingRuleset + ? upcomingRuleset?.weight.toFloat() + : typeof currentTotalIssuanceRate !== 'undefined' && + typeof decayPercentFloat !== 'undefined' + ? currentTotalIssuanceRate - currentTotalIssuanceRate * decayPercentFloat + : undefined const totalIssuanceRateDatum: ConfigurationPanelDatum = useMemo(() => { const current = currentTotalIssuanceRate @@ -49,33 +48,53 @@ export const useV4FormatConfigurationTokenSection = ({ if (upcomingRuleset === null || upcomingRulesetLoading) { return pairToDatum(t`Total issuance rate`, current, null) } + const queued = queuedTotalIssuanceRate ? `${queuedTotalIssuanceRate} ${tokenSymbol}/ETH` : undefined + return pairToDatum(t`Total issuance rate`, current, queued) - }, [upcomingRuleset, currentTotalIssuanceRate, tokenSymbol, queuedTotalIssuanceRate, upcomingRulesetLoading]) + }, [ + upcomingRuleset, + currentTotalIssuanceRate, + tokenSymbol, + queuedTotalIssuanceRate, + upcomingRulesetLoading, + ]) const reservedPercentFloat = rulesetMetadata?.reservedPercent.toFloat() - const queuedReservedPercentFloat = upcomingRulesetMetadata?.reservedPercent.toFloat() + const queuedReservedPercentFloat = + upcomingRulesetMetadata?.reservedPercent.toFloat() const payerIssuanceRateDatum: ConfigurationPanelDatum = useMemo(() => { - const currentPayerIssuanceRate = currentTotalIssuanceRate && reservedPercentFloat ? - currentTotalIssuanceRate - (currentTotalIssuanceRate * reservedPercentFloat) - : undefined + const currentPayerIssuanceRate = + typeof currentTotalIssuanceRate !== 'undefined' && + typeof reservedPercentFloat !== 'undefined' + ? currentTotalIssuanceRate - + currentTotalIssuanceRate * reservedPercentFloat + : undefined const current = currentPayerIssuanceRate ? `${currentPayerIssuanceRate} ${tokenSymbol}/ETH` : undefined - if (upcomingRuleset === null || upcomingRulesetMetadata === null || upcomingRulesetLoading) { + + if ( + upcomingRuleset === null || + upcomingRulesetMetadata === null || + upcomingRulesetLoading + ) { return pairToDatum(t`Payer issuance rate`, current, null) } + const _reservedPercent = queuedReservedPercentFloat ?? reservedPercentFloat - const queuedPayerIssuanceRate = queuedTotalIssuanceRate && _reservedPercent ? - queuedTotalIssuanceRate - (queuedTotalIssuanceRate * _reservedPercent) - : undefined + const queuedPayerIssuanceRate = + queuedTotalIssuanceRate && _reservedPercent + ? queuedTotalIssuanceRate - queuedTotalIssuanceRate * _reservedPercent + : undefined const queued = queuedPayerIssuanceRate ? `${queuedPayerIssuanceRate} ${tokenSymbol}/ETH` : undefined + return pairToDatum(t`Payer issuance rate`, current, queued) }, [ tokenSymbol, @@ -85,27 +104,28 @@ export const useV4FormatConfigurationTokenSection = ({ currentTotalIssuanceRate, queuedTotalIssuanceRate, reservedPercentFloat, - upcomingRulesetLoading + upcomingRulesetLoading, ]) const reservedPercentDatum: ConfigurationPanelDatum = useMemo(() => { - const current = rulesetMetadata?.reservedPercent ? - `${rulesetMetadata.reservedPercent.formatPercentage()}%` : undefined + const current = rulesetMetadata?.reservedPercent + ? `${rulesetMetadata.reservedPercent.formatPercentage()}%` + : undefined if (upcomingRulesetMetadata === null || upcomingRulesetLoading) { return pairToDatum(t`Reserved rate`, current, null) } const queued = upcomingRulesetMetadata?.reservedPercent ? `${upcomingRulesetMetadata.reservedPercent.formatPercentage()}%` - : rulesetMetadata?.reservedPercent ? - `${rulesetMetadata.reservedPercent.formatPercentage()}%` + : rulesetMetadata?.reservedPercent + ? `${rulesetMetadata.reservedPercent.formatPercentage()}%` : undefined return pairToDatum(t`Reserved rate`, current, queued) }, [upcomingRulesetMetadata, rulesetMetadata, upcomingRulesetLoading]) const decayPercentDatum: ConfigurationPanelDatum = useMemo(() => { - const current = ruleset ? - `${ruleset.decayPercent.formatPercentage()}%` + const current = ruleset + ? `${ruleset.decayPercent.formatPercentage()}%` : undefined if (upcomingRuleset === null || upcomingRulesetLoading) { @@ -113,15 +133,16 @@ export const useV4FormatConfigurationTokenSection = ({ } const queued = upcomingRuleset ? `${upcomingRuleset.decayPercent.formatPercentage()}%` - : ruleset ? - `${ruleset.decayPercent.formatPercentage()}%` + : ruleset + ? `${ruleset.decayPercent.formatPercentage()}%` : undefined return pairToDatum(t`Decay rate`, current, queued) }, [ruleset, upcomingRuleset, upcomingRulesetLoading]) const redemptionRateDatum: ConfigurationPanelDatum = useMemo(() => { - const currentRedemptionRate = rulesetMetadata?.redemptionRate.formatPercentage() + const currentRedemptionRate = + rulesetMetadata?.redemptionRate.formatPercentage() const current = currentRedemptionRate ? `${currentRedemptionRate}%` @@ -133,8 +154,8 @@ export const useV4FormatConfigurationTokenSection = ({ const queued = upcomingRulesetMetadata ? `${upcomingRulesetMetadata?.redemptionRate.formatPercentage()}%` - : rulesetMetadata ? - `${rulesetMetadata.redemptionRate.formatPercentage()}%` + : rulesetMetadata + ? `${rulesetMetadata.redemptionRate.formatPercentage()}%` : undefined return pairToDatum(t`Redemption rate`, current, queued) }, [upcomingRulesetMetadata, rulesetMetadata, upcomingRulesetLoading]) @@ -153,18 +174,22 @@ export const useV4FormatConfigurationTokenSection = ({ } const queuedOwnerTokenMinting = - upcomingRulesetMetadata?.allowOwnerMinting !== undefined ? - upcomingRulesetMetadata?.allowOwnerMinting - : rulesetMetadata?.allowOwnerMinting !== undefined ? - rulesetMetadata.allowOwnerMinting - : undefined + upcomingRulesetMetadata?.allowOwnerMinting !== undefined + ? upcomingRulesetMetadata?.allowOwnerMinting + : rulesetMetadata?.allowOwnerMinting !== undefined + ? rulesetMetadata.allowOwnerMinting + : undefined return flagPairToDatum( t`Owner token minting`, currentOwnerTokenMinting, queuedOwnerTokenMinting, ) - }, [rulesetMetadata?.allowOwnerMinting, upcomingRulesetMetadata, upcomingRulesetLoading]) + }, [ + rulesetMetadata?.allowOwnerMinting, + upcomingRulesetMetadata, + upcomingRulesetLoading, + ]) const tokenTransfersDatum: ConfigurationPanelDatum = useMemo(() => { const currentTokenTransfersDatum = @@ -181,8 +206,8 @@ export const useV4FormatConfigurationTokenSection = ({ const queuedTokenTransfersDatum = upcomingRulesetMetadata?.pauseCreditTransfers !== undefined ? !upcomingRulesetMetadata?.pauseCreditTransfers - : rulesetMetadata?.pauseCreditTransfers !== undefined ? - !rulesetMetadata.pauseCreditTransfers + : rulesetMetadata?.pauseCreditTransfers !== undefined + ? !rulesetMetadata.pauseCreditTransfers : null return flagPairToDatum( @@ -193,7 +218,7 @@ export const useV4FormatConfigurationTokenSection = ({ }, [ rulesetMetadata?.pauseCreditTransfers, upcomingRulesetMetadata, - upcomingRulesetLoading + upcomingRulesetLoading, ]) return useMemo(() => { From bd6700b9767931fc5a61d91d5be9e13c198c6fdc Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:15:28 +1000 Subject: [PATCH 04/44] fix: activity list when no token --- .../V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx index 43f1b487a6..7341ebabb9 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx @@ -34,7 +34,6 @@ export function V4ActivityList() { const payEvents = transformPayEventsRes(payEventsData) ?? [] - if (!token?.data?.symbol) return null return (
@@ -69,7 +68,7 @@ export function V4ActivityList() { extra={ bought {event.beneficiaryTokenCount?.format(6)}{' '} - {token.data?.symbol} + {token.data?.symbol ?? 'tokens'} } /> From d63d1f8a859e746ba01c3a42a0ea2786d1518b19 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:17:59 +1000 Subject: [PATCH 05/44] fix: payment when no token --- .../PayProjectModal/hooks/useProjectPaymentTokens.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts index cc8599c872..ae6283fb0e 100644 --- a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts +++ b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts @@ -1,5 +1,5 @@ import { FixedInt } from 'fpnum' -import { getTokenAToBQuote } from 'juice-sdk-core' +import { getTokenAToBQuote, NATIVE_TOKEN_DECIMALS } from 'juice-sdk-core' import { useJBRulesetContext, useJBTokenContext, @@ -35,8 +35,8 @@ export const useProjectPaymentTokens = () => { : null const receivedTickets = - token.data?.decimals && amountBQuote?.payerTokens - ? formatUnits(amountBQuote?.payerTokens, token.data?.decimals) + amountBQuote?.payerTokens + ? formatUnits(amountBQuote?.payerTokens, token.data?.decimals ?? NATIVE_TOKEN_DECIMALS) : null const receivedTokenSymbolText = tokenSymbolText({ tokenSymbol: token.data?.symbol, From 6a6e9dca9e05c28ec326ad7b54c3d5902d7888dd Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:26:38 +1000 Subject: [PATCH 06/44] fix: set pay memo properly --- .../PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts index 67d648d0dc..16005cb97f 100644 --- a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts +++ b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts @@ -118,7 +118,7 @@ export const usePayProjectTx = ({ weiAmount, beneficiary, 0n, - `JBM V4 ${projectId}`, // TODO update + memo, '0x0', ] as const From d9a8b8934ee8bd80f2ac582b2fbcde70024ba922 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:42:50 +1000 Subject: [PATCH 07/44] fix: refactor v4 create flow to not rely on project provider --- src/packages/v4/hooks/useLaunchProjectTx.ts | 121 ++++++++++++-------- src/pages/create/index.tsx | 19 +-- 2 files changed, 75 insertions(+), 65 deletions(-) diff --git a/src/packages/v4/hooks/useLaunchProjectTx.ts b/src/packages/v4/hooks/useLaunchProjectTx.ts index 703340c40c..38ffcfe689 100644 --- a/src/packages/v4/hooks/useLaunchProjectTx.ts +++ b/src/packages/v4/hooks/useLaunchProjectTx.ts @@ -4,13 +4,17 @@ 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 { 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 { Address, WaitForTransactionReceiptReturnType } from 'viem' +import { + LaunchV2V3ProjectArgs, + transformV2V3CreateArgsToV4, +} from '../utils/launchProject' import { wagmiConfig } from '../wagmiConfig' +import { useCurrentRouteChainId } from './useCurrentRouteChainId' const CREATE_EVENT_IDX = 2 const PROJECT_ID_TOPIC_IDX = 1 @@ -18,7 +22,7 @@ const HEX_BASE = 16 export interface LaunchTxOpts { onTransactionPending: (hash: `0x${string}`) => void - onTransactionConfirmed: (hash: `0x${string}`, projectId: number) => void + onTransactionConfirmed: (hash: `0x${string}`, projectId: number) => void onTransactionError: (error: Error) => void } @@ -33,43 +37,64 @@ const getProjectIdFromLaunchReceipt = ( txReceipt?.logs[CREATE_EVENT_IDX]?.topics?.[PROJECT_ID_TOPIC_IDX] if (!projectIdHex) return 0 - const projectId = parseInt(projectIdHex, HEX_BASE); + const projectId = parseInt(projectIdHex, HEX_BASE) return projectId } +// todo no ideal to hardcode these addresses +const SUPPORTED_JB_MULTITERMINAL_ADDRESS = { + '84532': '0x88e8ED1Dd942B2aB4Adc1e3b50Bd0EdB9822231E' as Address, + '421614': '0x88e8ED1Dd942B2aB4Adc1e3b50Bd0EdB9822231E' as Address, + '11155111': '0x88e8ED1Dd942B2aB4Adc1e3b50Bd0EdB9822231E' as Address, + '11155420': '0x88e8ED1Dd942B2aB4Adc1e3b50Bd0EdB9822231E' as Address, +} + +const SUPPORTED_JB_CONTROLLER_ADDRESS = { + '84532': '0x1e4c8DFfE8D72aeB63e8dDbE9eF89bc368cbbE99' as Address, + '421614': '0x1e4c8DFfE8D72aeB63e8dDbE9eF89bc368cbbE99' as Address, + '11155111': '0x1e4c8DFfE8D72aeB63e8dDbE9eF89bc368cbbE99' as Address, + '11155420': '0x1e4c8DFfE8D72aeB63e8dDbE9eF89bc368cbbE99' as Address, +} + /** * 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 { writeContractAsync: writeLaunchProject } = + useWriteJbControllerLaunchProjectFor() + + const chainId = useCurrentRouteChainId() + const terminalAddress = chainId + ? SUPPORTED_JB_MULTITERMINAL_ADDRESS[chainId] + : undefined + + const controllerAddress = chainId + ? SUPPORTED_JB_CONTROLLER_ADDRESS[chainId] + : undefined 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 - ) { + async ( + { + owner, + projectMetadataCID, + fundingCycleData, + fundingCycleMetadata, + fundAccessConstraints, + groupedSplits = [], + mustStartAtOrAfter = DEFAULT_MUST_START_AT_OR_AFTER, + }: LaunchV2V3ProjectData, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: LaunchTxOpts, + ) => { + if (!controllerAddress || !terminalAddress || !userAddress || !chainId) { return } @@ -77,48 +102,47 @@ export function useLaunchProjectTx() { const v2v3Args = [ _owner, - [projectMetadataCID, JUICEBOX_MONEY_PROJECT_METADATA_DOMAIN], + [projectMetadataCID, JUICEBOX_MONEY_PROJECT_METADATA_DOMAIN], fundingCycleData, - fundingCycleMetadata, - mustStartAtOrAfter, + fundingCycleMetadata, + mustStartAtOrAfter, groupedSplits, fundAccessConstraints, - [contracts.primaryNativeTerminal.data], // _terminals, just supporting single for now - // Eventually should be something like: - // getTerminalsFromFundAccessConstraints( - // fundAccessConstraints, - // contracts.primaryNativeTerminal.data, - // ), + [terminalAddress], // _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 + primaryNativeTerminal: terminalAddress, + tokenAddress: NATIVE_TOKEN, }) try { // SIMULATE TX: // const encodedData = encodeFunctionData({ // abi: jbControllerAbi, // ABI of the contract - // functionName: 'launchProjectFor', - // args, + // functionName: 'launchProjectFor', + // args, // }) const hash = await writeLaunchProject({ - address: contracts.controller.data, + chainId, + address: controllerAddress, args, }) onTransactionPendingCallback(hash) addTransaction?.('Launch Project', { hash }) - const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( - wagmiConfig, - { + const transactionReceipt: WaitForTransactionReceiptReturnType = + await waitForTransactionReceipt(wagmiConfig, { hash, - }, - ) + }) const newProjectId = getProjectIdFromLaunchReceipt(transactionReceipt) @@ -130,10 +154,11 @@ export function useLaunchProjectTx() { } }, [ - contracts.controller.data, + chainId, + controllerAddress, userAddress, writeLaunchProject, - contracts.primaryNativeTerminal.data, + terminalAddress, addTransaction, ], ) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 4c283e303a..ebb6cdf2be 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -2,12 +2,8 @@ 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 { 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' @@ -33,16 +29,7 @@ export default function CreatePage() { if (featureFlagEnabled(FEATURE_FLAGS.V4)) { contentByVersion = ( - {/* Hardcode V4 create to Sepoila for now */} - - - + ) } @@ -55,9 +42,7 @@ export default function CreatePage() { /> - - {contentByVersion} - + {contentByVersion} ) From e655d8e3d92519ef30cbc7b17f99860df6bb8be2 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Mon, 9 Sep 2024 11:13:19 +0800 Subject: [PATCH 08/44] feat: v4 edit cycle tx (#4448) --- src/packages/v4/hooks/useEditRulesetTx.ts | 86 +++++++++++++ src/packages/v4/utils/editRuleset.ts | 115 ++++++++++++++++++ .../ReviewConfirmModal/ReviewConfirmModal.tsx | 36 ++++-- .../ReviewConfirmModal/TokensSectionDiff.tsx | 34 +++--- 4 files changed, 245 insertions(+), 26 deletions(-) create mode 100644 src/packages/v4/hooks/useEditRulesetTx.ts create mode 100644 src/packages/v4/utils/editRuleset.ts diff --git a/src/packages/v4/hooks/useEditRulesetTx.ts b/src/packages/v4/hooks/useEditRulesetTx.ts new file mode 100644 index 0000000000..165755a361 --- /dev/null +++ b/src/packages/v4/hooks/useEditRulesetTx.ts @@ -0,0 +1,86 @@ +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { NATIVE_TOKEN } from 'juice-sdk-core' +import { useJBContractContext, useWriteJbControllerLaunchRulesetsFor } from 'juice-sdk-react' +import { useCallback, useContext } from 'react' +import { transformEditCycleFormFieldsToTxArgs } from '../utils/editRuleset' +import { EditCycleFormFields } from '../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields' + +export interface EditRulesetTxOpts { + onTransactionPending: (hash: `0x${string}`) => void + onTransactionConfirmed: () => void + onTransactionError: (error: Error) => void +} + +/** + * Takes data in EditCycleFormFields format, converts it to Edit Ruleset tx format and passes it to `writeEditRuleset` + * @returns A function that deploys a project. + */ +export function useEditRulesetTx() { + const { writeContractAsync: writeEditRuleset } = useWriteJbControllerLaunchRulesetsFor() + const { contracts } = useJBContractContext() + + const { addTransaction } = useContext(TxHistoryContext) + + const { userAddress } = useWallet() + + return useCallback( + async (formValues: EditCycleFormFields, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: EditRulesetTxOpts + ) => { + if ( + !contracts.controller.data || + !contracts.primaryNativeTerminal.data || + !userAddress + ) { + return + } + + const args = transformEditCycleFormFieldsToTxArgs({ + formValues, + primaryNativeTerminal: contracts.primaryNativeTerminal.data, + tokenAddress: NATIVE_TOKEN + }) + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbControllerAbi, // ABI of the contract + // functionName: 'launchRulesetsFor', + // args, + // }) + + const hash = await writeEditRuleset({ + address: contracts.controller.data, + args, + }) + + onTransactionPendingCallback(hash) + addTransaction?.('Edit Ruleset', { hash }) + // const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( + // wagmiConfig, + // { + // hash, + // }, + // ) + + onTransactionConfirmedCallback() + } catch (e) { + onTransactionErrorCallback( + (e as Error) ?? new Error('Transaction failed'), + ) + } + }, + [ + contracts.controller.data, + userAddress, + writeEditRuleset, + contracts.primaryNativeTerminal.data, + addTransaction, + ], + ) +} diff --git a/src/packages/v4/utils/editRuleset.ts b/src/packages/v4/utils/editRuleset.ts new file mode 100644 index 0000000000..99f8e11b60 --- /dev/null +++ b/src/packages/v4/utils/editRuleset.ts @@ -0,0 +1,115 @@ +import round from "lodash/round"; +import { otherUnitToSeconds } from "utils/format/formatTime"; +import { EditCycleFormFields } from "../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields"; + +export function transformEditCycleFormFieldsToTxArgs({ + formValues, + primaryNativeTerminal, + tokenAddress, +}: { + formValues: EditCycleFormFields; + primaryNativeTerminal: `0x${string}`; + tokenAddress: `0x${string}`; +}) { + const now = round(new Date().getTime() / 1000); + const mustStartAtOrAfter = now; + + const duration = otherUnitToSeconds({ + duration: formValues.duration, + unit: formValues.durationUnit.value, + }) + const weight = BigInt(formValues.issuanceRate); + const decayPercent = formValues.decayPercent; + const approvalHook = formValues.approvalHook; + + const rulesetConfigurations = [ + { + mustStartAtOrAfter, + duration, + weight, + decayPercent, + approvalHook, + + metadata: { + reservedPercent: formValues.reservedPercent, + redemptionRate: formValues.redemptionRate, + baseCurrency: 1, // Assuming base currency is a constant value, typically USD + pausePay: formValues.pausePay, + pauseRedeem: false, // Defaulting this value since it's not in formValues + pauseCreditTransfers: !formValues.tokenTransfers, + allowOwnerMinting: formValues.allowOwnerMinting, + allowSetCustomToken: false, // Defaulting to false as it's not in formValues + allowTerminalMigration: formValues.allowTerminalMigration, + allowSetTerminals: formValues.allowSetTerminals, + allowSetController: formValues.allowSetController, + allowAddAccountingContext: false, // Defaulting to false as it's not in formValues + allowAddPriceFeed: false, // Defaulting to false as it's not in formValues + ownerMustSendPayouts: false, // Defaulting to false as it's not in formValues + holdFees: formValues.holdFees, + useTotalSurplusForRedemptions: false, // Defaulting to false as it's not in formValues + useDataHookForPay: false, // Defaulting to false as it's not in formValues + useDataHookForRedeem: false, // Defaulting to false as it's not in formValues + dataHook: "0x0000000000000000000000000000000000000000" as `0x${string}`, // Defaulting to a null address + metadata: 0, // Assuming no additional metadata is provided + }, + + splitGroups: [ + { + groupId: BigInt(1), // Assuming 1 for payout splits + splits: formValues.payoutSplits.map((split) => ({ + preferAddToBalance: Boolean(split.preferAddToBalance), + percent: Number(split.percent.value), + projectId: BigInt(split.projectId), + beneficiary: split.beneficiary as `0x${string}`, + lockedUntil: split.lockedUntil ?? 0, + hook: split.hook as `0x${string}`, + })), + }, + { + groupId: BigInt(2), // Assuming 2 for reserved tokens splits + splits: formValues.reservedTokensSplits.map((split) => ({ + preferAddToBalance: Boolean(split.preferAddToBalance), + percent: Number(split.percent.value), + projectId: BigInt(split.projectId), + beneficiary: split.beneficiary as `0x${string}`, + lockedUntil: split.lockedUntil ?? 0, + hook: split.hook as `0x${string}`, + })), + }, + ], + + fundAccessLimitGroups: [ + { + terminal: primaryNativeTerminal, + token: tokenAddress, + payoutLimits: [ + { + amount: BigInt(formValues.payoutLimit ?? "0"), + currency: 1, // Assuming currency is constant (e.g., USD) + }, + ], + surplusAllowances: [ + { + amount: BigInt(0), // Assuming no surplus allowances for now + currency: 1, // Assuming currency is constant (e.g., USD) + }, + ], + }, + ], + }, + ]; + + const terminalConfigurations = [ + { + terminal: primaryNativeTerminal, + accountingContextsToAccept: [] as const, + }, + ]; + + return [ + BigInt(now), // Convert the current timestamp to bigint for the first argument + rulesetConfigurations, + terminalConfigurations, + formValues.memo ?? "", + ] as const; +} diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx index 980c8c784b..747bf0a2d7 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx @@ -8,6 +8,8 @@ import { useState } from 'react' // import { useReconfigureFundingCycle } from '../../../hooks/useReconfigureFundingCycle' import { useEditCycleFormContext } from '../EditCycleFormContext' // import { usePrepareSaveEditCycleData } from '../hooks/usePrepareSaveEditCycleData' +import { useEditRulesetTx } from 'packages/v4/hooks/useEditRulesetTx' +import { emitErrorNotification } from 'utils/notifications' import { TransactionSuccessModal } from '../TransactionSuccessModal' import { DetailsSectionDiff } from './DetailsSectionDiff' import { PayoutsSectionDiff } from './PayoutsSectionDiff' @@ -26,6 +28,7 @@ export function ReviewConfirmModal({ }) { const [editCycleSuccessModalOpen, setEditCycleSuccessModalOpen] = useState(false) + const [confirmLoading, setConfirmLoading] = useState(false) const { editCycleForm } = useEditCycleFormContext() @@ -38,17 +41,26 @@ export function ReviewConfirmModal({ const memo = useWatch('memo', editCycleForm) // const { editingFundingCycleConfig } = usePrepareSaveEditCycleData() + const editRulesetTx = useEditRulesetTx() - // const { reconfigureLoading, reconfigureFundingCycle } = - // useReconfigureFundingCycle({ - // editingFundingCycleConfig, - // memo: memo ?? '', - // onComplete: () => { - // editCycleForm?.resetFields() - // setEditCycleSuccessModalOpen(true) - // onClose() - // }, - // }) + + const handleConfirm = () => { + setConfirmLoading(true) + editRulesetTx(editCycleForm?.getFieldsValue(true), { + onTransactionPending: () => null, + onTransactionConfirmed: () => { + editCycleForm?.resetFields() + setConfirmLoading(false) + setEditCycleSuccessModalOpen(true) + onClose() + }, + onTransactionError: error => { + console.error(error) + setConfirmLoading(false) + emitErrorNotification(`Error launching ruleset: ${error}`) + }, + }) + } const panelProps = { className: 'text-lg' } @@ -58,12 +70,12 @@ export function ReviewConfirmModal({ open={open} title={Review & confirm} destroyOnClose - onOk={() => null}//reconfigureFundingCycle()} + onOk={handleConfirm} okText={Deploy changes} okButtonProps={{ disabled: !formHasChanges }} cancelButtonProps={{ hidden: true }} onCancel={onClose} - confirmLoading={false}//reconfigureLoading} + confirmLoading={confirmLoading} >

diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/TokensSectionDiff.tsx b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/TokensSectionDiff.tsx index 56a3c83d22..a761ee95d0 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/TokensSectionDiff.tsx +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/TokensSectionDiff.tsx @@ -61,7 +61,7 @@ export function TokensSectionDiff() { - {mintRateHasDiff && currentMintRateAfterDiscountRateApplied && ( + {mintRateHasDiff && currentMintRateAfterDiscountRateApplied ? ( } /> - )} - {discountRateHasDiff && currentDiscountRate && ( + ) : null} + + {discountRateHasDiff && currentDiscountRate ? ( - )} - {redemptionHasDiff && currentRedemptionRate && ( + ) : null} + + {redemptionHasDiff && currentRedemptionRate ? ( - )} - {allowMintingHasDiff && ( + ) : null} + + {allowMintingHasDiff ? ( } /> - )} - {tokenTransfersHasDiff && ( + ) : null} + + {tokenTransfersHasDiff ? ( } /> - )} - {reservedRateHasDiff && currentReservedRate && ( + ) : null} + + {reservedRateHasDiff && currentReservedRate ? ( {currentReservedRate}%} /> - )} - {reservedSplitsHasDiff && ( + ) : null} + + {reservedSplitsHasDiff ? (

Reserved recipients: @@ -136,7 +142,7 @@ export function TokensSectionDiff() { showDiffs />
- )} + ) : null}
} /> From a83608cd3d33b00eaef68538426cb589d97d698c Mon Sep 17 00:00:00 2001 From: Johnny D Date: Mon, 9 Sep 2024 12:04:25 +0800 Subject: [PATCH 09/44] feat: v4 set metadata tx (#4449) --- .../v4/hooks/useEditProjectDetailsTx.ts | 83 +++++++++++++++++++ src/packages/v4/hooks/useEditRulesetTx.ts | 4 +- .../ReviewConfirmModal/ReviewConfirmModal.tsx | 14 +--- .../ProjectDetailsSettingsPage.tsx | 50 +++++------ 4 files changed, 109 insertions(+), 42 deletions(-) create mode 100644 src/packages/v4/hooks/useEditProjectDetailsTx.ts diff --git a/src/packages/v4/hooks/useEditProjectDetailsTx.ts b/src/packages/v4/hooks/useEditProjectDetailsTx.ts new file mode 100644 index 0000000000..25aa19497f --- /dev/null +++ b/src/packages/v4/hooks/useEditProjectDetailsTx.ts @@ -0,0 +1,83 @@ +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { useJBContractContext, useWriteJbControllerSetUriOf } from 'juice-sdk-react' +import { useCallback, useContext } from 'react' + +export interface EditRulesetTxOpts { + onTransactionPending: (hash: `0x${string}`) => void + onTransactionConfirmed: () => void + onTransactionError: (error: Error) => void +} + +/** + * Takes data in EditCycleFormFields format, converts it to Edit Ruleset tx format and passes it to `writeEditRuleset` + * @returns A function that deploys a project. + */ +export function useEditProjectDetailsTx() { + const { writeContractAsync: writeEditMetadata } = useWriteJbControllerSetUriOf() + const { contracts, projectId } = useJBContractContext() + + const { addTransaction } = useContext(TxHistoryContext) + + const { userAddress } = useWallet() + + return useCallback( + async (cid: `0x${string}`, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: EditRulesetTxOpts + ) => { + if ( + !contracts.controller.data || + !contracts.primaryNativeTerminal.data || + !userAddress + ) { + return + } + + const args = [ + projectId, + cid + ] as const + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbControllerAbi, // ABI of the contract + // functionName: 'setUriOf', + // args, + // }) + + const hash = await writeEditMetadata({ + address: contracts.controller.data, + args + }) + + onTransactionPendingCallback(hash) + addTransaction?.('Edit Metadata', { hash }) + // const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( + // wagmiConfig, + // { + // hash, + // }, + // ) + + onTransactionConfirmedCallback() + } catch (e) { + onTransactionErrorCallback( + (e as Error) ?? new Error('Transaction failed'), + ) + } + }, + [ + contracts.controller.data, + userAddress, + writeEditMetadata, + contracts.primaryNativeTerminal.data, + projectId, + addTransaction, + ], + ) +} diff --git a/src/packages/v4/hooks/useEditRulesetTx.ts b/src/packages/v4/hooks/useEditRulesetTx.ts index 165755a361..83f6b84162 100644 --- a/src/packages/v4/hooks/useEditRulesetTx.ts +++ b/src/packages/v4/hooks/useEditRulesetTx.ts @@ -6,7 +6,7 @@ import { useCallback, useContext } from 'react' import { transformEditCycleFormFieldsToTxArgs } from '../utils/editRuleset' import { EditCycleFormFields } from '../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields' -export interface EditRulesetTxOpts { +export interface EditMetadataTxOpts { onTransactionPending: (hash: `0x${string}`) => void onTransactionConfirmed: () => void onTransactionError: (error: Error) => void @@ -30,7 +30,7 @@ export function useEditRulesetTx() { onTransactionPending: onTransactionPendingCallback, onTransactionConfirmed: onTransactionConfirmedCallback, onTransactionError: onTransactionErrorCallback, - }: EditRulesetTxOpts + }: EditMetadataTxOpts ) => { if ( !contracts.controller.data || diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx index 747bf0a2d7..c2d81a670d 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx @@ -1,15 +1,12 @@ import { Trans, t } from '@lingui/macro' import { Form } from 'antd' -import { useWatch } from 'antd/lib/form/Form' import { JuiceTextArea } from 'components/inputs/JuiceTextArea' import TransactionModal from 'components/modals/TransactionModal' -import { CreateCollapse } from 'packages/v2v3/components/Create/components/CreateCollapse/CreateCollapse' -import { useState } from 'react' -// import { useReconfigureFundingCycle } from '../../../hooks/useReconfigureFundingCycle' -import { useEditCycleFormContext } from '../EditCycleFormContext' -// import { usePrepareSaveEditCycleData } from '../hooks/usePrepareSaveEditCycleData' +import { CreateCollapse } from 'packages/v4/components/Create/components/CreateCollapse/CreateCollapse' import { useEditRulesetTx } from 'packages/v4/hooks/useEditRulesetTx' +import { useState } from 'react' import { emitErrorNotification } from 'utils/notifications' +import { useEditCycleFormContext } from '../EditCycleFormContext' import { TransactionSuccessModal } from '../TransactionSuccessModal' import { DetailsSectionDiff } from './DetailsSectionDiff' import { PayoutsSectionDiff } from './PayoutsSectionDiff' @@ -39,11 +36,8 @@ export function ReviewConfirmModal({ const formHasChanges = detailsSectionHasDiff || payoutsSectionHasDiff || tokensSectionHasDiff - const memo = useWatch('memo', editCycleForm) - // const { editingFundingCycleConfig } = usePrepareSaveEditCycleData() const editRulesetTx = useEditRulesetTx() - const handleConfirm = () => { setConfirmLoading(true) editRulesetTx(editCycleForm?.getFieldsValue(true), { @@ -54,7 +48,7 @@ export function ReviewConfirmModal({ setEditCycleSuccessModalOpen(true) onClose() }, - onTransactionError: error => { + onTransactionError: (error: unknown) => { console.error(error) setConfirmLoading(false) emitErrorNotification(`Error launching ruleset: ${error}`) diff --git a/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx b/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx index 7adeacfc05..2a81156d71 100644 --- a/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx +++ b/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx @@ -1,18 +1,15 @@ import { useForm } from 'antd/lib/form/Form' import { ProjectDetailsForm, ProjectDetailsFormFields } from 'components/Project/ProjectSettings/ProjectDetailsForm' import { PROJECT_PAY_CHARACTER_LIMIT } from 'constants/numbers' -import { PV_V2 } from 'constants/pv' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { uploadProjectMetadata } from 'lib/api/ipfs' -import { revalidateProject } from 'lib/api/nextjs' +import { useEditProjectDetailsTx } from 'packages/v4/hooks/useEditProjectDetailsTx' -import { useEditProjectDetailsTx } from 'packages/v2v3/hooks/transactor/useEditProjectDetailsTx' import { useCallback, useContext, useEffect, useState } from 'react' import { withoutHttps } from 'utils/http' -import { emitInfoNotification } from 'utils/notifications' +import { emitErrorNotification, emitInfoNotification } from 'utils/notifications' export function ProjectDetailsSettingsPage() { - const { projectId } = useContext(ProjectMetadataContext) const { projectMetadata, refetchProjectMetadata } = useContext( ProjectMetadataContext, ) @@ -20,7 +17,7 @@ export function ProjectDetailsSettingsPage() { const [loadingSaveChanges, setLoadingSaveChanges] = useState() const [projectForm] = useForm() - const editV2ProjectDetailsTx = useEditProjectDetailsTx() // v4Todo: V4 tx + const editProjectDetailsTx = useEditProjectDetailsTx() const onProjectFormSaved = useCallback(async () => { setLoadingSaveChanges(true) @@ -49,42 +46,35 @@ export function ProjectDetailsSettingsPage() { return } - const txSuccess = await editV2ProjectDetailsTx( - { - cid: uploadedMetadata.Hash, - }, - { - onConfirmed: async () => { + editProjectDetailsTx( + uploadedMetadata.Hash as `0x${string}`, { + onTransactionPending: () => null, + onTransactionConfirmed: () => { + projectForm?.resetFields() setLoadingSaveChanges(false) - emitInfoNotification('Project details saved', { description: 'Your project details have been saved.', }) - if (projectId) { - await revalidateProject({ - pv: PV_V2, - projectId: String(projectId), - }) - } + // v4Todo: part of v2, not sure if necessary + // if (projectId) { + // await revalidateProject({ + // pv: PV_V4, + // projectId: String(projectId), + // }) + // } refetchProjectMetadata() }, - onError: () => { - setLoadingSaveChanges(false) - }, - onCancelled: () => { + onTransactionError: (error: unknown) => { + console.error(error) setLoadingSaveChanges(false) + emitErrorNotification(`Error launching ruleset: ${error}`) }, - }, + } ) - - if (!txSuccess) { - setLoadingSaveChanges(false) - } }, [ - editV2ProjectDetailsTx, + editProjectDetailsTx, projectForm, - projectId, refetchProjectMetadata, projectMetadata, ]) From f8960d47d92162d3e1888fd224b9f6c377fc4c79 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Mon, 9 Sep 2024 17:04:27 +0800 Subject: [PATCH 10/44] V4 Download holders modal (#4451) --- .../v1/components/V1Project/TokensSection.tsx | 2 +- .../TokenHoldersModal/TokenHoldersModal.tsx | 2 +- .../DownloadParticipantsModal.tsx | 0 .../modals/ParticipantsModal/HoldersList.tsx | 2 +- .../TokenDistributionChart/TokenAreaChart.tsx | 0 .../TokenDistributionChart/TokenPieChart.tsx | 0 .../TokenDistributionChart/index.tsx | 0 .../modals/ParticipantsModal/index.tsx | 15 +- .../v4/components/V4TokenHoldersModal.tsx | 28 --- .../DownloadTokenHoldersModal.tsx | 135 +++++++++++ .../V4TokenHoldersModal/HoldersList.tsx | 219 ++++++++++++++++++ .../TokenDistributionChart/TokenAreaChart.tsx | 159 +++++++++++++ .../TokenDistributionChart/TokenPieChart.tsx | 148 ++++++++++++ .../TokenDistributionChart/index.tsx | 70 ++++++ .../V4TokenHoldersModal.tsx | 85 +++++++ .../v4/graphql/queries/participants.graphql | 24 ++ .../queries/participantsDownload.graphql | 25 ++ .../V4TokensPanel/V4TokensPanel.tsx | 3 +- 18 files changed, 876 insertions(+), 41 deletions(-) rename src/{components/modals => packages/v2v3/components/V2V3Project/modals/ParticipantsModal}/DownloadParticipantsModal.tsx (100%) rename src/{components => packages/v2v3/components/V2V3Project}/modals/ParticipantsModal/HoldersList.tsx (98%) rename src/{components => packages/v2v3/components/V2V3Project/modals/ParticipantsModal}/TokenDistributionChart/TokenAreaChart.tsx (100%) rename src/{components => packages/v2v3/components/V2V3Project/modals/ParticipantsModal}/TokenDistributionChart/TokenPieChart.tsx (100%) rename src/{components => packages/v2v3/components/V2V3Project/modals/ParticipantsModal}/TokenDistributionChart/index.tsx (100%) rename src/{components => packages/v2v3/components/V2V3Project}/modals/ParticipantsModal/index.tsx (87%) delete mode 100644 src/packages/v4/components/V4TokenHoldersModal.tsx create mode 100644 src/packages/v4/components/modals/V4TokenHoldersModal/DownloadTokenHoldersModal.tsx create mode 100644 src/packages/v4/components/modals/V4TokenHoldersModal/HoldersList.tsx create mode 100644 src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/TokenAreaChart.tsx create mode 100644 src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx create mode 100644 src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/index.tsx create mode 100644 src/packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal.tsx create mode 100644 src/packages/v4/graphql/queries/participants.graphql create mode 100644 src/packages/v4/graphql/queries/participantsDownload.graphql diff --git a/src/packages/v1/components/V1Project/TokensSection.tsx b/src/packages/v1/components/V1Project/TokensSection.tsx index 8bdd9884ba..6a7ec6d507 100644 --- a/src/packages/v1/components/V1Project/TokensSection.tsx +++ b/src/packages/v1/components/V1Project/TokensSection.tsx @@ -3,7 +3,6 @@ import { Button, Descriptions, Space, Statistic } from 'antd' import { IssueErc20TokenButton } from 'components/buttons/IssueErc20TokenButton' import EthereumAddress from 'components/EthereumAddress' import ManageTokensModal from 'components/modals/ManageTokensModal' -import ParticipantsModal from 'components/modals/ParticipantsModal' import SectionHeader from 'components/SectionHeader' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { BigNumber } from 'ethers' @@ -19,6 +18,7 @@ import { useV1UnclaimedBalance } from 'packages/v1/hooks/contractReader/useV1Unc import { useTransferTokensTx } from 'packages/v1/hooks/transactor/useTransferTokensTx' import { V1OperatorPermission } from 'packages/v1/models/permissions' import { decodeFundingCycleMetadata } from 'packages/v1/utils/fundingCycle' +import ParticipantsModal from 'packages/v2v3/components/V2V3Project/modals/ParticipantsModal' import { CSSProperties, useContext, useState } from 'react' import { isZeroAddress } from 'utils/address' import { formatPercent, formatWad } from 'utils/format/formatNumber' diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokenHoldersModal/TokenHoldersModal.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokenHoldersModal/TokenHoldersModal.tsx index 2ee4b5018c..8194368674 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokenHoldersModal/TokenHoldersModal.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokenHoldersModal/TokenHoldersModal.tsx @@ -1,4 +1,4 @@ -import ParticipantsModal from 'components/modals/ParticipantsModal' +import ParticipantsModal from 'packages/v2v3/components/V2V3Project/modals/ParticipantsModal' import { useProjectContext } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectContext' // TODO: This is hacked together - we should consider rebuilding diff --git a/src/components/modals/DownloadParticipantsModal.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/DownloadParticipantsModal.tsx similarity index 100% rename from src/components/modals/DownloadParticipantsModal.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/DownloadParticipantsModal.tsx diff --git a/src/components/modals/ParticipantsModal/HoldersList.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/HoldersList.tsx similarity index 98% rename from src/components/modals/ParticipantsModal/HoldersList.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/HoldersList.tsx index 104ae4a9e4..34b5424d81 100644 --- a/src/components/modals/ParticipantsModal/HoldersList.tsx +++ b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/HoldersList.tsx @@ -21,7 +21,7 @@ import { PV } from 'models/pv' import { useState } from 'react' import { formatPercent } from 'utils/format/formatNumber' import { tokenSymbolText } from 'utils/tokenSymbolText' -import { DownloadParticipantsModal } from '../DownloadParticipantsModal' +import { DownloadParticipantsModal } from './DownloadParticipantsModal' interface ParticipantOption { label: string diff --git a/src/components/TokenDistributionChart/TokenAreaChart.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/TokenAreaChart.tsx similarity index 100% rename from src/components/TokenDistributionChart/TokenAreaChart.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/TokenAreaChart.tsx diff --git a/src/components/TokenDistributionChart/TokenPieChart.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/TokenPieChart.tsx similarity index 100% rename from src/components/TokenDistributionChart/TokenPieChart.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/TokenPieChart.tsx diff --git a/src/components/TokenDistributionChart/index.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/index.tsx similarity index 100% rename from src/components/TokenDistributionChart/index.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/index.tsx diff --git a/src/components/modals/ParticipantsModal/index.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/index.tsx similarity index 87% rename from src/components/modals/ParticipantsModal/index.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/index.tsx index 6d8d03aeb9..99db1d5b18 100644 --- a/src/components/modals/ParticipantsModal/index.tsx +++ b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/index.tsx @@ -9,16 +9,16 @@ import { isZeroAddress } from 'utils/address' import { tokenSymbolText } from 'utils/tokenSymbolText' import { useQuery } from '@tanstack/react-query' -import TokenDistributionChart from 'components/TokenDistributionChart' import { OrderDirection, - Participant_OrderBy, - ParticipantsDocument, ParticipantsQuery, QueryParticipantsArgs, + Participant_OrderBy as V1V2V3Participant_OrderBy, + ParticipantsDocument as V1V2V3ParticipantsDocument, } from 'generated/graphql' import { client } from 'lib/apollo/client' import { paginateDepleteQuery } from 'lib/apollo/paginateDepleteQuery' +import TokenDistributionChart from 'packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart' import HoldersList from './HoldersList' export default function ParticipantsModal({ @@ -41,20 +41,19 @@ export default function ParticipantsModal({ queryFn: () => paginateDepleteQuery({ client, - document: ParticipantsDocument, + document: V1V2V3ParticipantsDocument, variables: { orderDirection: OrderDirection.desc, - orderBy: Participant_OrderBy.balance, + orderBy: V1V2V3Participant_OrderBy.balance, where: { projectId, pv, - balance_gt: BigNumber.from(0), wallet_not: constants.AddressZero, }, - }, + } }), staleTime: 5 * 60 * 1000, // 5 min - enabled: Boolean(projectId && pv && open), + enabled: Boolean(projectId && open), }) return ( diff --git a/src/packages/v4/components/V4TokenHoldersModal.tsx b/src/packages/v4/components/V4TokenHoldersModal.tsx deleted file mode 100644 index 798980d654..0000000000 --- a/src/packages/v4/components/V4TokenHoldersModal.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// import { BigNumber } from '@ethersproject/bignumber' -// import ParticipantsModal from 'components/modals/ParticipantsModal' -import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' -import { useReadJbTokensTokenOf } from 'juice-sdk-react' -import { useV4TotalTokenSupply } from '../hooks/useV4TotalTokenSupply' - -export const V4TokenHoldersModal = ({ - open, - onClose, -}: { - open: boolean - onClose: VoidFunction -}) => { - const { data: tokenAddress } = useReadJbTokensTokenOf() - const { data: tokenSymbol } = useNameOfERC20(tokenAddress) - - const { data: totalTokenSupply } = useV4TotalTokenSupply() - return null - // return ( - // - // ) -} diff --git a/src/packages/v4/components/modals/V4TokenHoldersModal/DownloadTokenHoldersModal.tsx b/src/packages/v4/components/modals/V4TokenHoldersModal/DownloadTokenHoldersModal.tsx new file mode 100644 index 0000000000..0ed03d2f4f --- /dev/null +++ b/src/packages/v4/components/modals/V4TokenHoldersModal/DownloadTokenHoldersModal.tsx @@ -0,0 +1,135 @@ +import { t, Trans } from '@lingui/macro' +import { Modal } from 'antd' +import InputAccessoryButton from 'components/buttons/InputAccessoryButton' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' + +import { useBlockNumber } from 'hooks/useBlockNumber' +import { useJBContractContext } from 'juice-sdk-react' +import { ParticipantsDownloadDocument } from 'packages/v4/graphql/client/graphql' +import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' +import { useCallback, useEffect, useState } from 'react' +import { downloadCsvFile } from 'utils/csv' +import { fromWad } from 'utils/format/formatNumber' +import { emitErrorNotification } from 'utils/notifications' +import { tokenSymbolText } from 'utils/tokenSymbolText' + +export function DownloadTokenHoldersModal({ + tokenSymbol, + open, + onCancel, +}: { + tokenSymbol: string | undefined + open: boolean | undefined + onCancel: VoidFunction | undefined +}) { + const { projectId } = useJBContractContext() + + const [blockNumber, setBlockNumber] = useState() + const [loading, setLoading] = useState() + + // Use block number 5 blocks behind chain head to allow for subgraph being a bit behind on indexing. + const { data: latestBlockNumber } = useBlockNumber({ behindChainHeight: 5 }) + + const { data } = useSubgraphQuery({ + document: ParticipantsDownloadDocument, + variables: { + where: { + projectId: Number(projectId), + }, + block: { + number: blockNumber + } + }, + enabled: Boolean(projectId && open), + }) + + const participants = data?.participants + + useEffect(() => { + setBlockNumber(latestBlockNumber) + }, [latestBlockNumber]) + + const download = useCallback(async () => { + if (blockNumber === undefined || !projectId) return + + const rows = [ + [ + 'Wallet address', + `Total ${tokenSymbolText({ tokenSymbol })} balance`, + 'Unclaimed balance', + 'Claimed balance', + 'Total ETH paid', + 'Last paid timestamp', + ], // CSV header row + ] + + setLoading(true) + try { + if (!participants) { + emitErrorNotification(t`Error loading holders`) + throw new Error('No data.') + } + + participants.forEach(p => { + let date = new Date((p.lastPaidTimestamp ?? 0) * 1000).toUTCString() + + if (date.includes(',')) date = date.split(',')[1] + + rows.push([ + p.wallet.id ?? '--', + fromWad(p.balance), + fromWad(p.stakedBalance), + fromWad(p.erc20Balance), + fromWad(p.volume), + date, + ]) + }) + + downloadCsvFile( + `@v4-project-${projectId}_holders-block${blockNumber}.csv`, + rows, + ) + + setLoading(false) + } catch (e) { + console.error('Error downloading participants', e) + setLoading(false) + } + }, [blockNumber, projectId, tokenSymbol, participants]) + + return ( + +
+

+ + Download CSV of {tokenSymbolText({ tokenSymbol })} holders + +

+ + + setBlockNumber(val ? parseInt(val) : undefined)} + accessory={ + setBlockNumber(latestBlockNumber)} + disabled={blockNumber === latestBlockNumber} + /> + } + /> +
+
+ ) +} diff --git a/src/packages/v4/components/modals/V4TokenHoldersModal/HoldersList.tsx b/src/packages/v4/components/modals/V4TokenHoldersModal/HoldersList.tsx new file mode 100644 index 0000000000..b92b6b3883 --- /dev/null +++ b/src/packages/v4/components/modals/V4TokenHoldersModal/HoldersList.tsx @@ -0,0 +1,219 @@ +import { + DownloadOutlined, + SortAscendingOutlined, + SortDescendingOutlined, +} from '@ant-design/icons' +import { BigNumber } from '@ethersproject/bignumber' +import { Trans, t } from '@lingui/macro' +import { Button } from 'antd' +import EthereumAddress from 'components/EthereumAddress' +import Loading from 'components/Loading' +import { TokenAmount } from 'components/TokenAmount' +import { JuiceListbox } from 'components/inputs/JuiceListbox' +import { NativeTokenValue } from 'juice-sdk-react' +import { OrderDirection, Participant_OrderBy, ParticipantsDocument } from 'packages/v4/graphql/client/graphql' +import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' +import { useEffect, useState } from 'react' +import { formatPercent } from 'utils/format/formatNumber' +import { tokenSymbolText } from 'utils/tokenSymbolText' +import { DownloadTokenHoldersModal } from './DownloadTokenHoldersModal' + +interface ParticipantOption { + label: string + value: Participant_OrderBy +} + +type Participant = { + volume: bigint; + lastPaidTimestamp: number; + balance: bigint; + stakedBalance: bigint; + id: string; + wallet: { + id: string; + }; +} + +const participantOptions = (tokenText: string): ParticipantOption[] => [ + { + label: t`${tokenText} balance`, + value: Participant_OrderBy.balance, + }, + { + label: t`Total paid`, + value: Participant_OrderBy.volume, + }, + { + label: t`Last paid`, + value: Participant_OrderBy.lastPaidTimestamp, + }, +] + +const pageSize = 100 + +export default function HoldersList({ + projectId, + tokenSymbol, + totalTokenSupply, +}: { + projectId: number | undefined + tokenSymbol: string | undefined + totalTokenSupply: bigint | undefined +}) { + const [sortPayerReports, setSortPayerReports] = useState( + Participant_OrderBy.balance, + ) + const [sortPayerReportsDirection, setSortPayerReportsDirection] = + useState(OrderDirection.desc) + const [pageNumber, setPageNumber] = useState(0) + const [participants, setParticipants] = useState([]) + const [downloadModalVisible, setDownloadModalVisible] = useState() + + const pOptions = participantOptions( + tokenSymbolText({ + tokenSymbol, + capitalize: true, + }), + ) + + const participantOption = pOptions.find( + option => option.value === sortPayerReports, + ) + + const { data, isLoading } = useSubgraphQuery({ + document: ParticipantsDocument, + variables: { + orderDirection: sortPayerReportsDirection, + orderBy: sortPayerReports, + first: pageSize, + skip: pageNumber * pageSize, + where: { + projectId: Number(projectId), + }, + }, + enabled: Boolean(projectId), + }) + + useEffect(() => { + if (data?.participants) { + setParticipants(prev => { + const newParticipants = data.participants.filter( + newParticipant => !prev.some(prevParticipant => prevParticipant.id === newParticipant.id) + ) + return [...prev, ...newParticipants] + }) + } + }, [data]) + + const loadMore = () => { + setPageNumber(prevPage => prevPage + 1) + } + + return ( +
+
+ { + setSortPayerReports(v.value) + setPageNumber(0) + setParticipants([]) + }} + /> +
{ + setSortPayerReportsDirection( + sortPayerReportsDirection === OrderDirection.asc + ? OrderDirection.desc + : OrderDirection.asc, + ) + setPageNumber(0) + setParticipants([]) + }} + > + { + // these icons are visually confusing and reversed on purpose + sortPayerReportsDirection === OrderDirection.asc ? ( + + ) : ( + + ) + } +
+ +
+ + {participants.map(p => ( +
+
+
+
+ +
+
+ + contributed + +
+
+ +
+
+ {' '} + ({formatPercent( + BigNumber.from(p.balance), // TODO: make formatPercent take bigint + BigNumber.from(totalTokenSupply) + )}%) +
+
+ + {' '} + unclaimed + +
+
+
+
+ ))} + + {isLoading && pageNumber === 0 && ( +
+ +
+ )} + + {participants.length > 0 && participants.length % pageSize === 0 && ( +
+ Load more... +
+ )} + + setDownloadModalVisible(false)} + /> +
+ ) +} diff --git a/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/TokenAreaChart.tsx b/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/TokenAreaChart.tsx new file mode 100644 index 0000000000..845d3ad03c --- /dev/null +++ b/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/TokenAreaChart.tsx @@ -0,0 +1,159 @@ +import { ThemeContext } from 'contexts/Theme/ThemeContext' +import { BigNumber } from 'ethers' +import tailwind from 'lib/tailwind' +import { ParticipantsQuery } from 'packages/v4/graphql/client/graphql' +import { useContext, useMemo } from 'react' +import { + Area, + AreaChart, + Label, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { fromWad } from 'utils/format/formatNumber' + +type Entry = { + percent: number + groupIndex: number +} + +export default function TokenAreaChart({ + tokenSupply, + participants, +}: { + tokenSupply: bigint | undefined + participants: ParticipantsQuery['participants'] | undefined +}) { + const { themeOption } = useContext(ThemeContext) + + const groupCount = 12 + const groupSize = useMemo( + () => + participants + ? Math.floor(participants.length / groupCount) || 1 // Use floor for groupSize, unless floor is 0 + : undefined, + [participants], + ) + + // Format participants into groups for chart display + const chartData = useMemo(() => { + if (!tokenSupply || !participants || !groupSize) return [] + + let tempTotalBalance = BigNumber.from(0) + let groupIndex = 0 + + const participantGroups = participants.reduce((acc, curr, i) => { + tempTotalBalance = tempTotalBalance.add(curr.balance) + + if (i >= groupSize - 1 && i % groupSize === 0) { + // Add group + const percent = + (parseFloat(fromWad(tempTotalBalance)) / + parseFloat(fromWad(tokenSupply))) * + 100 + tempTotalBalance = BigNumber.from(0) + groupIndex = groupIndex + 1 + + return [...acc, { percent, groupIndex: groupIndex - 1 }] + } else if (i === participants.length - 1) { + // Add incomplete group from remainder participants + const percent = + (parseFloat(fromWad(tempTotalBalance)) / + parseFloat(fromWad(tokenSupply))) * + 100 + + return [...acc, { percent, groupIndex }] + } + + return acc + }, [] as Entry[]) + + return participantGroups + }, [participants, tokenSupply, groupSize]) + + const { + theme: { colors }, + } = tailwind + + const chartFill = colors.bluebs[500] + const textFill = themeOption === 'dark' ? colors.slate[200] : colors.grey[500] + + if (!chartData.length) return null + + return ( + + + + + value + '%'} + tickMargin={6} + style={{ + fontSize: 12, + fill: textFill, + }} + > + + + { + if (!active || !payload?.length || !groupSize) return null + + const { percent, groupIndex } = payload[0].payload + + const walletRange = `${groupSize * groupIndex}- + ${ + groupIndex + 1 > groupCount + ? participants?.length + : groupSize * (groupIndex + 1) + }` + + return ( +
+
+ {percent >= 0.001 ? percent.toFixed(3) : '<0.001'}% of supply +
+
+ Wallets {walletRange} +
+
+ ) + }} + animationDuration={50} + /> +
+
+ ) +} diff --git a/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx b/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx new file mode 100644 index 0000000000..76c10a52be --- /dev/null +++ b/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx @@ -0,0 +1,148 @@ +import EthereumAddress from 'components/EthereumAddress' +import { ThemeContext } from 'contexts/Theme/ThemeContext' +import { BigNumber } from 'ethers' +import tailwind from 'lib/tailwind' +import { ParticipantsQuery } from 'packages/v4/graphql/client/graphql' +import { useContext, useEffect, useMemo, useState } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { formattedNum, fromWad } from 'utils/format/formatNumber' + +type Entry = { + wallet?: string + walletsCount?: number + balance: number | undefined + percent: number +} + +export default function TokenPieChart({ + tokenSupply, + participants, + size, +}: { + tokenSupply: bigint | undefined + participants: ParticipantsQuery['participants'] | undefined + size: number +}) { + const { themeOption } = useContext(ThemeContext) + + const [activeWallet, setActiveWallet] = useState() + + // Format participants for chart display + const pieChartData = useMemo(() => { + if (!tokenSupply || !participants) return [] + + // Only show (arbitrary) max number of wallets to avoid chart clutter + const maxVisibleWallets = 100 + + const visibleWallets = participants.slice(0, maxVisibleWallets) + const remainderWallets = participants.slice( + maxVisibleWallets, + participants.length, + ) + + const _chartData: Entry[] = visibleWallets.map(w => ({ + wallet: w.wallet.id, + balance: parseFloat(fromWad(w.balance)), + percent: + parseFloat(fromWad(w.balance)) / parseFloat(fromWad(tokenSupply)), + })) + + // If any remainder wallets, include them as a single entry + if (remainderWallets.length) { + // Calculate total tokens held by remainder participants + const remainderBalance = remainderWallets.reduce( + (acc, curr) => acc.add(curr.balance), + BigNumber.from(0), + ) + + _chartData.push({ + walletsCount: remainderWallets.length, + balance: parseFloat(fromWad(remainderBalance)), + percent: + parseFloat(fromWad(remainderBalance)) / + parseFloat(fromWad(tokenSupply)), + }) + } + + return _chartData + }, [participants, tokenSupply]) + + // Default activate first pieChart entry + useEffect(() => { + if (pieChartData) setActiveWallet(a => (a ? a : pieChartData[0])) + }, [pieChartData]) + + const { + theme: { colors }, + } = tailwind + + const inactiveFill = colors.bluebs[500] + const activeFill = colors.bluebs[300] + const remainderInactiveFill = colors.grey[500] + const remainderActiveFill = colors.grey[300] + const stroke = themeOption === 'dark' ? colors.slate[800] : colors.smoke[25] + + return ( +
+ { + setActiveWallet(undefined) + }} + > + + {pieChartData.map((entry, index) => { + let fill: string + if (activeWallet && activeWallet.wallet === entry.wallet) { + fill = entry.wallet ? activeFill : remainderActiveFill + } else { + fill = entry.wallet ? inactiveFill : remainderInactiveFill + } + + return ( + setActiveWallet(entry)} + /> + ) + })} + + + +
+ {activeWallet && ( + <> +
+ {activeWallet.wallet ? ( + + ) : ( + `${formattedNum(activeWallet.walletsCount)} wallets` + )} +
+
+ {formattedNum(Math.round(activeWallet.balance ?? 0))} +
+
+ {activeWallet.percent >= 0.0001 + ? (activeWallet.percent * 100).toFixed(2) + : '<0.01'} + % +
+ + )} +
+
+ ) +} diff --git a/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/index.tsx b/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/index.tsx new file mode 100644 index 0000000000..f4c4cec591 --- /dev/null +++ b/src/packages/v4/components/modals/V4TokenHoldersModal/TokenDistributionChart/index.tsx @@ -0,0 +1,70 @@ +import { ChartBarSquareIcon, ChartPieIcon } from '@heroicons/react/24/outline' +import Loading from 'components/Loading' +import { ParticipantsQuery } from 'packages/v4/graphql/client/graphql' +import { useState } from 'react' +import TokenAreaChart from './TokenAreaChart' +import TokenPieChart from './TokenPieChart' + +export default function TokenDistributionChart({ + participants, + isLoading, + tokenSupply, +}: { + participants: ParticipantsQuery['participants'] | undefined + isLoading?: boolean + tokenSupply: bigint | undefined +}) { + const [viewMode, setViewMode] = useState<'pie' | 'area'>('pie') + + // Don't render chart for projects with no token supply + if (tokenSupply === 0n || !participants?.length) return null + + const size = 320 + + if (isLoading) { + return ( +
+ +
+ ) + } + + let content + switch (viewMode) { + case 'pie': + content = ( + + ) + break + case 'area': + content = ( + + ) + break + } + + return ( +
+
{content}
+ +
+
setViewMode('pie')} + className={viewMode === 'pie' ? 'opacity-100' : 'opacity-50'} + > + +
+
setViewMode('area')} + className={viewMode === 'area' ? 'opacity-100' : 'opacity-50'} + > + +
+
+
+ ) +} diff --git a/src/packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal.tsx b/src/packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal.tsx new file mode 100644 index 0000000000..4b0e57d700 --- /dev/null +++ b/src/packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal.tsx @@ -0,0 +1,85 @@ +import { t, Trans } from '@lingui/macro' +import { Modal } from 'antd' +import EthereumAddress from 'components/EthereumAddress' +import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' +import { useJBContractContext, useReadJbTokensTokenOf } from 'juice-sdk-react' +import { OrderDirection, Participant_OrderBy, ParticipantsDocument } from 'packages/v4/graphql/client/graphql' +import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' +import { isZeroAddress } from 'utils/address' +import { tokenSymbolText } from 'utils/tokenSymbolText' +import { useV4TotalTokenSupply } from '../../../hooks/useV4TotalTokenSupply' +import HoldersList from './HoldersList' +import TokenDistributionChart from './TokenDistributionChart' + +export const V4TokenHoldersModal = ({ + open, + onClose, +}: { + open: boolean + onClose: VoidFunction +}) => { + const { projectId } = useJBContractContext() + const { data: tokenAddress } = useReadJbTokensTokenOf() + const { data: tokenSymbol } = useNameOfERC20(tokenAddress) + + const { data: totalTokenSupply } = useV4TotalTokenSupply() + + const { data, isLoading } = useSubgraphQuery({ + document: ParticipantsDocument, + variables: { + orderDirection: OrderDirection.desc, + orderBy: Participant_OrderBy.balance, + where: { + projectId: Number(projectId), + }, + }, + enabled: Boolean(projectId && open), + }) + + const allParticipants = data?.participants + + return ( + +
+

+ + {tokenSymbolText({ tokenSymbol, capitalize: true })} holders + +

+
+
+ {tokenAddress && !isZeroAddress(tokenAddress) && ( +
+ + Token address: + +
+ )} +
{allParticipants?.length} wallets
+
+ +
+ +
+ + +
+
+
+ ) +} diff --git a/src/packages/v4/graphql/queries/participants.graphql b/src/packages/v4/graphql/queries/participants.graphql new file mode 100644 index 0000000000..9e028259e5 --- /dev/null +++ b/src/packages/v4/graphql/queries/participants.graphql @@ -0,0 +1,24 @@ +query Participants( + $where: Participant_filter + $first: Int + $skip: Int + $orderBy: Participant_orderBy + $orderDirection: OrderDirection +) { + participants( + where: $where + first: $first + skip: $skip + orderBy: $orderBy + orderDirection: $orderDirection + ) { + wallet { + id + } + volume + lastPaidTimestamp + balance + stakedBalance + id + } +} diff --git a/src/packages/v4/graphql/queries/participantsDownload.graphql b/src/packages/v4/graphql/queries/participantsDownload.graphql new file mode 100644 index 0000000000..0537b8a03f --- /dev/null +++ b/src/packages/v4/graphql/queries/participantsDownload.graphql @@ -0,0 +1,25 @@ +query ParticipantsDownload( + $where: Participant_filter + $first: Int + $skip: Int + $block: Block_height +) { + participants( + where: $where + first: $first + skip: $skip + block: $block + orderBy: balance + orderDirection: desc + ) { + wallet { + id + } + volume + volumeUSD + balance + stakedBalance + erc20Balance + lastPaidTimestamp + } +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx index 0eff7023aa..f81b0485cd 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx @@ -7,8 +7,7 @@ import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/Titl // import { ReservedTokensSubPanel } from './components/ReservedTokensSubPanel' // import { TokenRedemptionCallout } from './components/TokenRedemptionCallout' // import { TransferUnclaimedTokensModalWrapper } from './components/TransferUnclaimedTokensModalWrapper' -// import { IssueErc20TokenButton } from 'components/buttons/IssueErc20TokenButton' -import { V4TokenHoldersModal } from 'packages/v4/components/V4TokenHoldersModal' +import { V4TokenHoldersModal } from 'packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal' import { useCallback, useState } from 'react' import { useV4TokensPanel } from './hooks/useV4TokensPanel' import { useV4YourBalanceMenuItems } from './hooks/useV4YourBalanceMenuItems' From 34032bfc2c86385e6d2ad0842bc883d863c38c2b Mon Sep 17 00:00:00 2001 From: Johnny D Date: Mon, 9 Sep 2024 17:18:34 +0800 Subject: [PATCH 11/44] V4 project page: volume charts (#4452) --- .../VolumeChart/hooks/useProjectTimeline.ts | 19 +++++++------------ .../V4ActivityPanel/V4ActivityList.tsx | 4 ++-- .../V4ActivityPanel/V4ActivityPanel.tsx | 13 +++++++------ 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/components/VolumeChart/hooks/useProjectTimeline.ts b/src/components/VolumeChart/hooks/useProjectTimeline.ts index cc8fea805e..3410c66eae 100644 --- a/src/components/VolumeChart/hooks/useProjectTimeline.ts +++ b/src/components/VolumeChart/hooks/useProjectTimeline.ts @@ -1,12 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { PV_V2, PV_V4 } from 'constants/pv' import { readProvider } from 'constants/readProvider' +import { RomanStormVariables } from 'constants/romanStorm' import EthDater from 'ethereum-block-by-date' -import { - ProjectTlQuery, - useProjectsQuery, - useProjectTlQuery, -} from 'generated/graphql' +import { ProjectTlQuery, useProjectsQuery, useProjectTlQuery } from 'generated/graphql' import { client } from 'lib/apollo/client' import { PV } from 'models/pv' import { ProjectTlDocument } from 'packages/v4/graphql/client/graphql' @@ -15,7 +12,6 @@ import { useMemo } from 'react' import { wadToFloat } from 'utils/format/formatNumber' import { getSubgraphIdForProject } from 'utils/graph' import { daysToMS, minutesToMS } from 'utils/units' -import { RomanStormVariables } from 'constants/romanStorm' import { ProjectTimelinePoint, ProjectTimelineRange } from '../types' @@ -105,13 +101,14 @@ export function useProjectTimeline({ skip: pv === PV_V4, }) + const { data: v4QueryResult } = useSubgraphQuery({ - document: ProjectTlDocument, + document: ProjectTlDocument, variables: { id: blocks ? projectId.toString() : '', ...blocks, }, - enabled: pv === PV_V4, + enabled: pv === PV_V4 }) const points = useMemo(() => { @@ -121,9 +118,7 @@ export function useProjectTimeline({ const points: ProjectTimelinePoint[] = [] for (let i = 0; i < COUNT; i++) { - const point = (queryResult as ProjectTlQuery)[ - `p${i}` as keyof typeof queryResult - ] + const point = (queryResult as ProjectTlQuery)[`p${i}` as keyof typeof queryResult] if (!point) continue if (exceptionTimestamp && exceptionTimestamp > timestamps[i]) { @@ -149,7 +144,7 @@ export function useProjectTimeline({ } return points - }, [timestamps, v1v2v3QueryResult, v4QueryResult, pv]) + }, [timestamps, v1v2v3QueryResult, v4QueryResult, pv, projectId, exceptionTimestamp, romanStormData?.projects]) return { points, diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx index 7341ebabb9..27db6ece2d 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityList.tsx @@ -22,14 +22,14 @@ export function V4ActivityList() { // TODO: pageSize (pagination) const { data: payEventsData, isLoading } = useSubgraphQuery({ - document: PayEventsDocument, + document: PayEventsDocument, variables: { orderBy: PayEvent_OrderBy.timestamp, orderDirection: OrderDirection.desc, where: { projectId: Number(projectId), }, - }, + } }) const payEvents = transformPayEventsRes(payEventsData) ?? [] diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx index b1428fbbee..cb8274affc 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx @@ -1,7 +1,8 @@ import { Trans } from '@lingui/macro' import { ErrorBoundaryCallout } from 'components/Callout/ErrorBoundaryCallout' import Loading from 'components/Loading' -// import VolumeChart from 'components/VolumeChart' +import VolumeChart from 'components/VolumeChart' +import { PV_V4 } from 'constants/pv' import { useJBContractContext } from 'juice-sdk-react' import { ProjectsDocument } from 'packages/v4/graphql/client/graphql' import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' @@ -11,14 +12,14 @@ import { V4ActivityList } from './V4ActivityList' export function V4ActivityPanel() { const { projectId } = useJBContractContext() const { data } = useSubgraphQuery({ - document: ProjectsDocument, + document: ProjectsDocument, variables: { where: { projectId: Number(projectId), }, - }, + } }) - + const createdAt = data?.projects?.[0].createdAt return ( @@ -29,12 +30,12 @@ export function V4ActivityPanel() { Volume chart failed to load.} > - {/* */} + />
From 779b765016123ba41546dd8b06a76279b601a9d0 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Tue, 10 Sep 2024 07:49:39 +0800 Subject: [PATCH 12/44] feat: v4 deploy erc20 (#4453) --- src/locales/messages.pot | 3 + .../v4/hooks/useProjectHasErc20Token.ts | 8 ++ .../v4/hooks/useV4IssueErc20TokenTx.ts | 67 ++++++++++ src/packages/v4/models/transactions.ts | 5 + .../CreateErc20TokenSettingsPage.tsx | 125 ++++++++++++++++++ .../ProjectSettingsContent.tsx | 3 +- 6 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/packages/v4/hooks/useProjectHasErc20Token.ts create mode 100644 src/packages/v4/hooks/useV4IssueErc20TokenTx.ts create mode 100644 src/packages/v4/models/transactions.ts create mode 100644 src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx diff --git a/src/locales/messages.pot b/src/locales/messages.pot index 5f24b83532..7abc7cc37a 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -1313,6 +1313,9 @@ msgstr "" msgid "Migrate payment terminal" msgstr "" +msgid "Failed to create ERC20 token: {0}" +msgstr "" + msgid "Payments" msgstr "" diff --git a/src/packages/v4/hooks/useProjectHasErc20Token.ts b/src/packages/v4/hooks/useProjectHasErc20Token.ts new file mode 100644 index 0000000000..a38b254f4a --- /dev/null +++ b/src/packages/v4/hooks/useProjectHasErc20Token.ts @@ -0,0 +1,8 @@ +import { useReadJbTokensTokenOf } from 'juice-sdk-react' +import { isZeroAddress } from 'utils/address' + +export const useProjectHasErc20Token = () => { + const { data: tokenAddress } = useReadJbTokensTokenOf() + + return Boolean(tokenAddress && !isZeroAddress(tokenAddress)) +} diff --git a/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts b/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts new file mode 100644 index 0000000000..237bb92a42 --- /dev/null +++ b/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts @@ -0,0 +1,67 @@ +import { useCallback, useContext } from 'react' + +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useJBContractContext, useWriteJbTokensDeployErc20For } from 'juice-sdk-react' +import { zeroAddress } from 'viem' +import { BaseTxOpts } from '../models/transactions' + +export function useV4IssueErc20TokenTx() { + const { addTransaction } = useContext(TxHistoryContext) + const { projectId, contracts } = useJBContractContext() + + const { writeContractAsync: deployErc20 } = useWriteJbTokensDeployErc20For() + + return useCallback ( + async ({ name, symbol }: { + name: string + symbol: string + }, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: BaseTxOpts + ) => { + if ( + !projectId || !name || !symbol + ) { + return + } + + const args = [projectId, name, symbol, `${zeroAddress}000000000000000000000000`] as const + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbTokensAbi, // ABI of the contract + // functionName: 'deployErc20For', + // args, + // }) + + const hash = await deployErc20({ + args, + }) + + onTransactionPendingCallback(hash) + addTransaction?.('Edit Ruleset', { hash }) + // const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( + // wagmiConfig, + // { + // hash, + // }, + // ) + + onTransactionConfirmedCallback() + } catch (e) { + onTransactionErrorCallback( + (e as Error) ?? new Error('Transaction failed'), + ) + } + }, + [ + deployErc20, + projectId, + addTransaction, + ], + ) +} diff --git a/src/packages/v4/models/transactions.ts b/src/packages/v4/models/transactions.ts new file mode 100644 index 0000000000..2aa9d8411e --- /dev/null +++ b/src/packages/v4/models/transactions.ts @@ -0,0 +1,5 @@ +export interface BaseTxOpts { + onTransactionPending: (hash?: `0x${string}`) => void + onTransactionConfirmed: (hash?: `0x${string}`) => void + onTransactionError: (error: Error) => void +} diff --git a/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx b/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx new file mode 100644 index 0000000000..d2fded6190 --- /dev/null +++ b/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx @@ -0,0 +1,125 @@ +import { Trans, t } from '@lingui/macro' +import { Button, Form, Input } from 'antd' +import { IssueErc20TokenTxArgs } from 'components/buttons/IssueErc20TokenButton' +import TransactionModal from 'components/modals/TransactionModal' +import { ISSUE_ERC20_EXPLANATION } from 'components/strings' +import { useProjectHasErc20Token } from 'packages/v4/hooks/useProjectHasErc20Token' +import { useV4IssueErc20TokenTx } from 'packages/v4/hooks/useV4IssueErc20TokenTx' +import { useV4WalletHasPermission } from 'packages/v4/hooks/useV4WalletHasPermission' +import { V4OperatorPermission } from 'packages/v4/models/v4Permissions' +import { useState } from 'react' +import { emitErrorNotification } from 'utils/notifications' + +export function CreateErc20TokenSettingsPage() { + const [form] = Form.useForm() + const [loading, setLoading] = useState() + const [transactionModalOpen, setTransactionModalOpen] = + useState(false) + const [transactionPending, setTransactionPending] = useState(false) +const issueErc20TokenTx = useV4IssueErc20TokenTx() + const projectHasErc20Token = useProjectHasErc20Token() + const hasIssueTicketsPermission = useV4WalletHasPermission( + V4OperatorPermission.DEPLOY_ERC20, + ) + + const canCreateErc20Token = !projectHasErc20Token && hasIssueTicketsPermission + + async function onSetENSNameFormSaved(values: IssueErc20TokenTxArgs) { + await form.validateFields() + + if (!issueErc20TokenTx) { + emitErrorNotification(t`ERC20 transaction not ready. Try again.`) + return + } + + setLoading(true) + + issueErc20TokenTx( + { name: values.name, symbol: values.symbol }, + { + onTransactionPending: () => { + setTransactionPending(true) + setTransactionModalOpen(true) + }, + onTransactionConfirmed: () => { + setTransactionPending(false) + setTransactionModalOpen(false) + setLoading(false) + setTimeout(() => { + window.location.reload() + }, 1000) + }, + onTransactionError: (e: Error) => { + setTransactionPending(false) + setTransactionModalOpen(false) + setLoading(false) + emitErrorNotification(e.message) + emitErrorNotification( + t`Failed to create ERC20 token: ${e.message}`, + ) + }, + }, + ) + } + + if (!canCreateErc20Token) { + return ( +
+

+ Token is already created or you do not have permission to create it. +

+
+ ) + } + + return ( + <> +

{ISSUE_ERC20_EXPLANATION}

+
+ + + + + + form.setFieldsValue({ symbol: e.target.value.toUpperCase() }) + } + /> + + +
+ + setTransactionModalOpen(false)} + onOk={() => setTransactionModalOpen(false)} + confirmLoading={loading} + centered + /> + + ) +} diff --git a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx index b15fb65127..d49acdaf0a 100644 --- a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx +++ b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx @@ -5,6 +5,7 @@ import { Button, Layout } from 'antd' import Link from 'next/link' import { useMemo } from 'react' import { twJoin } from 'tailwind-merge' +import { CreateErc20TokenSettingsPage } from './CreateErc20TokenSettingsPage' import { EditCyclePage } from './EditCyclePage/EditCyclePage' import { useSettingsPagePath } from './hooks/useSettingsPagePath' import { ProjectDetailsSettingsPage } from './ProjectDetailsSettingsPage/ProjectDetailsSettingsPage' @@ -23,7 +24,7 @@ const SettingsPageComponents: { transferownership: () => null, //TransferOwnershipSettingsPage, archiveproject: () => null, //ArchiveProjectSettingsPage, heldfees: () => null, //ProcessHeldFeesPage, - createerc20: () => null, //CreateErc20TokenSettingsPage, + createerc20: CreateErc20TokenSettingsPage, } const V4SettingsPageKeyTitleMap = ( From 9a0409c32b04ccad34f68a647e5d5309f60f8b78 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Tue, 10 Sep 2024 15:12:41 +1000 Subject: [PATCH 13/44] fix v4 create (#4454) --- .../Create/hooks/useLoadInitialStateFromQuery.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts index c437b7a4fd..d4d98c223a 100644 --- a/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts +++ b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts @@ -1,5 +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' import { ProjectTokensSelection } from 'models/projectTokenSelection' @@ -17,6 +16,7 @@ import { import { CreateState, ProjectState } from 'redux/slices/editingV2Project/types' import { isEqualAddress } from 'utils/address' import { parseWad } from 'utils/format/formatNumber' +import { zeroAddress } from 'viem' import { DefaultSettings as DefaultTokenSettings } from '../components/pages/ProjectToken/hooks/useProjectTokenForm' import { projectTokenSettingsToReduxFormat } from '../utils/projectTokenSettingsToReduxFormat' @@ -117,12 +117,11 @@ const parseCreateFlowStateFromInitialState = ( 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 + if (!router.isReady) return const { initialState } = router.query if (!initialState) { @@ -161,7 +160,7 @@ export function useLoadingInitialStateFromQuery() { { ...DEFAULT_REDUX_STATE.fundAccessConstraints[0], ...parsedInitialState.fundAccessConstraints[0], - terminal: contracts.primaryNativeTerminal.data, + terminal: zeroAddress, // filled later token: ETH_TOKEN_ADDRESS, }, ], @@ -172,7 +171,7 @@ export function useLoadingInitialStateFromQuery() { console.warn('Error parsing initialState:', e) } setLoading(false) - }, [router, dispatch, contracts.primaryNativeTerminal.data]) + }, [router, dispatch]) return loading } From c2ad930222dcbc43ec6a1ab65e174d91307a359d Mon Sep 17 00:00:00 2001 From: Johnny D Date: Tue, 10 Sep 2024 19:45:08 +1000 Subject: [PATCH 14/44] feat: v4 create erc20 button and add token to metamask button (#4455) --- .../V4TokensPanel/V4TokensPanel.tsx | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx index f81b0485cd..a302700ae0 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx @@ -7,8 +7,15 @@ import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/Titl // import { ReservedTokensSubPanel } from './components/ReservedTokensSubPanel' // import { TokenRedemptionCallout } from './components/TokenRedemptionCallout' // import { TransferUnclaimedTokensModalWrapper } from './components/TransferUnclaimedTokensModalWrapper' +import { SettingOutlined } from '@ant-design/icons' +import { Button, Tooltip } from 'antd' +import { AddTokenToMetamaskButton } from 'components/buttons/AddTokenToMetamaskButton' +import { ISSUE_ERC20_EXPLANATION } from 'components/strings' +import { useJBContractContext } from 'juice-sdk-react' import { V4TokenHoldersModal } from 'packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal' +import { v4ProjectRoute } from 'packages/v4/utils/routes' import { useCallback, useState } from 'react' +import { useChainId } from 'wagmi' import { useV4TokensPanel } from './hooks/useV4TokensPanel' import { useV4YourBalanceMenuItems } from './hooks/useV4YourBalanceMenuItems' import { V4ReservedTokensSubPanel } from './V4ReservedTokensSubPanel' @@ -163,6 +170,10 @@ export const V4TokensPanel = () => { } const ProjectTokenCard = () => { + const chainId = useChainId() + const { projectId: projectIdBig } = useJBContractContext() + const projectId = Number(projectIdBig) + const { projectToken, projectTokenAddress, @@ -184,15 +195,27 @@ const ProjectTokenCard = () => { )}
- {/* {projectTokenAddress && projectHasErc20Token && ( + {projectTokenAddress && projectHasErc20Token && ( - )} */} - {/* {canCreateErc20Token && ( - - )} */} + )} + {canCreateErc20Token ? ( + + + + + + ): null} } /> From 0a44f614934c4ece62cbdcdcdbcde3651a535c9c Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 11 Sep 2024 13:12:34 +1000 Subject: [PATCH 15/44] Allow v4 create to launch (#4456) --- src/packages/v4/hooks/useLaunchProjectTx.ts | 4 ++-- src/packages/v4/utils/launchProject.ts | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/packages/v4/hooks/useLaunchProjectTx.ts b/src/packages/v4/hooks/useLaunchProjectTx.ts index 38ffcfe689..196f4da95e 100644 --- a/src/packages/v4/hooks/useLaunchProjectTx.ts +++ b/src/packages/v4/hooks/useLaunchProjectTx.ts @@ -64,7 +64,7 @@ export function useLaunchProjectTx() { const { writeContractAsync: writeLaunchProject } = useWriteJbControllerLaunchProjectFor() - const chainId = useCurrentRouteChainId() + const chainId = useCurrentRouteChainId() ?? 84532 // Default to Sepolia. const terminalAddress = chainId ? SUPPORTED_JB_MULTITERMINAL_ADDRESS[chainId] : undefined @@ -120,7 +120,7 @@ export function useLaunchProjectTx() { const args = transformV2V3CreateArgsToV4({ v2v3Args, primaryNativeTerminal: terminalAddress, - tokenAddress: NATIVE_TOKEN, + currencyTokenAddress: NATIVE_TOKEN, }) try { diff --git a/src/packages/v4/utils/launchProject.ts b/src/packages/v4/utils/launchProject.ts index 0be03d8a6d..94827a1c58 100644 --- a/src/packages/v4/utils/launchProject.ts +++ b/src/packages/v4/utils/launchProject.ts @@ -1,4 +1,3 @@ -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"; @@ -19,11 +18,11 @@ export type LaunchV2V3ProjectArgs = [ export function transformV2V3CreateArgsToV4({ v2v3Args, primaryNativeTerminal, - tokenAddress + currencyTokenAddress }: { v2v3Args: LaunchV2V3ProjectArgs, primaryNativeTerminal: `0x${string}` - tokenAddress: `0x${string}` + currencyTokenAddress: `0x${string}` }) { const [ _owner, @@ -38,10 +37,9 @@ export function transformV2V3CreateArgsToV4({ ] = v2v3Args; const mustStartAtOrAfterNum = parseInt(_mustStartAtOrAfter) - const now = round(new Date().getTime() / 1000) const rulesetConfigurations = [{ - mustStartAtOrAfter: mustStartAtOrAfterNum > now ? mustStartAtOrAfterNum : now, + mustStartAtOrAfter: mustStartAtOrAfterNum ?? 0, // 0 denotes start immediately duration: _data.duration.toNumber(), weight: _data.weight.toBigInt(), decayPercent: _data.discountRate.toNumber(), @@ -84,7 +82,7 @@ export function transformV2V3CreateArgsToV4({ fundAccessLimitGroups: _fundAccessConstraints.map(constraint => ({ terminal: primaryNativeTerminal, - token: tokenAddress, + token: currencyTokenAddress, payoutLimits: [{ amount: constraint.distributionLimit.toBigInt(), currency: constraint.distributionLimitCurrency.toNumber(), @@ -98,7 +96,14 @@ export function transformV2V3CreateArgsToV4({ const terminalConfigurations = _terminals.map(terminal => ({ terminal: terminal as `0x${string}`, - accountingContextsToAccept: [] as const, + accountingContextsToAccept: [ + // @v4todo: + // { + // token: currencyTokenAddress, + // decimals: 18, + // currency: 0 + // } + ] as const, })); return [ From cf26228ebb709eaaa775f5c64483a5e01bc40ba9 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 11 Sep 2024 15:58:37 +1000 Subject: [PATCH 16/44] feat: v4 archive projects (#4457) --- src/locales/messages.pot | 9 + .../ArchiveProjectSettingsPage.tsx | 167 ++++++++++++++++++ .../ProjectDetailsSettingsPage.tsx | 15 +- .../ProjectSettingsContent.tsx | 19 +- .../ProjectSettingsDashboard.tsx | 32 ++-- 5 files changed, 207 insertions(+), 35 deletions(-) create mode 100644 src/packages/v4/views/V4ProjectSettings/ArchiveProjectSettingsPage.tsx diff --git a/src/locales/messages.pot b/src/locales/messages.pot index 7abc7cc37a..91a9ce5d62 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -227,6 +227,9 @@ msgstr "" msgid "Error downloading participants, try again." msgstr "" +msgid "Edit next ruleset" +msgstr "" + msgid "Set a future date & time to start your project's first cycle." msgstr "" @@ -1427,6 +1430,9 @@ msgstr "" msgid "What do we value?" msgstr "" +msgid "Ruleset configuration" +msgstr "" + msgid "Add a brief one-sentence summary of your project." msgstr "" @@ -3815,6 +3821,9 @@ msgstr "" msgid "DeFi" msgstr "" +msgid "Make changes to your ruleset settings and rules" +msgstr "" + msgid "While enabled, this project will use the custom behavior defined in the contract above when somebody redeems from this project. Exercise caution." msgstr "" diff --git a/src/packages/v4/views/V4ProjectSettings/ArchiveProjectSettingsPage.tsx b/src/packages/v4/views/V4ProjectSettings/ArchiveProjectSettingsPage.tsx new file mode 100644 index 0000000000..4757ec3611 --- /dev/null +++ b/src/packages/v4/views/V4ProjectSettings/ArchiveProjectSettingsPage.tsx @@ -0,0 +1,167 @@ +import { Trans } from '@lingui/macro' +import { Button, Statistic } from 'antd' +import { Callout } from 'components/Callout/Callout' +import { useJBProjectMetadataContext } from 'juice-sdk-react' +import { uploadProjectMetadata } from 'lib/api/ipfs' +import { useEditProjectDetailsTx } from 'packages/v4/hooks/useEditProjectDetailsTx' +import { useCallback, useState } from 'react' +import { emitErrorNotification, emitInfoNotification } from 'utils/notifications' + +export function ArchiveProjectSettingsPage() { + const [loading, setLoading] = useState(false) + + const editV4ProjectDetailsTx = useEditProjectDetailsTx() + const { metadata } = useJBProjectMetadataContext() + + const projectMetadata = metadata.data + + const setArchived = useCallback(async (archived: boolean) => { + if (!projectMetadata) return + + setLoading(true) + + const uploadedMetadata = await uploadProjectMetadata({ + ...projectMetadata, + archived, + }) + + if (!uploadedMetadata.Hash) { + setLoading(false) + return + } + + editV4ProjectDetailsTx( + uploadedMetadata.Hash as `0x${string}`, { + onTransactionPending: () => null, + onTransactionConfirmed: () => { + setLoading(false) + emitInfoNotification('Project archived', { + description: 'Your project has been archived.', + }) + + // v4Todo: part of v2, not sure if necessary + // if (projectId) { + // await revalidateProject({ + // pv: PV_V4, + // projectId: String(projectId), + // }) + // } + }, + onTransactionError: (error: unknown) => { + console.error(error) + setLoading(false) + emitErrorNotification(`Error launching ruleset: ${error}`) + }, + } + ) + }, [ + editV4ProjectDetailsTx, + projectMetadata, + ]) + + if (projectMetadata?.archived) { + return ( +
+ Project state} + valueRender={() => Archived} + /> + +
+

+ Unarchiving your project has the following effects: +

+ +
    +
  • + Your project will appear as 'active'. +
  • +
  • + + Your project can receive payments through the juicebox.money + app. + +
  • +
+
+ +

+ + Allow a few days for your project to appear in the "active" projects + list on the Projects page. + +

+
+ +
+
+ ) + } + + return ( +
+ Project state} + valueRender={() => Active} + /> + +
+

+ Archiving your project has the following effects: +

+ +
    +
  • + Your project will appear as 'archived'. +
  • +
  • + + Your project can't receive payments through the juicebox.money + app. + +
  • +
  • + + Unless payments to this project are paused in your cycle's + rules, your project can still receive payments directly through + the Juicebox protocol contracts. + +
  • +
+
+ +
+

+ + Allow a few days for your project to appear in the "archived" + projects list on the Projects page. + +

+ + + You can unarchive your project at any time. + +
+ +
+ +
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx b/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx index 2a81156d71..cd23f05410 100644 --- a/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx +++ b/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx @@ -1,18 +1,17 @@ import { useForm } from 'antd/lib/form/Form' import { ProjectDetailsForm, ProjectDetailsFormFields } from 'components/Project/ProjectSettings/ProjectDetailsForm' import { PROJECT_PAY_CHARACTER_LIMIT } from 'constants/numbers' -import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' +import { useJBProjectMetadataContext } from 'juice-sdk-react' import { uploadProjectMetadata } from 'lib/api/ipfs' import { useEditProjectDetailsTx } from 'packages/v4/hooks/useEditProjectDetailsTx' -import { useCallback, useContext, useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { withoutHttps } from 'utils/http' import { emitErrorNotification, emitInfoNotification } from 'utils/notifications' export function ProjectDetailsSettingsPage() { - const { projectMetadata, refetchProjectMetadata } = useContext( - ProjectMetadataContext, - ) + const { metadata } = useJBProjectMetadataContext() + const projectMetadata = metadata.data const [loadingSaveChanges, setLoadingSaveChanges] = useState() const [projectForm] = useForm() @@ -63,7 +62,6 @@ export function ProjectDetailsSettingsPage() { // projectId: String(projectId), // }) // } - refetchProjectMetadata() }, onTransactionError: (error: unknown) => { console.error(error) @@ -75,7 +73,6 @@ export function ProjectDetailsSettingsPage() { }, [ editProjectDetailsTx, projectForm, - refetchProjectMetadata, projectMetadata, ]) @@ -90,8 +87,7 @@ export function ProjectDetailsSettingsPage() { coverImageUri: projectMetadata?.coverImageUri ?? '', description: projectMetadata?.description ?? '', projectTagline: projectMetadata?.projectTagline ?? '', - projectRequiredOFACCheck: - projectMetadata?.projectRequiredOFACCheck ?? false, + projectRequiredOFACCheck: false, // OFAC not supported in V4 yet twitter: projectMetadata?.twitter ?? '', discord, telegram, @@ -107,7 +103,6 @@ export function ProjectDetailsSettingsPage() { projectMetadata?.coverImageUri, projectMetadata?.description, projectMetadata?.projectTagline, - projectMetadata?.projectRequiredOFACCheck, projectMetadata?.twitter, projectMetadata?.discord, projectMetadata?.telegram, diff --git a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx index d49acdaf0a..c9ff01032d 100644 --- a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx +++ b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx @@ -5,6 +5,7 @@ import { Button, Layout } from 'antd' import Link from 'next/link' import { useMemo } from 'react' import { twJoin } from 'tailwind-merge' +import { ArchiveProjectSettingsPage } from './ArchiveProjectSettingsPage' import { CreateErc20TokenSettingsPage } from './CreateErc20TokenSettingsPage' import { EditCyclePage } from './EditCyclePage/EditCyclePage' import { useSettingsPagePath } from './hooks/useSettingsPagePath' @@ -16,14 +17,14 @@ const SettingsPageComponents: { [k in SettingsPageKey]: () => JSX.Element | null } = { general: ProjectDetailsSettingsPage, - handle: () => null, //ProjectHandleSettingsPage, + // handle: () => null, //ProjectHandleSettingsPage, cycle: EditCyclePage, // nfts: () => null, //EditNftsPage, payouts: () => null, //PayoutsSettingsPage, - reservedtokens: () => null, //ReservedTokensSettingsPage, - transferownership: () => null, //TransferOwnershipSettingsPage, - archiveproject: () => null, //ArchiveProjectSettingsPage, - heldfees: () => null, //ProcessHeldFeesPage, + // reservedtokens: () => null, //ReservedTokensSettingsPage, + // transferownership: () => null, //TransferOwnershipSettingsPage, + archiveproject: ArchiveProjectSettingsPage, + // heldfees: () => null, //ProcessHeldFeesPage, createerc20: CreateErc20TokenSettingsPage, } @@ -33,14 +34,14 @@ const V4SettingsPageKeyTitleMap = ( [k in SettingsPageKey]: string } => ({ general: t`General`, - handle: t`Project handle`, + // handle: t`Project handle`, cycle: t`Cycle configuration`, payouts: t`Payouts`, - reservedtokens: t`Reserved token recipients`, + // reservedtokens: t`Reserved token recipients`, // nfts: hasExistingNfts ? t`Edit NFT collection` : t`Launch New NFT Collection`, - transferownership: t`Transfer ownership`, + // transferownership: t`Transfer ownership`, archiveproject: t`Archive project`, - heldfees: t`Process held fees`, + // heldfees: t`Process held fees`, createerc20: t`Create ERC-20 token`, }) diff --git a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsDashboard.tsx b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsDashboard.tsx index da2c5c8d41..67382b8867 100644 --- a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsDashboard.tsx +++ b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsDashboard.tsx @@ -14,14 +14,14 @@ import { useSettingsPagePath } from './hooks/useSettingsPagePath' export type SettingsPageKey = | 'general' - | 'handle' + // | 'handle' -> commenting out not necessary for v4 | 'cycle' // | 'nfts' | 'payouts' - | 'reservedtokens' - | 'transferownership' + // | 'reservedtokens' + // | 'transferownership' | 'archiveproject' - | 'heldfees' + // | 'heldfees' | 'createerc20' function SettingsCard({ children }: { children: React.ReactNode }) { @@ -146,6 +146,11 @@ export function ProjectSettingsDashboard() { Basic details +
  • + + Archive + +
  • {/*
  • Project handle @@ -155,13 +160,13 @@ export function ProjectSettingsDashboard() { Cycle configuration} + title={Ruleset configuration} subtitle={ - Make changes to your cycle settings and rules + Make changes to your ruleset settings and rules } > - Edit next cycle + Edit next ruleset
  • )} -
  • + {/*
  • Process held fees -
  • + */} - Manage} subtitle={Manage your project's state and ownership} > @@ -193,13 +198,8 @@ export function ProjectSettingsDashboard() { Transfer ownership -
  • - - Archive - -
  • -
    + */}
    From 2be05b6c88dc0b8adcef10af3e0d1b6e716c4d17 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 13 Sep 2024 23:10:52 +1000 Subject: [PATCH 17/44] feat: add v4 projects to db (#4458) --- src/graphql/projects/dbV4Projects.graphql | 25 +++++++++++++++++++ src/lib/api/supabase/projects/api.ts | 30 +++++++++++++++++------ src/lib/apollo/serverClient.ts | 13 +++++++--- src/lib/apollo/subgraphUri.ts | 25 +++++++++++++++++++ src/utils/sgDbProjects.ts | 3 +-- 5 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 src/graphql/projects/dbV4Projects.graphql diff --git a/src/graphql/projects/dbV4Projects.graphql b/src/graphql/projects/dbV4Projects.graphql new file mode 100644 index 0000000000..6101335916 --- /dev/null +++ b/src/graphql/projects/dbV4Projects.graphql @@ -0,0 +1,25 @@ +query DBV4Projects($first: Int, $skip: Int) { + projects(first: $first, skip: $skip) { + id + projectId + handle + # uri + currentBalance + volume + volumeUSD + redeemVolume + redeemVolumeUSD + redeemCount + creator + owner + contributorsCount + nftsMintedCount + createdAt + trendingScore + trendingVolume + deployer + paymentsCount + trendingPaymentsCount + createdWithinTrendingWindow + } +} diff --git a/src/lib/api/supabase/projects/api.ts b/src/lib/api/supabase/projects/api.ts index b6e5679c2b..73ddaec6e4 100644 --- a/src/lib/api/supabase/projects/api.ts +++ b/src/lib/api/supabase/projects/api.ts @@ -1,18 +1,21 @@ import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs' import { V2_BLOCKLISTED_PROJECTS } from 'constants/blocklist' +import { PV_V4 } from 'constants/pv' import { DbProjectsDocument, DbProjectsQuery, + Dbv4ProjectsDocument, Project, QueryProjectsArgs, } from 'generated/graphql' import { paginateDepleteQuery } from 'lib/apollo/paginateDepleteQuery' -import { serverClient } from 'lib/apollo/serverClient' +import { serverClient, v4ServerClient } from 'lib/apollo/serverClient' import { DBProject, DBProjectQueryOpts, SGSBCompareKey } from 'models/dbProject' import { Json } from 'models/json' import { NextApiRequest, NextApiResponse } from 'next' import { Database } from 'types/database.types' import { isHardArchived } from 'utils/archived' +import { getSubgraphIdForProject } from 'utils/graph' import { formatDBProjectRow, formatSGProjectForDB, @@ -23,15 +26,28 @@ import { dbProjects } from '../clients' * Query all projects from subgraph using apollo serverClient which is safe to use in edge runtime. */ export async function queryAllSGProjectsForServer() { - const res = await paginateDepleteQuery({ - client: serverClient, - document: DbProjectsDocument, - }) + const [res, resV4] = await Promise.all([ + paginateDepleteQuery({ + client: serverClient, + document: DbProjectsDocument, + }), + paginateDepleteQuery({ + client: v4ServerClient, + document: Dbv4ProjectsDocument, + }), + ]) // Response must be retyped with Json<>, because the serverClient does not perform the parsing expected by generated types const _res = res as unknown as Json>[] - - return _res.map(formatSGProjectForDB) + const _resV4 = resV4.map(p => { + return { + ...p, + id: getSubgraphIdForProject(PV_V4, p.projectId), // Patch in the subgraph ID for V4 projects (to be consitent with legacy subgraph) + pv: PV_V4, // Patch in the PV for V4 projects + } + }) as unknown as Json>[] + + return [..._res, ..._resV4].map(formatSGProjectForDB) } /** diff --git a/src/lib/apollo/serverClient.ts b/src/lib/apollo/serverClient.ts index d023d1255e..64617ecd3d 100644 --- a/src/lib/apollo/serverClient.ts +++ b/src/lib/apollo/serverClient.ts @@ -1,13 +1,20 @@ import { ApolloClient, InMemoryCache } from '@apollo/client' -import { subgraphUri } from './subgraphUri' +import { subgraphUri, v4SubgraphUri } from './subgraphUri' /** - * Unlike `client`, `serverClient` is safe to use in the edge runtime. However this client does not perform parsing on the response, meaning returned objects may not match the auto-generated types. + * Unlike `client`, `serverClient` is safe to use in the edge runtime. + * However, this client does not perform parsing on the response, + * meaning returned objects may not match the auto-generated types. */ const serverClient = new ApolloClient({ uri: subgraphUri(), cache: new InMemoryCache(), }) -export { serverClient } +const v4ServerClient = new ApolloClient({ + uri: v4SubgraphUri(), + cache: new InMemoryCache(), +}) + +export { serverClient, v4ServerClient } diff --git a/src/lib/apollo/subgraphUri.ts b/src/lib/apollo/subgraphUri.ts index d82b4506a2..e5880aa384 100644 --- a/src/lib/apollo/subgraphUri.ts +++ b/src/lib/apollo/subgraphUri.ts @@ -20,5 +20,30 @@ export const subgraphUri = () => { if (url.pathname.match(/graphql$/g)) { return url.href.slice(0, url.href.lastIndexOf('/')) } + + return url.href +} + +export const v4SubgraphUri = () => { + let uri: string | undefined + if (isBrowser()) { + uri = process.env.NEXT_PUBLIC_V4_SUBGRAPH_URL + if (!uri) { + throw new Error( + 'NEXT_PUBLIC_V4_SUBGRAPH_URL environment variable not defined', + ) + } + } else { + uri = process.env.V4_SUBGRAPH_URL + if (!uri) { + throw new Error('V4_SUBGRAPH_URL environment variable not defined') + } + } + + const url = new URL(uri) + if (url.pathname.match(/graphql$/g)) { + return url.href.slice(0, url.href.lastIndexOf('/')) + } + return url.href } diff --git a/src/utils/sgDbProjects.ts b/src/utils/sgDbProjects.ts index d696e65d0f..53ed23dd82 100644 --- a/src/utils/sgDbProjects.ts +++ b/src/utils/sgDbProjects.ts @@ -1,3 +1,4 @@ +import { Project } from 'generated/graphql' import { ipfsGatewayFetch } from 'lib/api/ipfs' import { DBProject, DBProjectRow, SGSBCompareKey } from 'models/dbProject' import { Json } from 'models/json' @@ -8,8 +9,6 @@ import { } from 'models/project-tags' import { ProjectMetadata, consolidateMetadata } from 'models/projectMetadata' import { PV } from 'models/pv' - -import { Project } from 'generated/graphql' import { formatError } from './format/formatError' import { parseBigNumberKeyVals } from './graph' import { isIpfsCID } from './ipfs' From be5f0b61b1f27f9f30d5196cf615a25de4b77550 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Sat, 14 Sep 2024 08:23:17 +1000 Subject: [PATCH 18/44] fix: update projects_pv_check --- supabase/migrations/20240913212503_project_pv_v4.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 supabase/migrations/20240913212503_project_pv_v4.sql diff --git a/supabase/migrations/20240913212503_project_pv_v4.sql b/supabase/migrations/20240913212503_project_pv_v4.sql new file mode 100644 index 0000000000..70e44771a0 --- /dev/null +++ b/supabase/migrations/20240913212503_project_pv_v4.sql @@ -0,0 +1,6 @@ +ALTER TABLE public.projects +DROP CONSTRAINT projects_pv_check; + +ALTER TABLE public.projects +ADD CONSTRAINT projects_pv_check +CHECK (pv IN ('1', '2', '4')); \ No newline at end of file From f809ddfb0b39476a0898c1b4863a4b2b38caa3cb Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Sun, 15 Sep 2024 06:30:36 +1000 Subject: [PATCH 19/44] feat: add chain_id col to projects --- supabase/migrations/20240913212503_project_pv_v4.sql | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/supabase/migrations/20240913212503_project_pv_v4.sql b/supabase/migrations/20240913212503_project_pv_v4.sql index 70e44771a0..78bac6e8ec 100644 --- a/supabase/migrations/20240913212503_project_pv_v4.sql +++ b/supabase/migrations/20240913212503_project_pv_v4.sql @@ -3,4 +3,7 @@ DROP CONSTRAINT projects_pv_check; ALTER TABLE public.projects ADD CONSTRAINT projects_pv_check -CHECK (pv IN ('1', '2', '4')); \ No newline at end of file +CHECK (pv IN ('1', '2', '4')); + +ALTER TABLE public.projects +add COLUMN "chain_id" int \ No newline at end of file From 8cbd43e4f113198bc2060d8d3b151120eecdd426 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Wed, 18 Sep 2024 08:04:24 +1000 Subject: [PATCH 20/44] drop pv check --- supabase/migrations/20240913212504_project_pv_v4_drop.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 supabase/migrations/20240913212504_project_pv_v4_drop.sql diff --git a/supabase/migrations/20240913212504_project_pv_v4_drop.sql b/supabase/migrations/20240913212504_project_pv_v4_drop.sql new file mode 100644 index 0000000000..6ef0acf4b1 --- /dev/null +++ b/supabase/migrations/20240913212504_project_pv_v4_drop.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.projects +DROP CONSTRAINT projects_pv_check; \ No newline at end of file From 5cf461030a46b9078b4931d5daa608947b34b8b7 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Thu, 19 Sep 2024 08:43:06 +1000 Subject: [PATCH 21/44] feat: v4 projects on proejcts page --- .../Projects/ProjectsFilterAndSort.tsx | 20 +++++++++++++++---- src/components/Projects/ProjectsView.tsx | 11 +++++++--- src/lib/api/supabase/projects/api.ts | 12 +++++++---- .../v4/graphql/queries}/dbV4Projects.graphql | 2 +- 4 files changed, 33 insertions(+), 12 deletions(-) rename src/{graphql/projects => packages/v4/graphql/queries}/dbV4Projects.graphql (97%) diff --git a/src/components/Projects/ProjectsFilterAndSort.tsx b/src/components/Projects/ProjectsFilterAndSort.tsx index 29cdbb4123..3abd96435c 100644 --- a/src/components/Projects/ProjectsFilterAndSort.tsx +++ b/src/components/Projects/ProjectsFilterAndSort.tsx @@ -21,8 +21,13 @@ export type CheckboxOnChange = (checked: boolean) => void export default function ProjectsFilterAndSort({ includeV1, setIncludeV1, + includeV2, setIncludeV2, + + includeV4, + setIncludeV4, + showArchived, setShowArchived, searchTags, @@ -34,8 +39,13 @@ export default function ProjectsFilterAndSort({ }: { includeV1: boolean setIncludeV1: CheckboxOnChange + includeV2: boolean setIncludeV2: CheckboxOnChange + + includeV4: boolean + setIncludeV4: CheckboxOnChange + showArchived: boolean setShowArchived: CheckboxOnChange searchTags: ProjectTagName[] @@ -67,10 +77,7 @@ export default function ProjectsFilterAndSort({ return (
    + ('volume') const [includeV1, setIncludeV1] = useState(true) const [includeV2, setIncludeV2] = useState(true) + const [includeV4, setIncludeV4] = useState(true) const [showArchived, setShowArchived] = useState(false) const [reversed, setReversed] = useState(false) @@ -72,8 +73,10 @@ export function ProjectsView() { const _pv: PV[] = [] if (includeV1) _pv.push(PV_V1) if (includeV2) _pv.push(PV_V2) - return _pv.length ? _pv : [PV_V1, PV_V2] - }, [includeV1, includeV2]) + if (includeV4) _pv.push(PV_V4) + + return _pv.length ? _pv : [PV_V1, PV_V2, PV_V4] + }, [includeV1, includeV2, includeV4]) function updateRoute( _searchTags: ProjectTagName[], @@ -137,8 +140,10 @@ export function ProjectsView() { ({ + paginateDepleteQuery({ client: v4ServerClient, document: Dbv4ProjectsDocument, }), @@ -43,7 +47,8 @@ export async function queryAllSGProjectsForServer() { return { ...p, id: getSubgraphIdForProject(PV_V4, p.projectId), // Patch in the subgraph ID for V4 projects (to be consitent with legacy subgraph) - pv: PV_V4, // Patch in the PV for V4 projects + pv: PV_V4, // Patch in the PV for V4 projects, + metadataUri: p.metadata, } }) as unknown as Json>[] @@ -109,7 +114,6 @@ export async function queryDBProjects( const pageSize = opts.pageSize ?? 20 // Only sort ascending if orderBy is defined and orderDirection is 'asc' const ascending = opts.orderBy ? opts.orderDirection === 'asc' : false - const searchFilter = createSearchFilter(opts.text) const supabase = createServerSupabaseClient({ req, res }) diff --git a/src/graphql/projects/dbV4Projects.graphql b/src/packages/v4/graphql/queries/dbV4Projects.graphql similarity index 97% rename from src/graphql/projects/dbV4Projects.graphql rename to src/packages/v4/graphql/queries/dbV4Projects.graphql index 6101335916..65898c2047 100644 --- a/src/graphql/projects/dbV4Projects.graphql +++ b/src/packages/v4/graphql/queries/dbV4Projects.graphql @@ -3,7 +3,7 @@ query DBV4Projects($first: Int, $skip: Int) { id projectId handle - # uri + metadata currentBalance volume volumeUSD From d0acde481281bb52cf7f6079a4dba0ca3903f03d Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Thu, 19 Sep 2024 09:03:55 +1000 Subject: [PATCH 22/44] support chain_id in db --- src/components/ProjectCard.tsx | 20 ++++++++++------- src/lib/api/supabase/projects/api.ts | 12 +++++----- src/lib/apollo/serverClient.ts | 9 ++++---- src/lib/apollo/subgraphUri.ts | 22 ++++++++++++++++--- src/locales/messages.pot | 3 +++ src/models/dbProject.ts | 1 + src/packages/v4/graphql/codegen.yml | 2 +- src/packages/v4/hooks/useV4ProjectRoute.ts | 11 ++++++++++ src/types/database.types.ts | 3 +++ src/utils/sgDbProjects.ts | 1 + .../20240913212505_project_pv_v4_drop.sql | 2 ++ 11 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 src/packages/v4/hooks/useV4ProjectRoute.ts create mode 100644 supabase/migrations/20240913212505_project_pv_v4_drop.sql diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index 259bfda4d9..5f96ca43ce 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -1,16 +1,16 @@ import * as constants from '@ethersproject/constants' import { BookmarkIcon as BookmarkIconSolid } from '@heroicons/react/24/solid' import { Skeleton } from 'antd' -import { PV_V2 } from 'constants/pv' +import { PV_V2, PV_V4 } from 'constants/pv' import { useProjectHandleText } from 'hooks/useProjectHandleText' -import Link from 'next/link' -import { isHardArchived } from 'utils/archived' -import { formatDate } from 'utils/format/formatDate' - import { useProjectMetadata } from 'hooks/useProjectMetadata' import { useSubtitle } from 'hooks/useSubtitle' import { SubgraphQueryProject } from 'models/subgraphProjects' +import Link from 'next/link' import { v2v3ProjectRoute } from 'packages/v2v3/utils/routes' +import { v4ProjectRoute } from 'packages/v4/utils/routes' +import { isHardArchived } from 'utils/archived' +import { formatDate } from 'utils/format/formatDate' import { ArchivedBadge } from './ArchivedBadge' import Loading from './Loading' import ProjectLogo from './ProjectLogo' @@ -29,12 +29,11 @@ export default function ProjectCard({ handle: project?.handle, projectId: project?.projectId, }) - const subtitle = useSubtitle(metadata) if (!project) return null - const { volume, pv, handle, projectId, createdAt } = project + const { volume, pv, handle, projectId, createdAt, chainId } = project const tags = metadata?.tags // If the total paid is greater than 0, but less than 10 ETH, show two decimal places. @@ -58,7 +57,12 @@ export default function ProjectCard({ : `/p/${handle}` const projectCardUrl = - pv === PV_V2 + pv === PV_V4 + ? v4ProjectRoute({ + projectId, + chainId, + }) + : pv === PV_V2 ? v2v3ProjectRoute({ projectId, handle, diff --git a/src/lib/api/supabase/projects/api.ts b/src/lib/api/supabase/projects/api.ts index 76deeece76..e0669a8c9c 100644 --- a/src/lib/api/supabase/projects/api.ts +++ b/src/lib/api/supabase/projects/api.ts @@ -9,7 +9,7 @@ import { } from 'generated/graphql' import { paginateDepleteQuery } from 'lib/apollo/paginateDepleteQuery' -import { serverClient, v4ServerClient } from 'lib/apollo/serverClient' +import { serverClient, v4SepoliaServerClient } from 'lib/apollo/serverClient' import { DBProject, DBProjectQueryOpts, SGSBCompareKey } from 'models/dbProject' import { Json } from 'models/json' import { NextApiRequest, NextApiResponse } from 'next' @@ -25,34 +25,36 @@ import { formatSGProjectForDB, parseDBProjectsRow, } from 'utils/sgDbProjects' +import { sepolia } from 'viem/chains' import { dbProjects } from '../clients' /** * Query all projects from subgraph using apollo serverClient which is safe to use in edge runtime. */ export async function queryAllSGProjectsForServer() { - const [res, resV4] = await Promise.all([ + const [res, resSepoliaV4] = await Promise.all([ paginateDepleteQuery({ client: serverClient, document: DbProjectsDocument, }), paginateDepleteQuery({ - client: v4ServerClient, + client: v4SepoliaServerClient, document: Dbv4ProjectsDocument, }), ]) // Response must be retyped with Json<>, because the serverClient does not perform the parsing expected by generated types const _res = res as unknown as Json>[] - const _resV4 = resV4.map(p => { + const _resSepoliaV4 = resSepoliaV4.map(p => { return { ...p, id: getSubgraphIdForProject(PV_V4, p.projectId), // Patch in the subgraph ID for V4 projects (to be consitent with legacy subgraph) pv: PV_V4, // Patch in the PV for V4 projects, metadataUri: p.metadata, + chainId: sepolia.id, } }) as unknown as Json>[] - return [..._res, ..._resV4].map(formatSGProjectForDB) + return [..._res, ..._resSepoliaV4].map(formatSGProjectForDB) } /** diff --git a/src/lib/apollo/serverClient.ts b/src/lib/apollo/serverClient.ts index 64617ecd3d..7cf67db4c4 100644 --- a/src/lib/apollo/serverClient.ts +++ b/src/lib/apollo/serverClient.ts @@ -1,10 +1,11 @@ import { ApolloClient, InMemoryCache } from '@apollo/client' +import { sepolia } from 'viem/chains' import { subgraphUri, v4SubgraphUri } from './subgraphUri' /** * Unlike `client`, `serverClient` is safe to use in the edge runtime. - * However, this client does not perform parsing on the response, + * However, this client does not perform parsing on the response, * meaning returned objects may not match the auto-generated types. */ const serverClient = new ApolloClient({ @@ -12,9 +13,9 @@ const serverClient = new ApolloClient({ cache: new InMemoryCache(), }) -const v4ServerClient = new ApolloClient({ - uri: v4SubgraphUri(), +const v4SepoliaServerClient = new ApolloClient({ + uri: v4SubgraphUri(sepolia.id), cache: new InMemoryCache(), }) -export { serverClient, v4ServerClient } +export { serverClient, v4SepoliaServerClient } diff --git a/src/lib/apollo/subgraphUri.ts b/src/lib/apollo/subgraphUri.ts index e5880aa384..5f22d28f13 100644 --- a/src/lib/apollo/subgraphUri.ts +++ b/src/lib/apollo/subgraphUri.ts @@ -1,4 +1,7 @@ +import { JBChainId } from 'juice-sdk-react' +import process from 'process' import { isBrowser } from 'utils/isBrowser' +import { sepolia } from 'viem/chains' export const subgraphUri = () => { let uri: string | undefined @@ -24,17 +27,30 @@ export const subgraphUri = () => { return url.href } -export const v4SubgraphUri = () => { +export const v4SubgraphUri = (chainId: JBChainId) => { let uri: string | undefined + + const env: { + [k in JBChainId]?: { + browser?: string + server?: string + } + } = { + [sepolia.id]: { + browser: process.env.NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL, + server: process.env.V4_SEPOLIA_SUBGRAPH_URL, + }, + } as const + if (isBrowser()) { - uri = process.env.NEXT_PUBLIC_V4_SUBGRAPH_URL + uri = env?.[chainId]?.browser if (!uri) { throw new Error( 'NEXT_PUBLIC_V4_SUBGRAPH_URL environment variable not defined', ) } } else { - uri = process.env.V4_SUBGRAPH_URL + uri = env?.[chainId]?.server if (!uri) { throw new Error('V4_SUBGRAPH_URL environment variable not defined') } diff --git a/src/locales/messages.pot b/src/locales/messages.pot index 91a9ce5d62..a6d5c40ccf 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -3104,6 +3104,9 @@ msgstr "" msgid "Unwatch" msgstr "" +msgid "V4" +msgstr "" + msgid "Locked until <0>{value}" msgstr "" diff --git a/src/models/dbProject.ts b/src/models/dbProject.ts index 5deb193a58..4bdd1ecec9 100644 --- a/src/models/dbProject.ts +++ b/src/models/dbProject.ts @@ -36,6 +36,7 @@ export type DBProject = { projectId: number createdAt: number pv: PV + chainId: number handle: string | null metadataUri: string | null diff --git a/src/packages/v4/graphql/codegen.yml b/src/packages/v4/graphql/codegen.yml index 406a351c79..f2cd24e8f9 100644 --- a/src/packages/v4/graphql/codegen.yml +++ b/src/packages/v4/graphql/codegen.yml @@ -1,5 +1,5 @@ overwrite: true -schema: ${NEXT_PUBLIC_V4_SUBGRAPH_URL} +schema: ${NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL} documents: 'src/packages/v4/graphql/queries/**/*.graphql' generates: src/packages/v4/graphql/client/: diff --git a/src/packages/v4/hooks/useV4ProjectRoute.ts b/src/packages/v4/hooks/useV4ProjectRoute.ts new file mode 100644 index 0000000000..1e032acb8b --- /dev/null +++ b/src/packages/v4/hooks/useV4ProjectRoute.ts @@ -0,0 +1,11 @@ +import { useChainId } from 'wagmi' +import { v4ProjectRoute } from '../utils/routes' + +export function useV4ProjectRoute(projectId: number) { + const chainId = useChainId() + + return v4ProjectRoute({ + chainId, + projectId, + }) +} diff --git a/src/types/database.types.ts b/src/types/database.types.ts index 66018c63de..8bb0c1630a 100644 --- a/src/types/database.types.ts +++ b/src/types/database.types.ts @@ -191,6 +191,7 @@ export type Database = { payments_count: number project_id: number pv: string + chain_id: number redeem_count: number redeem_volume: string redeem_voume_usd: string @@ -224,6 +225,7 @@ export type Database = { payments_count: number project_id: number pv: string + chain_id: number redeem_count: number redeem_volume: string redeem_voume_usd: string @@ -257,6 +259,7 @@ export type Database = { payments_count?: number project_id?: number pv?: string + chain_id?: number redeem_count?: number redeem_volume?: string redeem_voume_usd?: string diff --git a/src/utils/sgDbProjects.ts b/src/utils/sgDbProjects.ts index 53ed23dd82..a2a2a41cc5 100644 --- a/src/utils/sgDbProjects.ts +++ b/src/utils/sgDbProjects.ts @@ -122,6 +122,7 @@ export function formatDBProjectRow( payments_count: p.paymentsCount, project_id: p.projectId, pv: p.pv, + chain_id: p.chainId, redeem_count: p.redeemCount, redeem_volume: p.redeemVolume, redeem_voume_usd: p.redeemVolumeUSD, diff --git a/supabase/migrations/20240913212505_project_pv_v4_drop.sql b/supabase/migrations/20240913212505_project_pv_v4_drop.sql new file mode 100644 index 0000000000..1c76770b44 --- /dev/null +++ b/supabase/migrations/20240913212505_project_pv_v4_drop.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.projects +add COLUMN "chain_id" int \ No newline at end of file From 82f144ffad394de77b82fed78c21d785d31be73f Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Thu, 19 Sep 2024 09:20:46 +1000 Subject: [PATCH 23/44] Support chain_id property on all sg/db projects --- src/components/ProjectCard.tsx | 4 ++-- src/lib/api/supabase/projects/api.ts | 10 ++++++++-- src/models/dbProject.ts | 4 ++-- src/pages/api/projects/health.ts | 4 +++- src/utils/sgDbProjects.ts | 9 +++++---- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index 5f96ca43ce..5b8acc8ff4 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -21,7 +21,7 @@ export default function ProjectCard({ project, bookmarked, }: { - project?: SubgraphQueryProject + project?: SubgraphQueryProject & { chainId?: number } bookmarked?: boolean }) { const { data: metadata } = useProjectMetadata(project?.metadataUri) @@ -57,7 +57,7 @@ export default function ProjectCard({ : `/p/${handle}` const projectCardUrl = - pv === PV_V4 + pv === PV_V4 && chainId ? v4ProjectRoute({ projectId, chainId, diff --git a/src/lib/api/supabase/projects/api.ts b/src/lib/api/supabase/projects/api.ts index e0669a8c9c..499d6a5165 100644 --- a/src/lib/api/supabase/projects/api.ts +++ b/src/lib/api/supabase/projects/api.ts @@ -8,6 +8,7 @@ import { QueryProjectsArgs, } from 'generated/graphql' +import { readNetwork } from 'constants/networks' import { paginateDepleteQuery } from 'lib/apollo/paginateDepleteQuery' import { serverClient, v4SepoliaServerClient } from 'lib/apollo/serverClient' import { DBProject, DBProjectQueryOpts, SGSBCompareKey } from 'models/dbProject' @@ -43,7 +44,12 @@ export async function queryAllSGProjectsForServer() { ]) // Response must be retyped with Json<>, because the serverClient does not perform the parsing expected by generated types - const _res = res as unknown as Json>[] + const _res = res.map(p => { + return { + ...p, + chainId: readNetwork.chainId, + } + }) as unknown as Json>[] const _resSepoliaV4 = resSepoliaV4.map(p => { return { ...p, @@ -52,7 +58,7 @@ export async function queryAllSGProjectsForServer() { metadataUri: p.metadata, chainId: sepolia.id, } - }) as unknown as Json>[] + }) as unknown as Json>[] return [..._res, ..._resSepoliaV4].map(formatSGProjectForDB) } diff --git a/src/models/dbProject.ts b/src/models/dbProject.ts index 4bdd1ecec9..91a07158b4 100644 --- a/src/models/dbProject.ts +++ b/src/models/dbProject.ts @@ -4,8 +4,8 @@ import { Database } from 'types/database.types' import { Project } from 'generated/graphql' import { ProjectTagName } from './project-tags' import { PV } from './pv' - -export type SGSBCompareKey = Extract +type P = Project & { chainId: number } +export type SGSBCompareKey = Extract /** * @param text Text to use for string search diff --git a/src/pages/api/projects/health.ts b/src/pages/api/projects/health.ts index b8e38f3cf6..a2895a6fa3 100644 --- a/src/pages/api/projects/health.ts +++ b/src/pages/api/projects/health.ts @@ -36,7 +36,9 @@ const handler: NextApiHandler = async (_, res) => { (await paginateDepleteQuery({ client: serverClient, document: DbProjectsDocument, - })) as unknown as Json>[] + })) as unknown as Json< + Pick + >[] ).map(formatSGProjectForDB) report += `\n\n${dbProjectsCount} projects in database` diff --git a/src/utils/sgDbProjects.ts b/src/utils/sgDbProjects.ts index a2a2a41cc5..7657765023 100644 --- a/src/utils/sgDbProjects.ts +++ b/src/utils/sgDbProjects.ts @@ -83,6 +83,7 @@ export function parseDBProjectsRow(p: DBProjectRow): Json { paymentsCount: p.payments_count, projectId: p.project_id, pv: p.pv as PV, + chainId: p.chain_id, redeemCount: p.redeem_count, redeemVolume: p.redeem_volume, redeemVolumeUSD: p.redeem_voume_usd, @@ -152,7 +153,7 @@ export function formatSgProjectsForUpdate({ retryIpfs, returnAllProjects, }: { - sgProjects: Json>[] + sgProjects: Json>[] dbProjects: Record> retryIpfs?: boolean returnAllProjects?: boolean @@ -243,7 +244,7 @@ export async function formatWithMetadata({ sgProject, dbProject, }: { - sgProject: Json> + sgProject: Json> dbProject: | Pick< DBProject, @@ -351,8 +352,8 @@ function padBigNumForSort(bn: string) { } export function formatSGProjectForDB( - p: Json>, -): Json> { + p: Json>, +): Json> { return { ...p, // Adjust BigNumber values before we compare them to database values From 8b197a5d9ccfad9b93d2db1b7227e4dd0817f8d4 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Thu, 19 Sep 2024 21:58:14 +1000 Subject: [PATCH 24/44] possibly improve darkmode fuoc --- src/contexts/Theme/useJuiceTheme.ts | 60 ++++++++++++++++------------- src/pages/_app.tsx | 2 + 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/contexts/Theme/useJuiceTheme.ts b/src/contexts/Theme/useJuiceTheme.ts index bc9cfc723a..fb91878614 100644 --- a/src/contexts/Theme/useJuiceTheme.ts +++ b/src/contexts/Theme/useJuiceTheme.ts @@ -3,6 +3,8 @@ import type { ThemeContextType } from 'contexts/Theme/ThemeContext' import { startTransition, useEffect, useState } from 'react' import { useMedia } from './useMedia' +export const THEME_STORAGE_KEY = 'jb_theme' + const userPrefersDarkMode = (): boolean => { if (typeof window === 'undefined') { return false @@ -11,8 +13,12 @@ const userPrefersDarkMode = (): boolean => { return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false } -const getInitialThemeOption = (storageKey: string) => { - const storedThemeOption = localStorage?.getItem(storageKey) +export const getInitialThemeOption = () => { + if (typeof window === 'undefined') { + return false + } + + const storedThemeOption = localStorage?.getItem(THEME_STORAGE_KEY) if (storedThemeOption) { return storedThemeOption as ThemeOption } @@ -20,7 +26,23 @@ const getInitialThemeOption = (storageKey: string) => { return userPrefersDarkMode() ? ThemeOption.dark : ThemeOption.light } -export function useJuiceTheme(storageKey = 'jb_theme'): ThemeContextType { +export const syncTheme = (themeOption: ThemeOption) => { + if (typeof document === 'undefined') { + return false + } + + if (themeOption === ThemeOption.dark) { + document.body.classList.add('dark') + } else { + document.body.classList.remove('dark') + } + + document.documentElement.style.setProperty('color-scheme', themeOption) + + localStorage?.setItem(THEME_STORAGE_KEY, themeOption) +} + +export function useJuiceTheme(): ThemeContextType { const [currentThemeOption, setCurrentThemeOption] = useState( 'light' as ThemeOption, ) @@ -29,33 +51,19 @@ export function useJuiceTheme(storageKey = 'jb_theme'): ThemeContextType { // Load the theme from local storage on initial load useEffect(() => { - const initialThemeOption = getInitialThemeOption(storageKey) - startTransition(() => { - setCurrentThemeOption(initialThemeOption) - }) - }, [storageKey]) - - // Set the theme on the body element - // This is needed for tailwind css dark theme classes to work - useEffect(() => { - if (currentThemeOption === ThemeOption.dark) { - document.body.classList.add('dark') - } else { - document.body.classList.remove('dark') - } - document.documentElement.style.setProperty( - 'color-scheme', - currentThemeOption, - ) - }, [currentThemeOption]) + const initialThemeOption = getInitialThemeOption(THEME_STORAGE_KEY) + setCurrentThemeOption(initialThemeOption) + }, []) + + function setThemeOption(themeOption: ThemeOption) { + syncTheme(themeOption) + setCurrentThemeOption(themeOption) + } return { themeOption: currentThemeOption, forThemeOption: map => map[currentThemeOption], - setThemeOption: (themeOption: ThemeOption) => { - setCurrentThemeOption(themeOption) - localStorage?.setItem(storageKey, themeOption) - }, + setThemeOption, isMobile, } } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index cc95c7ebcf..42690818af 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -5,6 +5,7 @@ import SupabaseSessionProvider from 'contexts/SupabaseSession/SupabaseSessionPro import { initWeb3Onboard } from 'hooks/Wallet/initWeb3Onboard' import { useFathom } from 'lib/fathom' import type { AppProps } from 'next/app' +import { getInitialThemeOption, syncTheme } from 'contexts/Theme/useJuiceTheme' import '../styles/index.scss' /** @@ -15,6 +16,7 @@ import '../styles/index.scss' const web3Onboard = initWeb3Onboard() export default function MyApp({ Component, pageProps }: AppProps) { + syncTheme(getInitialThemeOption()) useFathom() if (!pageProps.i18n) { From 725de08be74f5a82e6fbbea0516cd4733fb3708d Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:04:45 +1000 Subject: [PATCH 25/44] fix types --- src/contexts/Theme/useJuiceTheme.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/contexts/Theme/useJuiceTheme.ts b/src/contexts/Theme/useJuiceTheme.ts index fb91878614..c7307f5e3b 100644 --- a/src/contexts/Theme/useJuiceTheme.ts +++ b/src/contexts/Theme/useJuiceTheme.ts @@ -15,7 +15,7 @@ const userPrefersDarkMode = (): boolean => { export const getInitialThemeOption = () => { if (typeof window === 'undefined') { - return false + return } const storedThemeOption = localStorage?.getItem(THEME_STORAGE_KEY) @@ -26,9 +26,9 @@ export const getInitialThemeOption = () => { return userPrefersDarkMode() ? ThemeOption.dark : ThemeOption.light } -export const syncTheme = (themeOption: ThemeOption) => { - if (typeof document === 'undefined') { - return false +export const syncTheme = (themeOption: ThemeOption | undefined) => { + if (!themeOption || typeof document === 'undefined') { + return } if (themeOption === ThemeOption.dark) { @@ -51,7 +51,11 @@ export function useJuiceTheme(): ThemeContextType { // Load the theme from local storage on initial load useEffect(() => { - const initialThemeOption = getInitialThemeOption(THEME_STORAGE_KEY) + const initialThemeOption = getInitialThemeOption() + if (!initialThemeOption) { + return + } + setCurrentThemeOption(initialThemeOption) }, []) From 484c83c966bb29644583c4f905f25d4ac4b06699 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:18:13 +1000 Subject: [PATCH 26/44] Bump next from 14.2.5 to 14.2.10 (#4460) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 136 ++++++++++++++++++++++++--------------------------- 2 files changed, 64 insertions(+), 74 deletions(-) diff --git a/package.json b/package.json index 6e1f60a64d..e11c4b7a6c 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "lottie-react": "^2.4.0", "mjml": "^4.15.3", "mustache": "^4.2.0", - "next": "^14.2.5", + "next": "^14.2.10", "object-hash": "^3.0.0", "pino": "^8.11.0", "pino-pretty": "^10.0.0", diff --git a/yarn.lock b/yarn.lock index 64bcdf8321..a734a47e76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3406,10 +3406,10 @@ dependencies: webpack-bundle-analyzer "4.10.1" -"@next/env@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.5.tgz#1d9328ab828711d3517d0a1d505acb55e5ef7ad0" - integrity sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA== +"@next/env@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.10.tgz#1d3178340028ced2d679f84140877db4f420333c" + integrity sha512-dZIu93Bf5LUtluBXIv4woQw2cZVZ2DJTjax5/5DOs3lzEOeKLy7GxRSr4caK9/SCPdaW6bCgpye6+n4Dh9oJPw== "@next/eslint-plugin-next@14.2.5": version "14.2.5" @@ -3418,50 +3418,50 @@ dependencies: glob "10.3.10" -"@next/swc-darwin-arm64@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz#d0a160cf78c18731c51cc0bff131c706b3e9bb05" - integrity sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ== - -"@next/swc-darwin-x64@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz#eb832a992407f6e6352eed05a073379f1ce0589c" - integrity sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA== - -"@next/swc-linux-arm64-gnu@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz#098fdab57a4664969bc905f5801ef5a89582c689" - integrity sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA== - -"@next/swc-linux-arm64-musl@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz#243a1cc1087fb75481726dd289c7b219fa01f2b5" - integrity sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA== - -"@next/swc-linux-x64-gnu@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz#b8a2e436387ee4a52aa9719b718992e0330c4953" - integrity sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ== - -"@next/swc-linux-x64-musl@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz#cb8a9adad5fb8df86112cfbd363aab5c6d32757b" - integrity sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ== - -"@next/swc-win32-arm64-msvc@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz#81f996c1c38ea0900d4e7719cc8814be8a835da0" - integrity sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw== - -"@next/swc-win32-ia32-msvc@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz#f61c74ce823e10b2bc150e648fc192a7056422e0" - integrity sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg== - -"@next/swc-win32-x64-msvc@14.2.5": - version "14.2.5" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz#ed199a920efb510cfe941cd75ed38a7be21e756f" - integrity sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g== +"@next/swc-darwin-arm64@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.10.tgz#49d10ca4086fbd59ee68e204f75d7136eda2aa80" + integrity sha512-V3z10NV+cvMAfxQUMhKgfQnPbjw+Ew3cnr64b0lr8MDiBJs3eLnM6RpGC46nhfMZsiXgQngCJKWGTC/yDcgrDQ== + +"@next/swc-darwin-x64@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.10.tgz#0ebeae3afb8eac433882b79543295ab83624a1a8" + integrity sha512-Y0TC+FXbFUQ2MQgimJ/7Ina2mXIKhE7F+GUe1SgnzRmwFY3hX2z8nyVCxE82I2RicspdkZnSWMn4oTjIKz4uzA== + +"@next/swc-linux-arm64-gnu@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.10.tgz#7e602916d2fb55a3c532f74bed926a0137c16f20" + integrity sha512-ZfQ7yOy5zyskSj9rFpa0Yd7gkrBnJTkYVSya95hX3zeBG9E55Z6OTNPn1j2BTFWvOVVj65C3T+qsjOyVI9DQpA== + +"@next/swc-linux-arm64-musl@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.10.tgz#6b143f628ccee490b527562e934f8de578d4be47" + integrity sha512-n2i5o3y2jpBfXFRxDREr342BGIQCJbdAUi/K4q6Env3aSx8erM9VuKXHw5KNROK9ejFSPf0LhoSkU/ZiNdacpQ== + +"@next/swc-linux-x64-gnu@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.10.tgz#086f2f16a0678890a1eb46518c4dda381b046082" + integrity sha512-GXvajAWh2woTT0GKEDlkVhFNxhJS/XdDmrVHrPOA83pLzlGPQnixqxD8u3bBB9oATBKB//5e4vpACnx5Vaxdqg== + +"@next/swc-linux-x64-musl@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.10.tgz#1befef10ed8dbcc5047b5d637a25ae3c30a0bfc3" + integrity sha512-opFFN5B0SnO+HTz4Wq4HaylXGFV+iHrVxd3YvREUX9K+xfc4ePbRrxqOuPOFjtSuiVouwe6uLeDtabjEIbkmDA== + +"@next/swc-win32-arm64-msvc@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.10.tgz#731f52c3ae3c56a26cf21d474b11ae1529531209" + integrity sha512-9NUzZuR8WiXTvv+EiU/MXdcQ1XUvFixbLIMNQiVHuzs7ZIFrJDLJDaOF1KaqttoTujpcxljM/RNAOmw1GhPPQQ== + +"@next/swc-win32-ia32-msvc@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.10.tgz#32723ef7f04e25be12af357cc72ddfdd42fd1041" + integrity sha512-fr3aEbSd1GeW3YUMBkWAu4hcdjZ6g4NBl1uku4gAn661tcxd1bHs1THWYzdsbTRLcCKLjrDZlNp6j2HTfrw+Bg== + +"@next/swc-win32-x64-msvc@14.2.10": + version "14.2.10" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.10.tgz#ee1d036cb5ec871816f96baee7991035bb242455" + integrity sha512-UjeVoRGKNL2zfbcQ6fscmgjBAS/inHBh63mjIlfPg/NG8Yn2ztqylXt5qilYb6hoHIwaU2ogHknHWWmahJjgZQ== "@nicolo-ribaudo/semver-v6@^6.3.3": version "6.3.3" @@ -7042,21 +7042,11 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503: - version "1.0.30001621" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001621.tgz" - integrity sha512-+NLXZiviFFKX0fk8Piwv3PfLPGtRqJeq2TiNoUff/qB5KJgwecJTvCXDpmlyP/eCI/GUEmp/h/y5j0yckiiZrA== - -caniuse-lite@^1.0.30001579: +caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001629: version "1.0.30001651" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz#52de59529e8b02b1aedcaaf5c05d9e23c0c28138" integrity sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg== -caniuse-lite@^1.0.30001629: - version "1.0.30001639" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001639.tgz#972b3a6adeacdd8f46af5fc7f771e9639f6c1521" - integrity sha512-eFHflNTBIlFwP2AIKaYuBQN/apnUoKNhBdza8ZnW/h2di4LCZ4xFqYlxUxo+LQ76KFI1PGcC1QDxMbxTZpSCAg== - capital-case@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/capital-case/-/capital-case-1.0.4.tgz#9d130292353c9249f6b00fa5852bee38a717e669" @@ -14656,12 +14646,12 @@ next-tick@1, next-tick@^1.1.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== -next@^14.2.5: - version "14.2.5" - resolved "https://registry.yarnpkg.com/next/-/next-14.2.5.tgz#afe4022bb0b752962e2205836587a289270efbea" - integrity sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA== +next@^14.2.10: + version "14.2.10" + resolved "https://registry.yarnpkg.com/next/-/next-14.2.10.tgz#331981a4fecb1ae8af1817d4db98fc9687ee1cb6" + integrity sha512-sDDExXnh33cY3RkS9JuFEKaS4HmlWmDKP1VJioucCG6z5KuA008DPsDZOzi8UfqEk3Ii+2NCQSJrfbEWtZZfww== dependencies: - "@next/env" "14.2.5" + "@next/env" "14.2.10" "@swc/helpers" "0.5.5" busboy "1.6.0" caniuse-lite "^1.0.30001579" @@ -14669,15 +14659,15 @@ next@^14.2.5: postcss "8.4.31" styled-jsx "5.1.1" optionalDependencies: - "@next/swc-darwin-arm64" "14.2.5" - "@next/swc-darwin-x64" "14.2.5" - "@next/swc-linux-arm64-gnu" "14.2.5" - "@next/swc-linux-arm64-musl" "14.2.5" - "@next/swc-linux-x64-gnu" "14.2.5" - "@next/swc-linux-x64-musl" "14.2.5" - "@next/swc-win32-arm64-msvc" "14.2.5" - "@next/swc-win32-ia32-msvc" "14.2.5" - "@next/swc-win32-x64-msvc" "14.2.5" + "@next/swc-darwin-arm64" "14.2.10" + "@next/swc-darwin-x64" "14.2.10" + "@next/swc-linux-arm64-gnu" "14.2.10" + "@next/swc-linux-arm64-musl" "14.2.10" + "@next/swc-linux-x64-gnu" "14.2.10" + "@next/swc-linux-x64-musl" "14.2.10" + "@next/swc-win32-arm64-msvc" "14.2.10" + "@next/swc-win32-ia32-msvc" "14.2.10" + "@next/swc-win32-x64-msvc" "14.2.10" no-case@^2.2.0, no-case@^2.3.2: version "2.3.2" From 5f9902c016afb9ac57dcea85400d38959317ec5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:19:50 +1000 Subject: [PATCH 27/44] Bump dompurify from 3.0.5 to 3.1.3 (#4461) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e11c4b7a6c..abab9651fe 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "axios": "^1.7.4", "bottleneck": "^2.19.5", "discord.js": "^14.9.0", - "dompurify": "^3.0.5", + "dompurify": "^3.1.3", "eslint-config-next": "^14.2.5", "ethereum-block-by-date": "1.4.6", "ethers": "^5.7.0", diff --git a/yarn.lock b/yarn.lock index a734a47e76..4afee90462 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8553,10 +8553,10 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.5.tgz#eb3d9cfa10037b6e73f32c586682c4b2ab01fbed" - integrity sha512-F9e6wPGtY+8KNMRAVfxeCOHU0/NPWMSENNq4pQctuXRqqdEPW7q3CrLbR5Nse044WwacyjHGOMlvNsBe1y6z9A== +dompurify@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.3.tgz#cfe3ce4232c216d923832f68f2aa18b2fb9bd223" + integrity sha512-5sOWYSNPaxz6o2MUPvtyxTTqR4D3L77pr5rUQoWgD5ROQtVIZQgJkXbo1DLlK3vj11YGw5+LnF4SYti4gZmwng== domutils@^2.4.2: version "2.8.0" From b6ce48bdcb9fac0953969085e3edae26f70549b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:27:01 +1000 Subject: [PATCH 28/44] Bump dset from 3.1.2 to 3.1.4 (#4462) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4afee90462..a0d7d44b48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8601,9 +8601,9 @@ dotenv@^16.0.0, dotenv@^16.0.1: resolved "https://github.com/dapphub/ds-test.git#e282159d5170298eb2455a6c05280ab5a73a4ef0" dset@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.2.tgz#89c436ca6450398396dc6538ea00abc0c54cd45a" - integrity sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q== + version "3.1.4" + resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.4.tgz#f8eaf5f023f068a036d08cd07dc9ffb7d0065248" + integrity sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA== duplexer@^0.1.2: version "0.1.2" From 9ee0712578ef3c627ea1cf7fb0bd8a1b49086095 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:25:45 +1000 Subject: [PATCH 29/44] nuke @types/ethereum-block-by-date --- package.json | 1 - yarn.lock | 15 +++------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index abab9651fe..26a2b85c5e 100644 --- a/package.json +++ b/package.json @@ -159,7 +159,6 @@ "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/dompurify": "^3.0.2", - "@types/ethereum-block-by-date": "1.4.1", "@types/form-data": "^2.5.0", "@types/formidable": "^2.0.5", "@types/he": "^1.2.0", diff --git a/yarn.lock b/yarn.lock index a0d7d44b48..ed85eeb5e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4461,15 +4461,6 @@ dependencies: "@types/trusted-types" "*" -"@types/ethereum-block-by-date@1.4.1": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@types/ethereum-block-by-date/-/ethereum-block-by-date-1.4.1.tgz#197e0643b8582465e699448799c7a502a1e0e7f6" - integrity sha512-TNOlViB9QbX24aJCYgJmVjIPvKqtYWUrv6nuAOA7WMFMpwXOEfZ9y5bnCNw+yvlggByFAaS5A/URIkX75TxWgg== - dependencies: - ethers "^5.4.5" - moment "^2.29.1" - web3 "^1.5.2" - "@types/filesystem@*": version "0.0.32" resolved "https://registry.yarnpkg.com/@types/filesystem/-/filesystem-0.0.32.tgz#307df7cc084a2293c3c1a31151b178063e0a8edf" @@ -9664,7 +9655,7 @@ ethers@^4.0.32, ethers@^4.0.45: uuid "2.0.1" xmlhttprequest "1.8.0" -ethers@^5.0.13, ethers@^5.4.5, ethers@^5.7.0: +ethers@^5.0.13, ethers@^5.7.0: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== @@ -14481,7 +14472,7 @@ mock-fs@^4.1.0: resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-4.14.0.tgz#ce5124d2c601421255985e6e94da80a7357b1b18" integrity sha512-qYvlv/exQ4+svI3UOvPUpLDF0OMX5euvUH0Ny4N5QyRyhNdgAgUrVH3iUINSzEPLvx0kbo/Bp28GJKIqvE7URw== -moment@^2.24.0, moment@^2.29.1, moment@^2.29.2, moment@^2.29.4: +moment@^2.24.0, moment@^2.29.2, moment@^2.29.4: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== @@ -19755,7 +19746,7 @@ web3-utils@1.10.0, web3-utils@^1.0.0-beta.31: randombytes "^2.1.0" utf8 "3.0.0" -web3@1.10.0, web3@^1.5.2: +web3@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/web3/-/web3-1.10.0.tgz#2fde0009f59aa756c93e07ea2a7f3ab971091274" integrity sha512-YfKY9wSkGcM8seO+daR89oVTcbu18NsVfvOngzqMYGUU0pPSQmE57qQDvQzUeoIOHAnXEBNzrhjQJmm8ER0rng== From 9971c590641d01c63c1924cc538f072a8088e5c2 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:30:37 +1000 Subject: [PATCH 30/44] nuke bad import --- src/lib/apollo/subgraphUri.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/apollo/subgraphUri.ts b/src/lib/apollo/subgraphUri.ts index 5f22d28f13..cd94b8d3cf 100644 --- a/src/lib/apollo/subgraphUri.ts +++ b/src/lib/apollo/subgraphUri.ts @@ -1,5 +1,4 @@ import { JBChainId } from 'juice-sdk-react' -import process from 'process' import { isBrowser } from 'utils/isBrowser' import { sepolia } from 'viem/chains' @@ -32,25 +31,25 @@ export const v4SubgraphUri = (chainId: JBChainId) => { const env: { [k in JBChainId]?: { - browser?: string - server?: string + browserUrl?: string + serverUrl?: string } } = { [sepolia.id]: { - browser: process.env.NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL, - server: process.env.V4_SEPOLIA_SUBGRAPH_URL, + browserUrl: process.env.NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL, + serverUrl: process.env.V4_SEPOLIA_SUBGRAPH_URL, }, } as const if (isBrowser()) { - uri = env?.[chainId]?.browser + uri = env?.[chainId]?.browserUrl if (!uri) { throw new Error( 'NEXT_PUBLIC_V4_SUBGRAPH_URL environment variable not defined', ) } } else { - uri = env?.[chainId]?.server + uri = env?.[chainId]?.serverUrl if (!uri) { throw new Error('V4_SUBGRAPH_URL environment variable not defined') } From 43071184570c83285cb2af5a60eee1e7996480ea Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:37:17 +1000 Subject: [PATCH 31/44] fix ci --- .github/workflows/ci-run.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-run.yml b/.github/workflows/ci-run.yml index 861018613c..245a58e779 100644 --- a/.github/workflows/ci-run.yml +++ b/.github/workflows/ci-run.yml @@ -24,6 +24,7 @@ env: SUBGRAPH_URL: ${{ secrets.SUBGRAPH_URL }} NEXT_PUBLIC_SUBGRAPH_URL: ${{ secrets.NEXT_PUBLIC_SUBGRAPH_URL }} NEXT_PUBLIC_V4_SUBGRAPH_URL: ${{ secrets.NEXT_PUBLIC_V4_SUBGRAPH_URL }} + NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL: ${{ secrets.NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL }} jobs: jest: From c23b15b0cb82e3eba187f26783d93be25565f3f0 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:10:22 +1000 Subject: [PATCH 32/44] Contracts sept (#4463) --- next-env.d.ts | 2 +- package.json | 4 +- src/lib/api/supabase/projects/api.ts | 1 - .../v4/graphql/queries/dbV4Projects.graphql | 2 +- .../v4/graphql/queries/project.graphql | 2 +- src/packages/v4/utils/editRuleset.ts | 1 + src/packages/v4/utils/launchProject.ts | 142 ++++++++++-------- yarn.lock | 16 +- 8 files changed, 90 insertions(+), 80 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03dc6..a4a7b3f5cf 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/package.json b/package.json index 26a2b85c5e..a3b93e6e65 100644 --- a/package.json +++ b/package.json @@ -107,8 +107,8 @@ "graphql": "^16.8.1", "he": "^1.2.0", "jsonwebtoken": "^9.0.0", - "juice-sdk-core": "^10.0.3-alpha", - "juice-sdk-react": "^10.0.1-alpha", + "juice-sdk-core": "^11.0.0-alpha", + "juice-sdk-react": "^11.0.0-alpha", "juicebox-metadata-helper": "0.1.7", "less": "4.1.2", "lodash": "^4.17.21", diff --git a/src/lib/api/supabase/projects/api.ts b/src/lib/api/supabase/projects/api.ts index 499d6a5165..76b6193894 100644 --- a/src/lib/api/supabase/projects/api.ts +++ b/src/lib/api/supabase/projects/api.ts @@ -55,7 +55,6 @@ export async function queryAllSGProjectsForServer() { ...p, id: getSubgraphIdForProject(PV_V4, p.projectId), // Patch in the subgraph ID for V4 projects (to be consitent with legacy subgraph) pv: PV_V4, // Patch in the PV for V4 projects, - metadataUri: p.metadata, chainId: sepolia.id, } }) as unknown as Json>[] diff --git a/src/packages/v4/graphql/queries/dbV4Projects.graphql b/src/packages/v4/graphql/queries/dbV4Projects.graphql index 65898c2047..ea6f313484 100644 --- a/src/packages/v4/graphql/queries/dbV4Projects.graphql +++ b/src/packages/v4/graphql/queries/dbV4Projects.graphql @@ -3,7 +3,7 @@ query DBV4Projects($first: Int, $skip: Int) { id projectId handle - metadata + metadataUri currentBalance volume volumeUSD diff --git a/src/packages/v4/graphql/queries/project.graphql b/src/packages/v4/graphql/queries/project.graphql index 0c51032449..a5f9c08fb7 100644 --- a/src/packages/v4/graphql/queries/project.graphql +++ b/src/packages/v4/graphql/queries/project.graphql @@ -1,7 +1,7 @@ query Projects($where: Project_filter, $first: Int, $skip: Int, $block: Block_height) { projects(where: $where, first: $first, skip: $skip, block: $block) { projectId - metadata + metadataUri handle contributorsCount createdAt diff --git a/src/packages/v4/utils/editRuleset.ts b/src/packages/v4/utils/editRuleset.ts index 99f8e11b60..0d1533946a 100644 --- a/src/packages/v4/utils/editRuleset.ts +++ b/src/packages/v4/utils/editRuleset.ts @@ -51,6 +51,7 @@ export function transformEditCycleFormFieldsToTxArgs({ useDataHookForRedeem: false, // Defaulting to false as it's not in formValues dataHook: "0x0000000000000000000000000000000000000000" as `0x${string}`, // Defaulting to a null address metadata: 0, // Assuming no additional metadata is provided + allowCrosschainSuckerExtension: false }, splitGroups: [ diff --git a/src/packages/v4/utils/launchProject.ts b/src/packages/v4/utils/launchProject.ts index 94827a1c58..544db1d8e8 100644 --- a/src/packages/v4/utils/launchProject.ts +++ b/src/packages/v4/utils/launchProject.ts @@ -1,7 +1,10 @@ -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"; +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 @@ -12,15 +15,15 @@ export type LaunchV2V3ProjectArgs = [ GroupedSplits[], // _groupedSplits V2V3FundAccessConstraint[], // _fundAccessConstraints string[], // _terminals - string // _memo -]; + string, // _memo +] export function transformV2V3CreateArgsToV4({ v2v3Args, primaryNativeTerminal, - currencyTokenAddress + currencyTokenAddress, }: { - v2v3Args: LaunchV2V3ProjectArgs, + v2v3Args: LaunchV2V3ProjectArgs primaryNativeTerminal: `0x${string}` currencyTokenAddress: `0x${string}` }) { @@ -33,78 +36,85 @@ export function transformV2V3CreateArgsToV4({ _groupedSplits, _fundAccessConstraints, _terminals, - _memo - ] = v2v3Args; + _memo, + ] = v2v3Args const mustStartAtOrAfterNum = parseInt(_mustStartAtOrAfter) - const rulesetConfigurations = [{ - mustStartAtOrAfter: mustStartAtOrAfterNum ?? 0, // 0 denotes start immediately - duration: _data.duration.toNumber(), - weight: _data.weight.toBigInt(), - decayPercent: _data.discountRate.toNumber(), - approvalHook: _data.ballot as `0x${string}`, + const rulesetConfigurations = [ + { + mustStartAtOrAfter: mustStartAtOrAfterNum ?? 0, // 0 denotes start immediately + 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, - }, + 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, + allowCrosschainSuckerExtension: false, + }, - 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}`, + 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: currencyTokenAddress, - payoutLimits: [{ - amount: constraint.distributionLimit.toBigInt(), - currency: constraint.distributionLimitCurrency.toNumber(), - }] as const, - surplusAllowances: [{ - amount: constraint.overflowAllowance.toBigInt(), - currency: constraint.overflowAllowanceCurrency.toNumber(), - }] as const, - })) - }]; + fundAccessLimitGroups: _fundAccessConstraints.map(constraint => ({ + terminal: primaryNativeTerminal, + token: currencyTokenAddress, + 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: [ // @v4todo: - // { - // token: currencyTokenAddress, - // decimals: 18, + // { + // token: currencyTokenAddress, + // decimals: 18, // currency: 0 // } ] as const, - })); + })) return [ _owner as `0x${string}`, @@ -112,5 +122,5 @@ export function transformV2V3CreateArgsToV4({ rulesetConfigurations, terminalConfigurations, _memo, - ] as const; + ] as const } diff --git a/yarn.lock b/yarn.lock index ed85eeb5e8..9aae589e59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12837,18 +12837,18 @@ jsx-ast-utils@^3.3.5: object.assign "^4.1.4" object.values "^1.1.6" -juice-sdk-core@^10.0.3-alpha: - version "10.0.3-alpha" - resolved "https://registry.yarnpkg.com/juice-sdk-core/-/juice-sdk-core-10.0.3-alpha.tgz#dcd1afa2faa13f42559ced3b308e5e886892c7bd" - integrity sha512-E+Wx7zv/PCOWrY9Co62ilyHa/6ge44xltTsNeaF96a3d3jWhWlkLlrDBrHDnT48VvClw0LbnRWOFvaB662OyFg== +juice-sdk-core@^11.0.0-alpha: + version "11.0.0-alpha" + resolved "https://registry.yarnpkg.com/juice-sdk-core/-/juice-sdk-core-11.0.0-alpha.tgz#dd0228158de3c3a2799ea6ba3b1f22c8c0635d60" + integrity sha512-wqKAb9f88579CiTZP6MNb08TOStQ4OqgLQYh7cwmONcpon09+A9tyHPCxhgKjGHGevzF80Wv2Z0dPjgujMRI2Q== dependencies: bs58 "^5.0.0" fpnum "^1.0.0" -juice-sdk-react@^10.0.1-alpha: - version "10.0.1-alpha" - resolved "https://registry.yarnpkg.com/juice-sdk-react/-/juice-sdk-react-10.0.1-alpha.tgz#a1a9273292ed7b6f2f97e87e60c131b8cd53ee03" - integrity sha512-Lpdymh4bt2YBba2htXn/RB+Kohs1iMhu2mqzadwAtrYpsFz+dKkL5GFA1YLLSC96d31KSVJX/bArX46Uc5hnoA== +juice-sdk-react@^11.0.0-alpha: + version "11.0.0-alpha" + resolved "https://registry.yarnpkg.com/juice-sdk-react/-/juice-sdk-react-11.0.0-alpha.tgz#15b93be75d80e9e83a0f45b4cc8c81d3f1828e49" + integrity sha512-G0zCPMCozfAK0Y9mxGMtMOYW8Bm3ml5bEEr/pKFbrrxIs+sEUnK4f5CZWiaA6fZlN8QnUbXbIRteFjv+RB2Yzg== juice@^10.0.0: version "10.0.0" From 64897e311060b28b706dd4ac4da7e1dea9f76598 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Mon, 23 Sep 2024 20:22:57 +1000 Subject: [PATCH 33/44] fix: catch errors when ens lookup fails on address input --- src/components/inputs/EthAddressInput.tsx | 4 ++-- src/pages/api/auth/challenge-message.ts | 4 +++- src/pages/api/ens/resolve/[address].ts | 4 ++-- src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts | 2 +- src/pages/api/juicebox/prices/ethusd.ts | 2 +- src/pages/api/juicebox/projectHandle/[projectId].ts | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/inputs/EthAddressInput.tsx b/src/components/inputs/EthAddressInput.tsx index fa88695f25..e99bd0fe4f 100644 --- a/src/components/inputs/EthAddressInput.tsx +++ b/src/components/inputs/EthAddressInput.tsx @@ -48,8 +48,8 @@ export function EthAddressInput({ async (address: string) => { onChange?.(address) - const ensNameForAddress = await resolveAddress(address) - if (ensNameForAddress.name) { + const ensNameForAddress = await resolveAddress(address).catch(() => {}) // noop, ignore errors + if (ensNameForAddress?.name) { setENSName(ensNameForAddress.name) setAddressForENSName(address) } diff --git a/src/pages/api/auth/challenge-message.ts b/src/pages/api/auth/challenge-message.ts index 8fd2aa1a9d..7d576a954d 100644 --- a/src/pages/api/auth/challenge-message.ts +++ b/src/pages/api/auth/challenge-message.ts @@ -14,8 +14,10 @@ const WalletSigningRequestMessageTemplate = template( */ const handler = async (req: NextApiRequest, res: NextApiResponse) => { try { - if (req.method !== 'GET') + if (req.method !== 'GET') { return res.status(405).json({ message: 'Method not allowed.' }) + } + const { walletAddress } = req.query ?? {} if (!walletAddress || typeof walletAddress !== 'string') { return res.status(400).json({ message: 'Invalid request.' }) diff --git a/src/pages/api/ens/resolve/[address].ts b/src/pages/api/ens/resolve/[address].ts index 013d94db17..1c66a554d2 100644 --- a/src/pages/api/ens/resolve/[address].ts +++ b/src/pages/api/ens/resolve/[address].ts @@ -9,7 +9,7 @@ const logger = getLogger('api/ens/resolve/[address]') const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== 'GET') { - return res.status(404) + return res.status(405).end() } try { @@ -20,7 +20,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (readNetwork.name === NetworkName.sepolia) { // ethers v5 doesn't support ens on sepolia - return res.status(404).json({ error: 'ens not supported on sepolia' }) + return res.status(400).json({ error: 'ens not supported on sepolia' }) } let response diff --git a/src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts b/src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts index bd9267fdfe..7fb4ede6b1 100644 --- a/src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts +++ b/src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts @@ -180,7 +180,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { enableCors(res) if (req.method !== 'GET') { - return res.status(404) + return res.status(405).end() } try { diff --git a/src/pages/api/juicebox/prices/ethusd.ts b/src/pages/api/juicebox/prices/ethusd.ts index 9e2a3b53d7..49a843a86d 100644 --- a/src/pages/api/juicebox/prices/ethusd.ts +++ b/src/pages/api/juicebox/prices/ethusd.ts @@ -17,7 +17,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { enableCors(res) if (req.method !== 'GET') { - return res.status(404) + return res.status(405).end() } try { diff --git a/src/pages/api/juicebox/projectHandle/[projectId].ts b/src/pages/api/juicebox/projectHandle/[projectId].ts index 8690c5b4d1..25643c9e52 100644 --- a/src/pages/api/juicebox/projectHandle/[projectId].ts +++ b/src/pages/api/juicebox/projectHandle/[projectId].ts @@ -9,7 +9,7 @@ const logger = getLogger('api/juicebox/projectHandle/[projectId]') const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== 'GET') { - return res.status(404).json({ error: 'not found' }) + return res.status(405).json({ error: 'method not supported' }) } try { From e699a6d47ebbbdd38bfcfabc131a8b456408eba3 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Mon, 23 Sep 2024 20:42:42 +1000 Subject: [PATCH 34/44] fix: update v4 launch contracts --- src/packages/v4/hooks/useLaunchProjectTx.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/packages/v4/hooks/useLaunchProjectTx.ts b/src/packages/v4/hooks/useLaunchProjectTx.ts index 196f4da95e..dd717acfa3 100644 --- a/src/packages/v4/hooks/useLaunchProjectTx.ts +++ b/src/packages/v4/hooks/useLaunchProjectTx.ts @@ -41,19 +41,22 @@ const getProjectIdFromLaunchReceipt = ( return projectId } -// todo no ideal to hardcode these addresses +/** + * The contract addresses to use for deployment + * @todo not ideal to hardcode these addresses + */ const SUPPORTED_JB_MULTITERMINAL_ADDRESS = { - '84532': '0x88e8ED1Dd942B2aB4Adc1e3b50Bd0EdB9822231E' as Address, - '421614': '0x88e8ED1Dd942B2aB4Adc1e3b50Bd0EdB9822231E' as Address, - '11155111': '0x88e8ED1Dd942B2aB4Adc1e3b50Bd0EdB9822231E' as Address, - '11155420': '0x88e8ED1Dd942B2aB4Adc1e3b50Bd0EdB9822231E' as Address, + '84532': '0x4DeF0AA5B9CA095d11705284221b2878731ab4EF' as Address, + '421614': '0x4DeF0AA5B9CA095d11705284221b2878731ab4EF' as Address, + '11155111': '0x4DeF0AA5B9CA095d11705284221b2878731ab4EF' as Address, + '11155420': '0x4DeF0AA5B9CA095d11705284221b2878731ab4EF' as Address, } const SUPPORTED_JB_CONTROLLER_ADDRESS = { - '84532': '0x1e4c8DFfE8D72aeB63e8dDbE9eF89bc368cbbE99' as Address, - '421614': '0x1e4c8DFfE8D72aeB63e8dDbE9eF89bc368cbbE99' as Address, - '11155111': '0x1e4c8DFfE8D72aeB63e8dDbE9eF89bc368cbbE99' as Address, - '11155420': '0x1e4c8DFfE8D72aeB63e8dDbE9eF89bc368cbbE99' as Address, + '84532': '0x219A5cE6d1c512D5b050ad2E3d380b8746BE0Cb8' as Address, + '421614': '0x219A5cE6d1c512D5b050ad2E3d380b8746BE0Cb8' as Address, + '11155111': '0x219A5cE6d1c512D5b050ad2E3d380b8746BE0Cb8' as Address, + '11155420': '0x219A5cE6d1c512D5b050ad2E3d380b8746BE0Cb8' as Address, } /** From d7b93dc1c46811405c398d4848438b4095ab9b51 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Mon, 23 Sep 2024 22:20:06 +1000 Subject: [PATCH 35/44] fix: launch projects with payouts and reserved tokens --- .../components/CreateFlowPayoutsTable.tsx | 14 +- .../CustomTokenSettings.tsx | 18 +-- .../ProjectToken/hooks/useProjectTokenForm.ts | 9 +- .../hooks/useLoadInitialStateFromQuery.ts | 4 +- .../determineAvailablePayoutsSelections.ts | 4 +- src/packages/v4/hooks/useLaunchProjectTx.ts | 3 +- src/packages/v4/hooks/useV4PayoutSplits.ts | 32 ++-- src/packages/v4/utils/launchProject.ts | 145 +++++++++--------- src/packages/v4/utils/math.ts | 3 +- .../V4DistributePayoutsModal.tsx | 2 +- .../hooks/useV4CurrentUpcomingPayoutSplits.ts | 7 +- .../hooks/useLoadEditCycleData.tsx | 36 ++--- 12 files changed, 143 insertions(+), 134 deletions(-) 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 index 1cafe219a4..8dc36c16ce 100644 --- a/src/packages/v4/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx +++ b/src/packages/v4/components/Create/components/pages/PayoutsPage/components/CreateFlowPayoutsTable.tsx @@ -1,13 +1,17 @@ import { Form } from 'antd' import { CURRENCY_METADATA, CurrencyName } from 'constants/currency' +import { BigNumber } from 'ethers' 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 { + allocationToSplit, + splitToAllocation, +} from 'packages/v2v3/utils/splitToAllocation' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' import { ReactNode } from 'react' import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' import { fromWad, parseWad } from 'utils/format/formatNumber' @@ -36,7 +40,7 @@ export function CreateFlowPayoutsTable({ const { form, initialValues } = usePayoutsForm() const distributionLimit = !editingDistributionLimit ? 0 - : editingDistributionLimit.amount.eq(MAX_DISTRIBUTION_LIMIT) + : editingDistributionLimit.amount.eq(MAX_PAYOUT_LIMIT) ? undefined : parseFloat(fromWad(editingDistributionLimit?.amount)) @@ -45,7 +49,9 @@ export function CreateFlowPayoutsTable({ const setDistributionLimit = (amount: number | undefined) => { setDistributionLimitAmount( - amount === undefined ? MAX_DISTRIBUTION_LIMIT : parseWad(amount), + amount === undefined + ? BigNumber.from(MAX_PAYOUT_LIMIT) + : parseWad(amount), ) } const setCurrency = (currency: CurrencyName) => { 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 c3489e99c5..9671ed27f0 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 @@ -15,7 +15,8 @@ import { TokenRedemptionRateGraph } from 'components/TokenRedemptionRateGraph/To 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 { MAX_MINT_RATE } from 'packages/v2v3/utils/math' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' import { useAppSelector } from 'redux/hooks/useAppSelector' import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' import { inputMustExistRule } from 'utils/antdRules' @@ -52,9 +53,7 @@ export const CustomTokenSettings = () => { const discountRateDisabled = !parseInt(duration) - const redemptionRateDisabled = distributionLimit?.amount.eq( - MAX_DISTRIBUTION_LIMIT, - ) + const redemptionRateDisabled = distributionLimit?.amount.eq(MAX_PAYOUT_LIMIT) const initalMintRateAccessory = ( @@ -132,9 +131,10 @@ export const CustomTokenSettings = () => {
    - 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 rate is reduced by this percentage every ruleset + (every {formatFundingCycleDuration(duration)}). + The higher this rate, the more incentive to pay this project + earlier. { ) : discountRate === 100 ? ( After {formatFundingCycleDuration(duration)} (your first - ruleset), your project will not issue any tokens unless you edit - the issuance rate. + ruleset), your project will not issue any tokens unless you + edit the issuance rate. ) : ( <> 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 index 9e743f07aa..47c1942e8c 100644 --- a/src/packages/v4/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts +++ b/src/packages/v4/components/Create/components/pages/ProjectToken/hooks/useProjectTokenForm.ts @@ -4,7 +4,6 @@ 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, @@ -14,7 +13,10 @@ import { redemptionRateFrom, reservedRateFrom, } from 'packages/v2v3/utils/math' -import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' +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' @@ -22,6 +24,7 @@ import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionL import { useEditingReservedTokensSplits } from 'redux/hooks/useEditingReservedTokensSplits' import { editingV2ProjectActions } from 'redux/slices/editingV2Project' import { useFormDispatchWatch } from '../../hooks/useFormDispatchWatch' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' export type ProjectTokensFormProps = Partial<{ selection: ProjectTokensSelection @@ -58,7 +61,7 @@ export const useProjectTokensForm = () => { const [distributionLimit] = useEditingDistributionLimit() const redemptionRateDisabled = - !distributionLimit || distributionLimit.amount.eq(MAX_DISTRIBUTION_LIMIT) + !distributionLimit || distributionLimit.amount.eq(MAX_PAYOUT_LIMIT) const discountRateDisabled = !parseInt(fundingCycleData.duration) const initialValues: ProjectTokensFormProps | undefined = useMemo(() => { diff --git a/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts index d4d98c223a..efe17f29bb 100644 --- a/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts +++ b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts @@ -5,7 +5,7 @@ import { ProjectTokensSelection } from 'models/projectTokenSelection' import { TreasurySelection } from 'models/treasurySelection' import { useRouter } from 'next/router' import { ballotStrategiesFn } from 'packages/v2v3/constants/ballotStrategies' -import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' import { useEffect, useState } from 'react' import { useDispatch } from 'react-redux' import { @@ -49,7 +49,7 @@ const parseCreateFlowStateFromInitialState = ( if (distributionLimit === undefined) { treasurySelection = undefined - } else if (distributionLimit.eq(MAX_DISTRIBUTION_LIMIT)) { + } else if (distributionLimit.eq(MAX_PAYOUT_LIMIT)) { treasurySelection = 'unlimited' } else if (distributionLimit.eq(0)) { treasurySelection = 'zero' diff --git a/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts b/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts index 2f8718bb04..5bede951b0 100644 --- a/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts +++ b/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts @@ -1,6 +1,6 @@ import { BigNumber } from 'ethers' import { PayoutsSelection } from 'models/payoutsSelection' -import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' export const determineAvailablePayoutsSelections = ( distributionLimit: BigNumber | undefined, @@ -11,7 +11,7 @@ export const determineAvailablePayoutsSelections = ( if (distributionLimit.eq(0)) { return new Set(['amounts']) } - if (distributionLimit.eq(MAX_DISTRIBUTION_LIMIT)) { + if (distributionLimit.eq(MAX_PAYOUT_LIMIT)) { return new Set(['percentages']) } return new Set(['amounts', 'percentages']) diff --git a/src/packages/v4/hooks/useLaunchProjectTx.ts b/src/packages/v4/hooks/useLaunchProjectTx.ts index dd717acfa3..a337c808aa 100644 --- a/src/packages/v4/hooks/useLaunchProjectTx.ts +++ b/src/packages/v4/hooks/useLaunchProjectTx.ts @@ -9,6 +9,7 @@ import { LaunchV2V3ProjectData } from 'packages/v2v3/hooks/transactor/useLaunchP import { useCallback, useContext } from 'react' import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' import { Address, WaitForTransactionReceiptReturnType } from 'viem' +import { sepolia } from 'viem/chains' import { LaunchV2V3ProjectArgs, transformV2V3CreateArgsToV4, @@ -67,7 +68,7 @@ export function useLaunchProjectTx() { const { writeContractAsync: writeLaunchProject } = useWriteJbControllerLaunchProjectFor() - const chainId = useCurrentRouteChainId() ?? 84532 // Default to Sepolia. + const chainId = useCurrentRouteChainId() ?? sepolia.id // default to sepolia const terminalAddress = chainId ? SUPPORTED_JB_MULTITERMINAL_ADDRESS[chainId] : undefined diff --git a/src/packages/v4/hooks/useV4PayoutSplits.ts b/src/packages/v4/hooks/useV4PayoutSplits.ts index 41708a6b30..59650d9728 100644 --- a/src/packages/v4/hooks/useV4PayoutSplits.ts +++ b/src/packages/v4/hooks/useV4PayoutSplits.ts @@ -1,4 +1,8 @@ -import { JBSplit, SplitPortion } from 'juice-sdk-core' +import { + JBSplit, + NATIVE_TOKEN, + SplitPortion +} from 'juice-sdk-core' import { useJBContractContext, useJBRuleset, @@ -10,22 +14,20 @@ export const useV4CurrentPayoutSplits = () => { const { projectId } = useJBContractContext() const { data: tokenAddress } = useReadJbTokensTokenOf() const { data: ruleset } = useJBRuleset() + const rulesetId = BigInt(ruleset?.id ?? 0) + const groupId = BigInt(tokenAddress ?? NATIVE_TOKEN) // contracts say this is: `uint256(uint160(tokenAddress))` - const groupId = BigInt(tokenAddress ?? 0) // contracts say this is: `uint256(uint160(tokenAddress))` - const { data: _splits, isLoading: currentSplitsLoading } = - useReadJbSplitsSplitsOf({ - args: [projectId, BigInt(ruleset?.id ?? 0), groupId], - query: { - select(data) { - return data.map(d => ({ + return useReadJbSplitsSplitsOf({ + args: [projectId, rulesetId, groupId], + query: { + select(data) { + return data.map( + (d): JBSplit => ({ ...d, percent: new SplitPortion(d.percent), - })) - }, + }), + ) }, - }) - - const splits: JBSplit[] = _splits ? [..._splits] : [] - - return { splits, isLoading: currentSplitsLoading } + }, + }) } diff --git a/src/packages/v4/utils/launchProject.ts b/src/packages/v4/utils/launchProject.ts index 544db1d8e8..c54df91405 100644 --- a/src/packages/v4/utils/launchProject.ts +++ b/src/packages/v4/utils/launchProject.ts @@ -1,10 +1,12 @@ +import { NATIVE_TOKEN, NATIVE_TOKEN_DECIMALS, SplitGroup } from 'juice-sdk-core' import { V2FundingCycleMetadata } from 'packages/v2/models/fundingCycle' import { V2V3FundAccessConstraint, V2V3FundingCycleData, } from 'packages/v2v3/models/fundingCycle' -import { GroupedSplits, SplitGroup } from 'packages/v2v3/models/splits' +import { GroupedSplits } from 'packages/v2v3/models/splits' import { V3FundingCycleMetadata } from 'packages/v3/models/fundingCycle' +import { Address } from 'viem' export type LaunchV2V3ProjectArgs = [ string, // _owner @@ -24,8 +26,8 @@ export function transformV2V3CreateArgsToV4({ currencyTokenAddress, }: { v2v3Args: LaunchV2V3ProjectArgs - primaryNativeTerminal: `0x${string}` - currencyTokenAddress: `0x${string}` + primaryNativeTerminal: Address + currencyTokenAddress: Address }) { const [ _owner, @@ -41,83 +43,86 @@ export function transformV2V3CreateArgsToV4({ const mustStartAtOrAfterNum = parseInt(_mustStartAtOrAfter) - const rulesetConfigurations = [ - { - mustStartAtOrAfter: mustStartAtOrAfterNum ?? 0, // 0 denotes start immediately - duration: _data.duration.toNumber(), - weight: _data.weight.toBigInt(), - decayPercent: _data.discountRate.toNumber(), - approvalHook: _data.ballot as `0x${string}`, + const ruleset = { + mustStartAtOrAfter: mustStartAtOrAfterNum ?? 0, // 0 denotes start immediately + duration: _data.duration.toNumber(), + weight: _data.weight.toBigInt(), + decayPercent: _data.discountRate.toNumber(), - 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, - allowCrosschainSuckerExtension: false, - }, + approvalHook: _data.ballot as Address, - 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}`, - })), - })), + 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 Address, + metadata: 0, + allowCrosschainSuckerExtension: false, + }, - fundAccessLimitGroups: _fundAccessConstraints.map(constraint => ({ - terminal: primaryNativeTerminal, - token: currencyTokenAddress, - payoutLimits: [ - { - amount: constraint.distributionLimit.toBigInt(), - currency: constraint.distributionLimitCurrency.toNumber(), - }, - ] as const, - surplusAllowances: [ - { - amount: constraint.overflowAllowance.toBigInt(), - currency: constraint.overflowAllowanceCurrency.toNumber(), - }, - ] as const, + splitGroups: _groupedSplits.map(group => ({ + groupId: + group.group === SplitGroup.ETHPayout + ? BigInt(NATIVE_TOKEN) + : 1n, // TODO dont hardcode reserved token group as 1n + splits: group.splits.map(split => ({ + preferAddToBalance: Boolean(split.preferClaimed), + percent: split.percent, + projectId: BigInt(parseInt(split.projectId ?? '0x00', 16)), + beneficiary: split.beneficiary as Address, + lockedUntil: split.lockedUntil ?? 0, + hook: split.allocator as Address, })), - }, - ] + })), + + fundAccessLimitGroups: _fundAccessConstraints.map(constraint => ({ + terminal: primaryNativeTerminal, + token: currencyTokenAddress, + payoutLimits: [ + { + amount: constraint.distributionLimit.toBigInt(), + currency: Number(BigInt(NATIVE_TOKEN)), // TODO support USD somehow + }, + ], + surplusAllowances: [ + { + amount: constraint.overflowAllowance.toBigInt(), + currency: Number(BigInt(NATIVE_TOKEN)), + }, + ], + })), + } + + const rulesetConfigurations = [ruleset] const terminalConfigurations = _terminals.map(terminal => ({ - terminal: terminal as `0x${string}`, + terminal: terminal as Address, accountingContextsToAccept: [ - // @v4todo: - // { - // token: currencyTokenAddress, - // decimals: 18, - // currency: 0 - // } - ] as const, + { + token: currencyTokenAddress, + decimals: NATIVE_TOKEN_DECIMALS, + currency: Number(BigInt(currencyTokenAddress)), + }, + ], })) return [ - _owner as `0x${string}`, + _owner as Address, _projectMetadata[0], rulesetConfigurations, terminalConfigurations, diff --git a/src/packages/v4/utils/math.ts b/src/packages/v4/utils/math.ts index d38d9fa04b..a5d55b13ce 100644 --- a/src/packages/v4/utils/math.ts +++ b/src/packages/v4/utils/math.ts @@ -1,7 +1,6 @@ -import * as constants from '@ethersproject/constants' import { feeForAmount } from 'utils/math' -export const MAX_PAYOUT_LIMIT = constants.MaxUint256.toBigInt() +export const MAX_PAYOUT_LIMIT = BigInt('26959946667150639794667015087019630673637144422540572481103610249215') // uint 224, probably a better way lol export const amountSubFee = ( amountWad: bigint | undefined, diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx index 012fd2e815..b559162cc0 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx @@ -32,7 +32,7 @@ export default function V4DistributePayoutsModal({ onCancel?: VoidFunction onConfirmed?: VoidFunction }) { - const { splits: payoutSplits } = useV4CurrentPayoutSplits() + const { data: payoutSplits } = useV4CurrentPayoutSplits() const { data: payoutLimit } = usePayoutLimit() const { distributableAmount: distributable } = useV4DistributableAmount() const { projectId } = useProjectMetadataContext() diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts index 0813a0d511..409405372a 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts @@ -12,11 +12,11 @@ export const useV4CurrentUpcomingPayoutSplits = ( ) => { const { projectId } = useJBContractContext() const { data: tokenAddress } = useReadJbTokensTokenOf() - const { splits, isLoading: currentSplitsLoading } = useV4CurrentPayoutSplits() + const { data: currentSplits, isLoading: currentSplitsLoading } = + useV4CurrentPayoutSplits() const { ruleset: upcomingRuleset, isLoading: upcomingRulesetLoading } = useJBUpcomingRuleset() - const { data: _upcomingSplits, isLoading: upcomingSplitsLoading } = useReadJbSplitsSplitsOf({ args: [ @@ -37,8 +37,9 @@ export const useV4CurrentUpcomingPayoutSplits = ( const upcomingSplits: JBSplit[] = _upcomingSplits ? [..._upcomingSplits] : [] if (type === 'current') { - return { splits, isLoading: currentSplitsLoading } + return { splits: currentSplits, isLoading: currentSplitsLoading } } + return { splits: upcomingSplits, isLoading: upcomingSplitsLoading || upcomingRulesetLoading, diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx index d33170692e..c4bbc78a8a 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx @@ -22,38 +22,33 @@ export const useLoadEditCycleData = () => { const { data: ruleset } = useJBRuleset() const { data: rulesetMetadata } = useJBRulesetMetadata() - const { - ruleset: upcomingRuleset, - } = useJBUpcomingRuleset() + const { ruleset: upcomingRuleset } = useJBUpcomingRuleset() const { splits: reservedTokensSplits } = useV4ReservedSplits() - const { splits: payoutSplits } = useV4CurrentPayoutSplits() + const { data: payoutSplits } = useV4CurrentPayoutSplits() const { data: payoutLimit } = usePayoutLimit() const [editCycleForm] = Form.useForm() useEffect(() => { - if ( - ruleset && - rulesetMetadata - ) { + if (ruleset && rulesetMetadata) { const duration = Number(ruleset.duration) const issuanceRate = upcomingRuleset?.weight.toFloat() ?? 0 const reservedPercent = rulesetMetadata.reservedPercent.formatPercentage() - // : DefaultTokenSettings.reservedTokensPercentage + // : DefaultTokenSettings.reservedTokensPercentage const decayPercent = ruleset.decayPercent.formatPercentage() - // : DefaultTokenSettings.discountRate + // : DefaultTokenSettings.discountRate const redemptionRate = rulesetMetadata.redemptionRate.formatPercentage() - // : DefaultTokenSettings.redemptionRate + // : DefaultTokenSettings.redemptionRate const allowOwnerMinting = rulesetMetadata.allowOwnerMinting - // : DefaultTokenSettings.tokenMinting + // : DefaultTokenSettings.tokenMinting const tokenTransfers = !rulesetMetadata.pauseCreditTransfers - // : DefaultTokenSettings.pauseTransfers + // : DefaultTokenSettings.pauseTransfers const formData: EditCycleFormFields = { duration: secondsToOtherUnit({ @@ -62,14 +57,11 @@ export const useLoadEditCycleData = () => { }), durationUnit: deriveDurationOption(duration), approvalHook: ruleset.approvalHook, - allowSetTerminals: - rulesetMetadata.allowSetTerminals, - allowSetController: - rulesetMetadata.allowSetController, - allowTerminalMigration: - rulesetMetadata.allowTerminalMigration, + allowSetTerminals: rulesetMetadata.allowSetTerminals, + allowSetController: rulesetMetadata.allowSetController, + allowTerminalMigration: rulesetMetadata.allowTerminalMigration, pausePay: rulesetMetadata.pausePay, - payoutSplits, + payoutSplits: payoutSplits ?? [], payoutLimit: payoutLimit ? Number(payoutLimit.amount) : undefined, // TODO: format payoutLimitCurrency: V4CurrencyName(payoutLimit?.currency) ?? 'ETH', holdFees: rulesetMetadata?.holdFees, @@ -88,8 +80,8 @@ export const useLoadEditCycleData = () => { setInitialFormData(formData) editCycleForm.setFieldsValue(formData) } - }, [ruleset, rulesetMetadata]) - + }, [ruleset, rulesetMetadata, payoutSplits]) + return { initialFormData, editCycleForm, From 7a23aa830f009edceef7f923a45f761dcea861b1 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 26 Sep 2024 12:51:59 +1000 Subject: [PATCH 36/44] fix v4 proj sepolia payouts, add footer to proj page (#4466) --- .../Project/ProjectTabs/utils/pairToDatum.ts | 4 +- .../components/HistorySubPanel.test.tsx | 93 ------------------- .../V4ProjectDashboard/V4ProjectDashboard.tsx | 4 +- ... useV4FormatConfigurationCycleSection.tsx} | 3 +- 4 files changed, 7 insertions(+), 97 deletions(-) delete mode 100644 src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistorySubPanel.test.tsx rename src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/{useV4FormatConfigurationCycleSection.ts => useV4FormatConfigurationCycleSection.tsx} (97%) diff --git a/src/components/Project/ProjectTabs/utils/pairToDatum.ts b/src/components/Project/ProjectTabs/utils/pairToDatum.ts index ad0aa0429d..23be161fa8 100644 --- a/src/components/Project/ProjectTabs/utils/pairToDatum.ts +++ b/src/components/Project/ProjectTabs/utils/pairToDatum.ts @@ -2,8 +2,8 @@ import { ConfigurationPanelDatum } from 'components/Project/ProjectTabs/CyclesPa export const pairToDatum = ( name: string, - current: string | undefined, - upcoming: string | undefined | null, + current: string | JSX.Element | undefined, + upcoming: string | JSX.Element | undefined | null, link?: string, easyCopy?: boolean, ): ConfigurationPanelDatum => { diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistorySubPanel.test.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistorySubPanel.test.tsx deleted file mode 100644 index 49e3116fd3..0000000000 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistorySubPanel.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @jest-environment jsdom - */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { render, screen } from '@testing-library/react' -import { BigNumber } from 'ethers' -import { FundingCyclesQuery } from 'generated/graphql' -import { usePastFundingCycles } from '../hooks/usePastFundingCycles' -import { HistorySubPanel } from './HistorySubPanel' - -jest.mock('../hooks/usePastFundingCycles') -jest.mock('@headlessui/react', () => { - return { - __esModule: true, - Disclosure: jest.requireActual('@headlessui/react').Disclosure, - Transition: jest - .fn() - .mockImplementation(({ children, show }) => ( -
    {show && children}
    - )), - } -}) -jest.mock('./HistoricalConfigurationPanel', () => { - return { - __esModule: true, - HistoricalConfigurationPanel: jest - .fn() - .mockImplementation(({ fundingCycle, metadata }) => ( -
    {JSON.stringify(metadata)}
    - )), - } -}) - -describe('HistorySubPanel', () => { - const mockFundingCycles: FundingCyclesQuery['fundingCycles'] = [ - { - ballot: '0x4b9f876c7fc5f6def8991fde639b2c812a85fb12', - ballotRedemptionRate: 6000, - basedOn: 1685615915, - burnPaused: false, - configuration: BigNumber.from('1686266495'), - controllerMigrationAllowed: true, - dataSource: '0x0000000000000000000000000000000000000000', - discountRate: BigNumber.from('15000000'), - distributionsPaused: false, - duration: 604800, - id: '2-397-37', - metadata: BigNumber.from('453635417129768049443073'), - metametadata: 0, - mintingAllowed: false, - mustStartAtOrAfter: null, - number: 37, - pausePay: false, - preferClaimedTokenOverride: false, - projectId: 397, - redeemPaused: false, - redemptionRate: 6000, - reservedRate: 5000, - setControllerAllowed: false, - setTerminalsAllowed: true, - shouldHoldFees: false, - startTimestamp: 1694997023, - terminalMigrationAllowed: true, - transfersPaused: false, - useDataSourceForPay: false, - useDataSourceForRedeem: false, - useTotalOverflowForRedemptions: false, - weight: BigNumber.from('341957057837004498728584'), - withdrawnAmount: BigNumber.from('30779487181046138000000'), - }, - ] - - beforeEach(() => { - ;(usePastFundingCycles as jest.Mock).mockReturnValue({ - loading: false, - data: { - fundingCycles: mockFundingCycles, - }, - error: null, - }) - }) - - it('renders without crashing', () => { - render() - }) - - it('displays correct cycle data', () => { - render() - expect( - screen.getByText(`#${mockFundingCycles[0].number}`), - ).toBeInTheDocument() - }) -}) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx index 329294c3b2..52880d268e 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx @@ -1,3 +1,4 @@ +import { Footer } from 'components/Footer/Footer' import { CoverPhoto } from 'components/Project/ProjectHeader/CoverPhoto' import { SuccessPayView } from 'packages/v4/components/ProjectDashboard/components/SuccessPayView/SuccessPayView' import { useProjectDispatch } from 'packages/v4/components/ProjectDashboard/redux/hooks' @@ -27,7 +28,7 @@ export function V4ProjectDashboard() {
    -
    +
    +
    ) } diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.tsx similarity index 97% rename from src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts rename to src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.tsx index 5bbaf06828..d3574c11f7 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.tsx @@ -2,6 +2,7 @@ import { t } from '@lingui/macro' import { ConfigurationPanelDatum } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' import { JBRulesetData } from 'juice-sdk-core' +import { NativeTokenValue } from 'juice-sdk-react' import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption' import { getApprovalStrategyByAddress } from 'packages/v4/utils/approvalHooks' import { formatCurrencyAmount } from 'packages/v4/utils/formatCurrencyAmount' @@ -83,7 +84,7 @@ export const useV4FormatConfigurationCycleSection = ({ const payoutsDatum: ConfigurationPanelDatum = useMemo(() => { const { amount, currency } = payoutLimitAmountCurrency ?? {} - const currentPayout = formatPayoutAmount(amount, currency) + const currentPayout = if ( upcomingPayoutLimitAmountCurrency === null || From 4209bbc6a58f255b1061873b4b595f149efe48d4 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 27 Sep 2024 11:31:53 +1000 Subject: [PATCH 37/44] fix: v4 edit ruleset tx (#4467) --- .../PayoutsTable/hooks/usePayoutsTable.tsx | 6 ++++-- src/packages/v4/hooks/useEditRulesetTx.ts | 13 +++++++----- src/packages/v4/utils/distributions.ts | 2 +- src/packages/v4/utils/editRuleset.ts | 20 ++++++++----------- .../hooks/usePayoutsSectionValues.ts | 6 +++--- .../hooks/useTokensSectionValues.ts | 13 +++++++----- .../hooks/useLoadEditCycleData.tsx | 5 ++++- 7 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx b/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx index d4b77219c0..846dd4cd20 100644 --- a/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx +++ b/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx @@ -324,9 +324,11 @@ export const usePayoutsTable = () => { }) : undefined // undefined means DL is infinite - const newSplitPercentPPB = round( - (_amount / (newDistributionLimit ?? 0)) * ONE_BILLION, + const newSplitPercentPPB = round(newDistributionLimit ? + (_amount / (newDistributionLimit)) * ONE_BILLION + : 0 ) + let adjustedSplits: Split[] = newSplits ?? payoutSplits // recalculate all split percents based on newly added split amount if (newDistributionLimit && !distributionLimitIsInfinite) { diff --git a/src/packages/v4/hooks/useEditRulesetTx.ts b/src/packages/v4/hooks/useEditRulesetTx.ts index 83f6b84162..7d37933690 100644 --- a/src/packages/v4/hooks/useEditRulesetTx.ts +++ b/src/packages/v4/hooks/useEditRulesetTx.ts @@ -1,7 +1,7 @@ import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' import { useWallet } from 'hooks/Wallet' import { NATIVE_TOKEN } from 'juice-sdk-core' -import { useJBContractContext, useWriteJbControllerLaunchRulesetsFor } from 'juice-sdk-react' +import { useJBContractContext, useWriteJbControllerQueueRulesetsOf } from 'juice-sdk-react' import { useCallback, useContext } from 'react' import { transformEditCycleFormFieldsToTxArgs } from '../utils/editRuleset' import { EditCycleFormFields } from '../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields' @@ -17,8 +17,8 @@ export interface EditMetadataTxOpts { * @returns A function that deploys a project. */ export function useEditRulesetTx() { - const { writeContractAsync: writeEditRuleset } = useWriteJbControllerLaunchRulesetsFor() - const { contracts } = useJBContractContext() + const { writeContractAsync: writeEditRuleset } = useWriteJbControllerQueueRulesetsOf() + const { contracts, projectId } = useJBContractContext() const { addTransaction } = useContext(TxHistoryContext) @@ -43,16 +43,19 @@ export function useEditRulesetTx() { const args = transformEditCycleFormFieldsToTxArgs({ formValues, primaryNativeTerminal: contracts.primaryNativeTerminal.data, - tokenAddress: NATIVE_TOKEN + tokenAddress: NATIVE_TOKEN, + projectId }) try { // SIMULATE TX: // const encodedData = encodeFunctionData({ // abi: jbControllerAbi, // ABI of the contract - // functionName: 'launchRulesetsFor', + // functionName: 'queueRulesetsOf', // args, // }) + // console.log('contracts address: ', contracts.controller.data) + // console.log('encodedData: ', encodedData) const hash = await writeEditRuleset({ address: contracts.controller.data, diff --git a/src/packages/v4/utils/distributions.ts b/src/packages/v4/utils/distributions.ts index dfaa0091e8..9924e7194c 100644 --- a/src/packages/v4/utils/distributions.ts +++ b/src/packages/v4/utils/distributions.ts @@ -116,7 +116,7 @@ export function ensureSplitsSumTo100Percent({ } // Calculate the ratio to adjust each split by - const ratio = max / currentTotal + const ratio = currentTotal ? max / currentTotal : 0 // Adjust each split const adjustedSplits = splits.map(split => { diff --git a/src/packages/v4/utils/editRuleset.ts b/src/packages/v4/utils/editRuleset.ts index 0d1533946a..fd59448c66 100644 --- a/src/packages/v4/utils/editRuleset.ts +++ b/src/packages/v4/utils/editRuleset.ts @@ -1,4 +1,6 @@ +import { NATIVE_TOKEN } from "juice-sdk-core"; import round from "lodash/round"; +import { parseWad } from "utils/format/formatNumber"; import { otherUnitToSeconds } from "utils/format/formatTime"; import { EditCycleFormFields } from "../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields"; @@ -6,10 +8,12 @@ export function transformEditCycleFormFieldsToTxArgs({ formValues, primaryNativeTerminal, tokenAddress, + projectId, }: { formValues: EditCycleFormFields; primaryNativeTerminal: `0x${string}`; tokenAddress: `0x${string}`; + projectId: bigint; }) { const now = round(new Date().getTime() / 1000); const mustStartAtOrAfter = now; @@ -56,7 +60,7 @@ export function transformEditCycleFormFieldsToTxArgs({ splitGroups: [ { - groupId: BigInt(1), // Assuming 1 for payout splits + groupId: BigInt(NATIVE_TOKEN), splits: formValues.payoutSplits.map((split) => ({ preferAddToBalance: Boolean(split.preferAddToBalance), percent: Number(split.percent.value), @@ -67,7 +71,7 @@ export function transformEditCycleFormFieldsToTxArgs({ })), }, { - groupId: BigInt(2), // Assuming 2 for reserved tokens splits + groupId: BigInt(1), splits: formValues.reservedTokensSplits.map((split) => ({ preferAddToBalance: Boolean(split.preferAddToBalance), percent: Number(split.percent.value), @@ -85,7 +89,7 @@ export function transformEditCycleFormFieldsToTxArgs({ token: tokenAddress, payoutLimits: [ { - amount: BigInt(formValues.payoutLimit ?? "0"), + amount: parseWad(formValues.payoutLimit).toBigInt(), currency: 1, // Assuming currency is constant (e.g., USD) }, ], @@ -100,17 +104,9 @@ export function transformEditCycleFormFieldsToTxArgs({ }, ]; - const terminalConfigurations = [ - { - terminal: primaryNativeTerminal, - accountingContextsToAccept: [] as const, - }, - ]; - return [ - BigInt(now), // Convert the current timestamp to bigint for the first argument + projectId, rulesetConfigurations, - terminalConfigurations, formValues.memo ?? "", ] as const; } diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts index d3179df115..6cfc84c0e0 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts @@ -1,9 +1,9 @@ -import { WeiPerEther } from '@ethersproject/constants' import { CurrencyName } from 'constants/currency' import { JBSplit } from 'juice-sdk-core' import { distributionLimitsEqual } from 'packages/v4/utils/distributions' import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' import { splitsListsHaveDiff } from 'packages/v4/utils/v4Splits' +import { parseWad } from 'utils/format/formatNumber' import { useEditCycleFormContext } from '../../EditCycleFormContext' export const usePayoutsSectionValues = () => { @@ -27,10 +27,10 @@ export const usePayoutsSectionValues = () => { const newDistributionLimitNum: number = editCycleForm?.getFieldValue('payoutLimit') const newDistributionLimit = - newDistributionLimitNum ? BigInt(newDistributionLimitNum) * WeiPerEther.toBigInt() : MAX_PAYOUT_LIMIT + newDistributionLimitNum ? parseWad(newDistributionLimitNum).toBigInt() : MAX_PAYOUT_LIMIT const currentDistributionLimitNum = initialFormData?.payoutLimit - const currentDistributionLimit = currentDistributionLimitNum ? BigInt(currentDistributionLimitNum) * WeiPerEther.toBigInt() : MAX_PAYOUT_LIMIT + const currentDistributionLimit = currentDistributionLimitNum ? parseWad(currentDistributionLimitNum).toBigInt() : MAX_PAYOUT_LIMIT const distributionLimitHasDiff = !distributionLimitsEqual(currentDistributionLimit, newDistributionLimit) || diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts index 4f3161da98..934f2a791e 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts @@ -1,4 +1,4 @@ -import { useJBTokenContext } from 'juice-sdk-react' +import { useJBRuleset, useJBTokenContext } from 'juice-sdk-react' import round from 'lodash/round' import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' import { splitsListsHaveDiff } from 'packages/v4/utils/v4Splits' @@ -11,6 +11,7 @@ export const useTokensSectionValues = () => { const formValues: EditCycleFormFields = editCycleForm?.getFieldsValue(true) + const { data: ruleset } = useJBRuleset() const { ruleset: upcomingRuleset, } = useJBUpcomingRuleset() @@ -43,11 +44,13 @@ export const useTokensSectionValues = () => { ) const onlyDiscountRateApplied = - upcomingRuleset && - newMintRate && - round(upcomingRuleset?.weight.toFloat(), 4) === round(newMintRate, 4) + ( + upcomingRuleset && + newMintRate && + round(upcomingRuleset?.weight.toFloat(), 4) === round(newMintRate, 4) + ) || ruleset?.duration === 0 - const mintRateHasDiff = !onlyDiscountRateApplied + const mintRateHasDiff = !onlyDiscountRateApplied const reservedRateHasDiff = Boolean( currentReservedRate && diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx index c4bbc78a8a..0d2e0f5ea9 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx @@ -6,6 +6,7 @@ import { secondsToOtherUnit, } from 'utils/format/formatTime' +import { Ether } from 'juice-sdk-core' import { useJBRuleset, useJBRulesetMetadata } from 'juice-sdk-react' import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' @@ -29,6 +30,8 @@ export const useLoadEditCycleData = () => { const { data: payoutLimit } = usePayoutLimit() const [editCycleForm] = Form.useForm() + + const payoutLimitAmount = new Ether(payoutLimit.amount).toFloat() useEffect(() => { if (ruleset && rulesetMetadata) { @@ -62,7 +65,7 @@ export const useLoadEditCycleData = () => { allowTerminalMigration: rulesetMetadata.allowTerminalMigration, pausePay: rulesetMetadata.pausePay, payoutSplits: payoutSplits ?? [], - payoutLimit: payoutLimit ? Number(payoutLimit.amount) : undefined, // TODO: format + payoutLimit: payoutLimitAmount, payoutLimitCurrency: V4CurrencyName(payoutLimit?.currency) ?? 'ETH', holdFees: rulesetMetadata?.holdFees, issuanceRate, From d5e6d8cc0a7b9fc2d1a7a5d5aba5a3a159ef2fb8 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 27 Sep 2024 13:48:03 +1000 Subject: [PATCH 38/44] fix: v4 launch erc20 tx (#4468) --- .../buttons/AddTokenToMetamaskButton.tsx | 4 ++-- .../v4/hooks/useV4IssueErc20TokenTx.ts | 20 +++++++++++-------- .../CreateErc20TokenSettingsPage.tsx | 4 ++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/buttons/AddTokenToMetamaskButton.tsx b/src/components/buttons/AddTokenToMetamaskButton.tsx index f21a780758..f6a0a7e3ef 100644 --- a/src/components/buttons/AddTokenToMetamaskButton.tsx +++ b/src/components/buttons/AddTokenToMetamaskButton.tsx @@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro' import type { MetaMaskInpageProvider } from '@metamask/providers' import { Button } from 'antd' import { providers } from 'ethers' -import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' +import useSymbolOfERC20 from 'hooks/ERC20/useSymbolOfERC20' import { twMerge } from 'tailwind-merge' import { Hash } from 'viem' @@ -28,7 +28,7 @@ const useMetamask = () => { function useAddTokenToWalletRequest({ tokenAddress }: { tokenAddress: Hash }) { const ethereum = useMetamask() - const { data: tokenSymbol } = useNameOfERC20(tokenAddress) + const { data: tokenSymbol } = useSymbolOfERC20(tokenAddress) if (!ethereum) { return diff --git a/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts b/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts index 237bb92a42..aff75e3842 100644 --- a/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts +++ b/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts @@ -1,15 +1,15 @@ import { useCallback, useContext } from 'react' import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' -import { useJBContractContext, useWriteJbTokensDeployErc20For } from 'juice-sdk-react' -import { zeroAddress } from 'viem' +import { useJBContractContext, useWriteJbControllerDeployErc20For } from 'juice-sdk-react' +import { Address, zeroAddress } from 'viem' import { BaseTxOpts } from '../models/transactions' export function useV4IssueErc20TokenTx() { const { addTransaction } = useContext(TxHistoryContext) const { projectId, contracts } = useJBContractContext() - const { writeContractAsync: deployErc20 } = useWriteJbTokensDeployErc20For() + const { writeContractAsync: deployErc20Tx } = useWriteJbControllerDeployErc20For() return useCallback ( async ({ name, symbol }: { @@ -33,17 +33,20 @@ export function useV4IssueErc20TokenTx() { try { // SIMULATE TX: // const encodedData = encodeFunctionData({ - // abi: jbTokensAbi, // ABI of the contract - // functionName: 'deployErc20For', + // abi: jbControllerAbi, // ABI of the contract + // functionName: 'deployERC20For', // args, // }) + // console.log('contract', contracts.controller.data) + // console.log('encodedData', encodedData) - const hash = await deployErc20({ + const hash = await deployErc20Tx({ args, + address: contracts.controller.data as Address }) onTransactionPendingCallback(hash) - addTransaction?.('Edit Ruleset', { hash }) + addTransaction?.('Launch ERC20 Token', { hash }) // const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( // wagmiConfig, // { @@ -59,9 +62,10 @@ export function useV4IssueErc20TokenTx() { } }, [ - deployErc20, + deployErc20Tx, projectId, addTransaction, + contracts.controller.data, ], ) } diff --git a/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx b/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx index d2fded6190..c1dcb397af 100644 --- a/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx +++ b/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx @@ -24,7 +24,7 @@ const issueErc20TokenTx = useV4IssueErc20TokenTx() const canCreateErc20Token = !projectHasErc20Token && hasIssueTicketsPermission - async function onSetENSNameFormSaved(values: IssueErc20TokenTxArgs) { + async function onIssueErc20FormSaved(values: IssueErc20TokenTxArgs) { await form.validateFields() if (!issueErc20TokenTx) { @@ -78,7 +78,7 @@ const issueErc20TokenTx = useV4IssueErc20TokenTx()
    Date: Fri, 27 Sep 2024 17:00:35 +1000 Subject: [PATCH 39/44] fix: more v4 settings bugs (#4469) --- .../ProjectHeader/ProjectHeaderPopupMenu.tsx | 70 +++++++++---------- src/locales/messages.pot | 6 -- src/packages/v4/hooks/useEditRulesetTx.ts | 14 ++-- src/packages/v4/utils/editRuleset.ts | 9 +-- src/packages/v4/utils/launchProject.ts | 4 +- .../V4ProjectDashboard/V4ProjectHeader.tsx | 6 +- .../useV4FormatConfigurationCycleSection.tsx | 15 +--- .../useV4FormatConfigurationTokenSection.ts | 8 +-- .../V4ReservedTokensSubPanel.tsx | 6 +- 9 files changed, 63 insertions(+), 75 deletions(-) diff --git a/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx b/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx index ff07259f6b..dbe189de07 100644 --- a/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx +++ b/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx @@ -1,8 +1,6 @@ import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline' import { Trans } from '@lingui/macro' -import { BookmarkButtonIcon } from 'components/buttons/BookmarkButton/BookmarkButtonIcon' import { useBookmarkButton } from 'components/buttons/BookmarkButton/hooks/useBookmarkButton' -import { SubscribeButtonIcon } from 'components/buttons/SubscribeButton/SubscribeButtonIcon' import { useSubscribeButton } from 'components/buttons/SubscribeButton/hooks/useSubscribeButton' import { PV_V2 } from 'constants/pv' import useMobile from 'hooks/useMobile' @@ -54,42 +52,42 @@ export function ProjectHeaderPopupMenu({ href, })) : []), - { - id: 'subscribe', - label: ( - <> - + // { + // id: 'subscribe', + // label: ( + // <> + // - - Get notifications - - - ), - onClick: onSubscribeButtonClicked, - }, - { - id: 'bookmark', - label: ( - <> - - - Save project - - - ), - onClick(ev) { - ev.preventDefault() - ev.stopPropagation() + // + // Get notifications + // + // + // ), + // onClick: onSubscribeButtonClicked, + // }, + // { + // id: 'bookmark', + // label: ( + // <> + // + // + // Save project + // + // + // ), + // onClick(ev) { + // ev.preventDefault() + // ev.stopPropagation() - onBookmarkButtonClicked() - }, - }, + // onBookmarkButtonClicked() + // }, + // }, { id: 'tools', label: ( diff --git a/src/locales/messages.pot b/src/locales/messages.pot index a6d5c40ccf..08f3ebe39c 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -92,9 +92,6 @@ msgstr "" msgid "Claim {tokensLabel} as ERC-20" msgstr "" -msgid "Save project" -msgstr "" - msgid "Total issuance" msgstr "" @@ -2810,9 +2807,6 @@ msgstr "" msgid "We've disabled payments because the project has opted to reserve 100% of new tokens. You would receive no tokens from your payment." msgstr "" -msgid "Get notifications" -msgstr "" - msgid "Unarchiving your project has the following effects:" msgstr "" diff --git a/src/packages/v4/hooks/useEditRulesetTx.ts b/src/packages/v4/hooks/useEditRulesetTx.ts index 7d37933690..3728579a7d 100644 --- a/src/packages/v4/hooks/useEditRulesetTx.ts +++ b/src/packages/v4/hooks/useEditRulesetTx.ts @@ -1,7 +1,9 @@ +import { waitForTransactionReceipt } from '@wagmi/core' import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' import { useWallet } from 'hooks/Wallet' import { NATIVE_TOKEN } from 'juice-sdk-core' import { useJBContractContext, useWriteJbControllerQueueRulesetsOf } from 'juice-sdk-react' +import { wagmiConfig } from 'packages/v4/wagmiConfig' import { useCallback, useContext } from 'react' import { transformEditCycleFormFieldsToTxArgs } from '../utils/editRuleset' import { EditCycleFormFields } from '../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields' @@ -64,12 +66,12 @@ export function useEditRulesetTx() { onTransactionPendingCallback(hash) addTransaction?.('Edit Ruleset', { hash }) - // const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( - // wagmiConfig, - // { - // hash, - // }, - // ) + await waitForTransactionReceipt( + wagmiConfig, + { + hash, + }, + ) onTransactionConfirmedCallback() } catch (e) { diff --git a/src/packages/v4/utils/editRuleset.ts b/src/packages/v4/utils/editRuleset.ts index fd59448c66..ca46980f54 100644 --- a/src/packages/v4/utils/editRuleset.ts +++ b/src/packages/v4/utils/editRuleset.ts @@ -1,5 +1,6 @@ import { NATIVE_TOKEN } from "juice-sdk-core"; import round from "lodash/round"; +import { issuanceRateFrom } from "packages/v2v3/utils/math"; import { parseWad } from "utils/format/formatNumber"; import { otherUnitToSeconds } from "utils/format/formatTime"; import { EditCycleFormFields } from "../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields"; @@ -22,8 +23,8 @@ export function transformEditCycleFormFieldsToTxArgs({ duration: formValues.duration, unit: formValues.durationUnit.value, }) - const weight = BigInt(formValues.issuanceRate); - const decayPercent = formValues.decayPercent; + const weight = BigInt(issuanceRateFrom(formValues.issuanceRate.toString())); + const decayPercent = round(formValues.decayPercent * 10000000); const approvalHook = formValues.approvalHook; const rulesetConfigurations = [ @@ -35,8 +36,8 @@ export function transformEditCycleFormFieldsToTxArgs({ approvalHook, metadata: { - reservedPercent: formValues.reservedPercent, - redemptionRate: formValues.redemptionRate, + reservedPercent: formValues.reservedPercent * 100, + redemptionRate: formValues.redemptionRate * 100, baseCurrency: 1, // Assuming base currency is a constant value, typically USD pausePay: formValues.pausePay, pauseRedeem: false, // Defaulting this value since it's not in formValues diff --git a/src/packages/v4/utils/launchProject.ts b/src/packages/v4/utils/launchProject.ts index c54df91405..21292b1834 100644 --- a/src/packages/v4/utils/launchProject.ts +++ b/src/packages/v4/utils/launchProject.ts @@ -1,4 +1,5 @@ import { NATIVE_TOKEN, NATIVE_TOKEN_DECIMALS, SplitGroup } from 'juice-sdk-core' +import round from 'lodash/round' import { V2FundingCycleMetadata } from 'packages/v2/models/fundingCycle' import { V2V3FundAccessConstraint, @@ -42,9 +43,10 @@ export function transformV2V3CreateArgsToV4({ ] = v2v3Args const mustStartAtOrAfterNum = parseInt(_mustStartAtOrAfter) + const now = round(new Date().getTime() / 1000) const ruleset = { - mustStartAtOrAfter: mustStartAtOrAfterNum ?? 0, // 0 denotes start immediately + mustStartAtOrAfter: mustStartAtOrAfterNum > now ? mustStartAtOrAfterNum : now, duration: _data.duration.toNumber(), weight: _data.weight.toBigInt(), decayPercent: _data.discountRate.toNumber(), diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx index 1171fdfa41..db5bcef5f3 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx @@ -6,7 +6,6 @@ import EthereumAddress from 'components/EthereumAddress' import { GnosisSafeBadge } from 'components/Project/ProjectHeader/GnosisSafeBadge' import { useSocialLinks } from 'components/Project/ProjectHeader/hooks/useSocialLinks' import { ProjectHeaderLogo } from 'components/Project/ProjectHeader/ProjectHeaderLogo' -import { ProjectHeaderPopupMenu } from 'components/Project/ProjectHeader/ProjectHeaderPopupMenu' import { SocialLinkButton } from 'components/Project/ProjectHeader/SocialLinkButton' // import { Subtitle } from 'components/Project/ProjectHeader/Subtitle' import { TruncatedText } from 'components/TruncatedText' @@ -58,7 +57,8 @@ export const V4ProjectHeader = ({ className }: { className?: string }) => {
    {projectId ? ( isMobile ? ( - + // + <> ) : ( <>
    @@ -73,7 +73,7 @@ export const V4ProjectHeader = ({ className }: { className?: string }) => { /> ))}
    - + {/* @v4todo: */} {canQueueRuleSets && ( { if (amount === undefined || amount === MAX_PAYOUT_LIMIT) return t`Unlimited` if (amount === 0n) return t`Zero (no payouts)` - return formatCurrencyAmount({ - amount: Number(amount) / 1e18, // Assuming fromWad - currency, - }) + return } const payoutsDatum: ConfigurationPanelDatum = useMemo(() => { const { amount, currency } = payoutLimitAmountCurrency ?? {} - const currentPayout = + const currentPayout = formatPayoutAmount(amount) if ( upcomingPayoutLimitAmountCurrency === null || @@ -97,13 +92,9 @@ export const useV4FormatConfigurationCycleSection = ({ upcomingPayoutLimitAmountCurrency?.amount !== undefined ? upcomingPayoutLimitAmountCurrency.amount : amount - const upcomingPayoutLimitCurrency = - upcomingPayoutLimitAmountCurrency?.currency !== undefined - ? upcomingPayoutLimitAmountCurrency.currency - : currency + const upcomingPayout = formatPayoutAmount( upcomingPayoutLimit, - upcomingPayoutLimitCurrency, ) return pairToDatum(t`Payouts`, currentPayout, upcomingPayout) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts index 9d9c1a41de..bca38922ca 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts @@ -41,7 +41,7 @@ export const useV4FormatConfigurationTokenSection = ({ : undefined const totalIssuanceRateDatum: ConfigurationPanelDatum = useMemo(() => { - const current = currentTotalIssuanceRate + const current = currentTotalIssuanceRate !== undefined ? `${currentTotalIssuanceRate} ${tokenSymbol}/ETH` : undefined @@ -49,7 +49,7 @@ export const useV4FormatConfigurationTokenSection = ({ return pairToDatum(t`Total issuance rate`, current, null) } - const queued = queuedTotalIssuanceRate + const queued = queuedTotalIssuanceRate !== undefined ? `${queuedTotalIssuanceRate} ${tokenSymbol}/ETH` : undefined @@ -74,7 +74,7 @@ export const useV4FormatConfigurationTokenSection = ({ currentTotalIssuanceRate * reservedPercentFloat : undefined - const current = currentPayerIssuanceRate + const current = currentPayerIssuanceRate !== undefined ? `${currentPayerIssuanceRate} ${tokenSymbol}/ETH` : undefined @@ -91,7 +91,7 @@ export const useV4FormatConfigurationTokenSection = ({ queuedTotalIssuanceRate && _reservedPercent ? queuedTotalIssuanceRate - queuedTotalIssuanceRate * _reservedPercent : undefined - const queued = queuedPayerIssuanceRate + const queued = queuedPayerIssuanceRate !== undefined ? `${queuedPayerIssuanceRate} ${tokenSymbol}/ETH` : undefined diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx index fa13d73bc3..be5f9875a9 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx @@ -53,9 +53,9 @@ export const V4ReservedTokensSubPanel = ({ {pendingReservedTokensFormatted || reservedPercent || From 1c473e28a7d073befa515d12bc3eb600a12aa328 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 27 Sep 2024 20:27:04 +1000 Subject: [PATCH 40/44] fix subgraph --- src/packages/v4/graphql/useSubgraphQuery.ts | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/packages/v4/graphql/useSubgraphQuery.ts b/src/packages/v4/graphql/useSubgraphQuery.ts index e8c6a7586c..f9437349ac 100644 --- a/src/packages/v4/graphql/useSubgraphQuery.ts +++ b/src/packages/v4/graphql/useSubgraphQuery.ts @@ -1,6 +1,8 @@ import { type TypedDocumentNode } from '@graphql-typed-document-node/core' import { useQuery, type UseQueryResult } from '@tanstack/react-query' import request from 'graphql-request' +import { useJBChainId } from 'juice-sdk-react' +import { v4SubgraphUri } from 'lib/apollo/subgraphUri' // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt // @ts-ignore @@ -8,29 +10,27 @@ BigInt.prototype.toJSON = function () { return { $bigint: this.toString() } } -export function useSubgraphQuery({ - document, - enabled = true, - variables +export function useSubgraphQuery({ + document, + enabled = true, + variables, }: { - document: TypedDocumentNode, - enabled?: boolean, + document: TypedDocumentNode + enabled?: boolean variables?: TVariables }): UseQueryResult { + const chainId = useJBChainId() return useQuery({ // eslint-disable-next-line @typescript-eslint/no-explicit-any queryKey: [(document.definitions[0] as any).name.value, variables], queryFn: async ({ queryKey }) => { - if (!process.env.NEXT_PUBLIC_V4_SUBGRAPH_URL) { - throw new Error('NEXT_PUBLIC_V4_SUBGRAPH_URL is not set') + if (!chainId) { + throw new Error('useSubgraphQuery needs a chainId, none provided') } + const uri = v4SubgraphUri(chainId) - return request( - process.env.NEXT_PUBLIC_V4_SUBGRAPH_URL, - document, - queryKey[1] ? queryKey[1] : undefined, - ) + return request(uri, document, queryKey[1] ? queryKey[1] : undefined) }, - enabled + enabled, }) } From 9ee3bd85583fadbad7fdad914a9f5ebc01532658 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 27 Sep 2024 20:47:10 +1000 Subject: [PATCH 41/44] Add v4 trending projs --- src/components/Home/HomepageProjectCard.tsx | 12 +++- .../Projects/TrendingProjectCard.tsx | 12 +++- .../queries/trendingProjectsV4.graphql | 29 ++++++++++ src/pages/api/projects/trending.ts | 55 ++++++++++++------- 4 files changed, 83 insertions(+), 25 deletions(-) create mode 100644 src/packages/v4/graphql/queries/trendingProjectsV4.graphql diff --git a/src/components/Home/HomepageProjectCard.tsx b/src/components/Home/HomepageProjectCard.tsx index 641175d84d..cf30382632 100644 --- a/src/components/Home/HomepageProjectCard.tsx +++ b/src/components/Home/HomepageProjectCard.tsx @@ -3,10 +3,11 @@ import { Skeleton } from 'antd' import { HomepageCard } from 'components/Home/HomepageCard' import ProjectLogo from 'components/ProjectLogo' import ETHAmount from 'components/currency/ETHAmount' -import { PV_V2 } from 'constants/pv' +import { PV_V2, PV_V4 } from 'constants/pv' import { useProjectMetadata } from 'hooks/useProjectMetadata' import { SubgraphQueryProject } from 'models/subgraphProjects' import { v2v3ProjectRoute } from 'packages/v2v3/utils/routes' +import { v4ProjectRoute } from 'packages/v4/utils/routes' function Statistic({ name, @@ -44,7 +45,7 @@ export function HomepageProjectCard({ project: Pick< SubgraphQueryProject, 'metadataUri' | 'volume' | 'paymentsCount' | 'handle' | 'pv' | 'projectId' - > + > & { chainId?: number } lazyLoad?: boolean }) { const { data: metadata, isLoading } = useProjectMetadata(project.metadataUri) @@ -52,7 +53,12 @@ export function HomepageProjectCard({ return ( + > & { chainId?: number } rank: number size?: 'sm' | 'lg' bookmarked?: boolean @@ -62,7 +63,12 @@ export default function TrendingProjectCard({ prefetch={false} key={project.handle} href={ - project.pv === PV_V2 + project.pv === PV_V4 && project.chainId + ? v4ProjectRoute({ + projectId: project.projectId, + chainId: project.chainId, + }) + : project.pv === PV_V2 ? v2v3ProjectRoute(project) : `/p/${project.handle}` } diff --git a/src/packages/v4/graphql/queries/trendingProjectsV4.graphql b/src/packages/v4/graphql/queries/trendingProjectsV4.graphql new file mode 100644 index 0000000000..949b14097d --- /dev/null +++ b/src/packages/v4/graphql/queries/trendingProjectsV4.graphql @@ -0,0 +1,29 @@ +query TrendingProjectsV4( + $where: Project_filter + $first: Int + $skip: Int + $orderBy: Project_orderBy + $orderDirection: OrderDirection + $block: Block_height +) { + projects( + where: $where + first: $first + skip: $skip + orderBy: $orderBy + orderDirection: desc + block: $block + ) { + id + projectId + handle + createdAt + metadataUri + volume + trendingScore + paymentsCount + trendingPaymentsCount + trendingVolume + createdWithinTrendingWindow + } +} diff --git a/src/pages/api/projects/trending.ts b/src/pages/api/projects/trending.ts index 321fde416b..e4c122e1c0 100644 --- a/src/pages/api/projects/trending.ts +++ b/src/pages/api/projects/trending.ts @@ -1,4 +1,4 @@ -import { PV_V1, PV_V2 } from 'constants/pv' +import { PV_V1, PV_V2, PV_V4 } from 'constants/pv' import { RomanStormVariables } from 'constants/romanStorm' import { BigNumber } from 'ethers' import { @@ -8,11 +8,13 @@ import { TrendingProjectsDocument, TrendingProjectsQuery, } from 'generated/graphql' -import { serverClient } from 'lib/apollo/serverClient' +import { serverClient, v4SepoliaServerClient } from 'lib/apollo/serverClient' import { NextApiHandler } from 'next' import { V1ArchivedProjectIds } from 'packages/v1/constants/archivedProjects' import { V2ArchivedProjectIds } from 'packages/v2v3/constants/archivedProjects' +import { TrendingProjectsV4Document } from 'packages/v4/graphql/client/graphql' import { getSubgraphIdForProject } from 'utils/graph' +import { sepolia } from 'viem/chains' const CACHE_MAXAGE = 60 * 5 // 5 minutes @@ -31,25 +33,40 @@ const handler: NextApiHandler = async (req, res) => { const rawFirst = req.query.count // TODO probably can use Yup for this const first = typeof rawFirst === 'string' ? parseInt(rawFirst) : undefined try { - const projectsRes = await serverClient.query< - TrendingProjectsQuery, - QueryProjectsArgs - >({ - query: TrendingProjectsDocument, - variables: { - where: { - trendingScore_gt: '0' as any, // eslint-disable-line @typescript-eslint/no-explicit-any - ...(ARCHIVED_SUBGRAPH_IDS.length - ? { id_not_in: ARCHIVED_SUBGRAPH_IDS } - : {}), // `id_not_in: ` will return 0 results + const [projectsRes, v4SepoliaProjectsRes] = await Promise.all([ + serverClient.query({ + query: TrendingProjectsDocument, + variables: { + where: { + trendingScore_gt: '0' as any, // eslint-disable-line @typescript-eslint/no-explicit-any + ...(ARCHIVED_SUBGRAPH_IDS.length + ? { id_not_in: ARCHIVED_SUBGRAPH_IDS } + : {}), // `id_not_in: ` will return 0 results + }, + first, + orderBy: Project_OrderBy.trendingScore, + orderDirection: OrderDirection.desc, + }, + }), + v4SepoliaServerClient.query({ + query: TrendingProjectsV4Document, + variables: { + where: { + trendingScore_gt: '0' as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }, + first, + orderBy: Project_OrderBy.trendingScore, + orderDirection: OrderDirection.desc, }, - first, - orderBy: Project_OrderBy.trendingScore, - orderDirection: OrderDirection.desc, - }, - }) + }), + ]) - const projects = [...projectsRes.data.projects] + const projects = [ + ...projectsRes.data.projects, + ...v4SepoliaProjectsRes.data.projects.map(p => { + return { ...p, chainId: sepolia.id, pv: PV_V4 } + }), + ] try { const romanProjectIndex = projects.findIndex( From 889fdbfa8678f4ac98102b74e1cae52ccd228ca8 Mon Sep 17 00:00:00 2001 From: aeolian <94939382+aeolianeth@users.noreply.github.com> Date: Fri, 27 Sep 2024 20:49:05 +1000 Subject: [PATCH 42/44] Imporve homepage card loading logic --- src/components/Home/HomepageProjectCard.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/Home/HomepageProjectCard.tsx b/src/components/Home/HomepageProjectCard.tsx index cf30382632..92db3ac357 100644 --- a/src/components/Home/HomepageProjectCard.tsx +++ b/src/components/Home/HomepageProjectCard.tsx @@ -63,7 +63,7 @@ export function HomepageProjectCard({ : `/p/${project.handle}` } img={ - metadata && !isLoading ? ( + !isLoading ? ( - {metadata.name} -
    - ) : ( + isLoading ? ( + ) : !metadata ? ( + '---' + ) : ( +
    + {metadata.name} +
    ) } description={ From d765de1dd760ded7c581bf42e985a6bd096a85e0 Mon Sep 17 00:00:00 2001 From: johnnyd-eth Date: Sat, 28 Sep 2024 07:47:03 +1000 Subject: [PATCH 43/44] temporarily disable v2v3 project launches --- .../PageButtonControl/PageButtonControl.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx b/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx index bc568c0773..9668a3c2d6 100644 --- a/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx +++ b/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx @@ -1,7 +1,7 @@ +import { Tooltip } from 'antd' 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 = ({ @@ -27,12 +27,15 @@ export const PageButtonControl = ({ onClick={onPageDone} /> ) : ( - + +
    Launch project
    + {/* */} +
    )}
    From 7faeb22267da761f0f582f8dd32681eb2ba937e7 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Sat, 28 Sep 2024 13:14:08 +1000 Subject: [PATCH 44/44] fix: v2v3 create (wrong terminal) (#4471) --- .../PageButtonControl/PageButtonControl.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx b/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx index 9668a3c2d6..bc568c0773 100644 --- a/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx +++ b/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx @@ -1,7 +1,7 @@ -import { Tooltip } from 'antd' 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 = ({ @@ -27,15 +27,12 @@ export const PageButtonControl = ({ onClick={onPageDone} /> ) : ( - -
    Launch project
    - {/* */} -
    + )}