From e600689e8864d3bf8376bf62c4877c2358d80d12 Mon Sep 17 00:00:00 2001 From: zho Date: Thu, 15 Aug 2024 15:21:43 +0200 Subject: [PATCH] feat: edit earnings (#2101) * feat: add edit earnings option and track rewards for submit and vote * feat: add small changes for rewards address * feat: add mobile optimizations * feat: add correct handleError call from hook * feat: prevent edit of the earnings if contest is completed * feat: add canceled state for edit earnings --- .../components/UI/DialogModalV4/index.tsx | 32 ++++ .../Earnings/components/Modal/index.tsx | 127 +++++++++++++ .../Parameters/components/Earnings/index.tsx | 63 +++++-- .../components/Submissions/index.tsx | 4 +- .../Parameters/components/Timeline/index.tsx | 4 +- .../Parameters/components/Voting/index.tsx | 4 +- .../CancelContest/components/Modal/index.tsx | 72 +++---- .../components/SplitFeeDestination/index.tsx | 175 ++++++++++++------ .../Charge/components/Vote/index.tsx | 4 +- .../components/Charge/index.tsx | 1 + .../hooks/useCastVotes/index.ts | 106 +++++++++-- .../hooks/useContest/index.ts | 115 +++++++----- .../hooks/useContestState/index.ts | 10 +- .../hooks/useCreatorSplitDestination/index.ts | 64 +++++++ .../hooks/useDeployContest/index.ts | 3 +- .../hooks/useDeployContest/types/index.ts | 4 +- .../hooks/useSubmitProposal/index.ts | 101 ++++++++-- packages/react-app-revamp/styles/globals.css | 2 +- 18 files changed, 679 insertions(+), 212 deletions(-) create mode 100644 packages/react-app-revamp/components/UI/DialogModalV4/index.tsx create mode 100644 packages/react-app-revamp/components/_pages/Contest/Parameters/components/Earnings/components/Modal/index.tsx create mode 100644 packages/react-app-revamp/hooks/useCreatorSplitDestination/index.ts diff --git a/packages/react-app-revamp/components/UI/DialogModalV4/index.tsx b/packages/react-app-revamp/components/UI/DialogModalV4/index.tsx new file mode 100644 index 000000000..e7e58ec56 --- /dev/null +++ b/packages/react-app-revamp/components/UI/DialogModalV4/index.tsx @@ -0,0 +1,32 @@ +import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"; +import { FC } from "react"; + +interface DialogModalV4Props { + isOpen: boolean; + children: React.ReactNode; + onClose: (value: boolean) => void; +} + +const DialogModalV4: FC = ({ isOpen, onClose, children }) => { + return ( + + + +
+
+ +
{children}
+
+
+
+
+ ); +}; + +export default DialogModalV4; diff --git a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Earnings/components/Modal/index.tsx b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Earnings/components/Modal/index.tsx new file mode 100644 index 000000000..df517fc2f --- /dev/null +++ b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Earnings/components/Modal/index.tsx @@ -0,0 +1,127 @@ +import ContestParamsSplitFeeDestination from "@components/_pages/Create/pages/ContestMonetization/components/Charge/components/SplitFeeDestination"; +import DialogModalV4 from "@components/UI/DialogModalV4"; +import { chains, config } from "@config/wagmi"; +import { extractPathSegments } from "@helpers/extractPath"; +import { addressRegex } from "@helpers/regex"; +import { useContestStore } from "@hooks/useContest/store"; +import { useCreatorSplitDestination } from "@hooks/useCreatorSplitDestination"; +import { JK_LABS_SPLIT_DESTINATION_DEFAULT } from "@hooks/useDeployContest"; +import { Charge, SplitFeeDestinationType } from "@hooks/useDeployContest/types"; +import { switchChain } from "@wagmi/core"; +import Image from "next/image"; +import { usePathname } from "next/navigation"; +import { FC, useState } from "react"; +import { useAccount } from "wagmi"; + +interface ContestParamsEarningsModalProps { + charge: Charge; + isOpen: boolean; + onClose: (value: boolean) => void; +} + +const ContestParamsEarningsModal: FC = ({ charge, isOpen, onClose }) => { + const pathname = usePathname(); + const { chainName: contestChainName } = extractPathSegments(pathname); + const contestChainId = chains.find(chain => chain.name.toLowerCase() === contestChainName.toLowerCase())?.id; + const { address: userAddress, chainId } = useAccount(); + const isUserOnCorrectChain = contestChainId === chainId; + const { rewardsModuleAddress } = useContestStore(state => state); + const [splitFeeDestinationError, setSplitFeeDestinationError] = useState(""); + const { setCreatorSplitDestination, isLoading, isConfirmed } = useCreatorSplitDestination(); + const [localCharge, setLocalCharge] = useState(charge); + + const handleSplitFeeDestinationTypeChange = (type: SplitFeeDestinationType) => { + let newAddress = charge.splitFeeDestination.address; + + switch (type) { + case SplitFeeDestinationType.RewardsPool: + newAddress = rewardsModuleAddress; + break; + case SplitFeeDestinationType.AnotherWallet: + newAddress = ""; + break; + case SplitFeeDestinationType.NoSplit: + newAddress = JK_LABS_SPLIT_DESTINATION_DEFAULT; + break; + case SplitFeeDestinationType.CreatorWallet: + newAddress = userAddress; + } + + const newSplitFeeDestination = { + ...charge.splitFeeDestination, + type, + address: newAddress, + }; + + const isValidAddress = addressRegex.test(newAddress ?? ""); + const error = type === SplitFeeDestinationType.AnotherWallet && !isValidAddress; + + setLocalCharge?.({ + ...charge, + splitFeeDestination: newSplitFeeDestination, + error, + }); + }; + + const handleSplitFeeDestinationAddressChange = (address: string) => { + const isValidAddress = addressRegex.test(address); + const newSplitFeeDestination = { + ...charge.splitFeeDestination, + type: SplitFeeDestinationType.AnotherWallet, + address, + }; + + setSplitFeeDestinationError(isValidAddress ? "" : "invalid address"); + + setLocalCharge?.({ + ...charge, + splitFeeDestination: newSplitFeeDestination, + error: !isValidAddress, + }); + }; + + const onSaveHandler = async () => { + if (!contestChainId) return; + + if (!isUserOnCorrectChain) { + await switchChain(config, { chainId: contestChainId }); + } + + setCreatorSplitDestination(localCharge.splitFeeDestination); + }; + + return ( + +
+
+

edit earnings

+ close onClose(true)} + /> +
+ + +
+
+ ); +}; + +export default ContestParamsEarningsModal; diff --git a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Earnings/index.tsx b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Earnings/index.tsx index 7ebae41ab..5ba4a8909 100644 --- a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Earnings/index.tsx +++ b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Earnings/index.tsx @@ -1,6 +1,10 @@ import shortenEthereumAddress from "@helpers/shortenEthereumAddress"; -import { Charge } from "@hooks/useDeployContest/types"; -import { FC } from "react"; +import { PencilSquareIcon } from "@heroicons/react/24/outline"; +import { ContestStateEnum, useContestStateStore } from "@hooks/useContestState/store"; +import { Charge, SplitFeeDestinationType } from "@hooks/useDeployContest/types"; +import { FC, useState } from "react"; +import { useAccount } from "wagmi"; +import ContestParamsEarningsModal from "./components/Modal"; interface ContestParametersEarningsProps { charge: Charge; @@ -9,11 +13,17 @@ interface ContestParametersEarningsProps { } const ContestParametersEarnings: FC = ({ charge, blockExplorerUrl, contestAuthor }) => { + const { address } = useAccount(); + const isConnectedWalletAuthor = address === contestAuthor; const isCreatorSplitEnabled = charge.percentageToCreator > 0; const creatorSplitDestination = charge.splitFeeDestination.address ? charge.splitFeeDestination.address : contestAuthor; const blockExplorerAddressUrl = blockExplorerUrl ? `${blockExplorerUrl}/address/${creatorSplitDestination}` : ""; + const [isEditEarningsModalOpen, setIsEditEarningsModalOpen] = useState(false); + const { contestState } = useContestStateStore(state => state); + const isContestFinishedOrCanceled = + contestState === ContestStateEnum.Completed || contestState === ContestStateEnum.Canceled; const percentageToCreatorMessage = () => { if (charge.percentageToCreator === 50) { @@ -22,24 +32,45 @@ const ContestParametersEarnings: FC = ({ charge, return `all earnings go to JokeRace`; } }; + + const creatorEarningsDestinationMessage = () => { + if (charge.splitFeeDestination.type === SplitFeeDestinationType.RewardsPool) { + return "creator earnings go to rewards pool"; + } else { + return ( + <> + creator earnings go to{" "} + + {shortenEthereumAddress(creatorSplitDestination)} + + + ); + } + }; + return ( -
-

earnings

+
+
+

earnings

+ {isConnectedWalletAuthor && !isContestFinishedOrCanceled && ( + + )} +
+ setIsEditEarningsModalOpen(false)} + />
); }; diff --git a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Submissions/index.tsx b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Submissions/index.tsx index 17fe30623..a9babfcf6 100644 --- a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Submissions/index.tsx +++ b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Submissions/index.tsx @@ -48,8 +48,8 @@ const ContestParametersSubmissions: FC = ({ ); return ( -
-

submissions

+
+

submissions

  • qualified wallets can enter{" "} diff --git a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Timeline/index.tsx b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Timeline/index.tsx index 8c11f349a..62aeb7956 100644 --- a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Timeline/index.tsx +++ b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Timeline/index.tsx @@ -9,8 +9,8 @@ interface ContestParametersTimelineProps { const ContestParametersTimeline: FC = ({ submissionsOpen, votesOpen, votesClose }) => { return ( -
    -

    timeline

    +
    +

    timeline

    diff --git a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Voting/index.tsx b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Voting/index.tsx index c9710a137..d770b4211 100644 --- a/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Voting/index.tsx +++ b/packages/react-app-revamp/components/_pages/Contest/Parameters/components/Voting/index.tsx @@ -79,8 +79,8 @@ const ContestParametersVoting: FC = ({ ); return ( -
    -

    voting

    +
    +

    voting

    • {address ? qualifyToVoteMessage : walletNotConnected}
    • {anyoneCanVote ? ( diff --git a/packages/react-app-revamp/components/_pages/Contest/components/CancelContest/components/Modal/index.tsx b/packages/react-app-revamp/components/_pages/Contest/components/CancelContest/components/Modal/index.tsx index 010d8147d..9ef8804f0 100644 --- a/packages/react-app-revamp/components/_pages/Contest/components/CancelContest/components/Modal/index.tsx +++ b/packages/react-app-revamp/components/_pages/Contest/components/CancelContest/components/Modal/index.tsx @@ -1,4 +1,4 @@ -import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"; +import DialogModalV4 from "@components/UI/DialogModalV4"; import Image from "next/image"; import { FC } from "react"; @@ -14,50 +14,36 @@ const CancelContestModal: FC = ({ cancelContestHandler, }) => { return ( - - - -
      -
      - -
      -
      -

      cancel contest?? 😬

      - close setIsCloseContestModalOpen(false)} - /> -
      -
      -

      if you proceed, you will cancel this contest.

      -

      🚨 players will not be able to keep playing.

      -

      - 🚨 the contest and entries will remain visible on our site. -

      -

      🚨 you can withdraw any funds on the rewards page.

      -

      are you really, really, really sure you want to proceed?

      -
      - -
      -
      + +
      +
      +

      cancel contest?? 😬

      + close setIsCloseContestModalOpen(false)} + /> +
      +
      +

      if you proceed, you will cancel this contest.

      +

      🚨 players will not be able to keep playing.

      +

      + 🚨 the contest and entries will remain visible on our site. +

      +

      🚨 you can withdraw any funds on the rewards page.

      +

      are you really, really, really sure you want to proceed?

      +
      -
      + ); }; diff --git a/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/components/SplitFeeDestination/index.tsx b/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/components/SplitFeeDestination/index.tsx index 9a09dcfd9..aa6d27948 100644 --- a/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/components/SplitFeeDestination/index.tsx +++ b/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/components/SplitFeeDestination/index.tsx @@ -1,7 +1,7 @@ import { Radio, RadioGroup } from "@headlessui/react"; import shortenEthereumAddress from "@helpers/shortenEthereumAddress"; import { SplitFeeDestination, SplitFeeDestinationType } from "@hooks/useDeployContest/types"; -import { FC, useState } from "react"; +import { FC, useEffect, useState } from "react"; import { useMediaQuery } from "react-responsive"; interface ContestParamsSplitFeeDestinationProps { @@ -9,6 +9,9 @@ interface ContestParamsSplitFeeDestinationProps { splitFeeDestinationError: string; onSplitFeeDestinationTypeChange?: (value: SplitFeeDestinationType) => void; onSplitFeeDestinationAddressChange?: (value: string) => void; + includeRewardsInfo?: boolean; + includeRewardsPool?: boolean; + rewardsModuleAddress?: string; } const PLACEHOLDER_ADDRESS = "0x7B15427393A98A041D00b50254A0C7a6fDC79F4E"; @@ -18,82 +21,144 @@ const ContestParamsSplitFeeDestination: FC { const [selected, setSelected] = useState(splitFeeDestination.type); const [address, setAddress] = useState(splitFeeDestination.address); + const [isRewardsModuleAddress, setIsRewardsModuleAddress] = useState(false); const isMobile = useMediaQuery({ query: "(max-width: 768px)" }); const percentageTitle = isMobile ? "who should we split earnings with?" : "we split all earnings 50/50. who should receive these?"; + useEffect(() => { + setSelected(splitFeeDestination.type); + setAddress(splitFeeDestination.address); + + if (splitFeeDestination.address === rewardsModuleAddress) { + setIsRewardsModuleAddress(true); + } else { + setIsRewardsModuleAddress(false); + } + }, [splitFeeDestination, rewardsModuleAddress]); + const handleSplitFeeDestinationTypeChange = (value: SplitFeeDestinationType) => { setSelected(value); onSplitFeeDestinationTypeChange?.(value); }; const handleSplitFeeDestinationAddressChange = (value: string) => { + if (value === rewardsModuleAddress) { + setIsRewardsModuleAddress(true); + } else { + setIsRewardsModuleAddress(false); + } setAddress(value); onSplitFeeDestinationAddressChange?.(value); }; return ( -
      -

      {percentageTitle}

      - -
      - - {({ checked }) => ( -
      -
      -
      -

      my wallet (recommended)

      +
      +
      +

      {percentageTitle}

      + +
      + + {({ checked }) => ( +
      +
      +
      +

      my wallet

      +
      -
      - )} - - - {({ checked }) => ( -
      -
      -
      -

      another wallet

      - {checked ? ( - handleSplitFeeDestinationAddressChange(e.target.value)} - placeholder={isMobile ? shortenEthereumAddress(PLACEHOLDER_ADDRESS, "long") : PLACEHOLDER_ADDRESS} - className="w-full md:w-[536px] h-10 bg-neutral-14 rounded-[10px] text-[20px] text-true-black placeholder-neutral-10 placeholder:font-bold p-4 focus:outline-none" - /> - ) : null} + )} + + + {({ checked }) => ( +
      +
      +
      +

      another wallet

      + {checked ? ( + <> + handleSplitFeeDestinationAddressChange(e.target.value)} + placeholder={ + isMobile ? shortenEthereumAddress(PLACEHOLDER_ADDRESS, "long") : PLACEHOLDER_ADDRESS + } + className="w-full md:w-[536px] h-10 bg-true-black border border-secondary-11 rounded-[10px] text-[20px] text-neutral-11 placeholder-neutral-10 placeholder:font-bold p-4 focus:outline-none" + /> + {isRewardsModuleAddress && ( +

      + looks like this is the rewards pool! we’ll send all your earnings to the rewards. +

      + )} + + ) : null} +
      -
      - )} - - - {({ checked }) => ( -
      -
      -
      -

      i prefer to take 0% of earnings

      + )} + + + {({ checked }) => ( +
      +
      +
      +

      i prefer to take 0% of earnings

      +
      -
      - )} - -
      - + )} +
      + {includeRewardsPool ? ( + + {({ checked }) => ( +
      +
      +
      +

      + the rewards pool (new) +

      +
      +
      + )} +
      + ) : null} +
      +
      +
      + {includeRewardsInfo ? ( +

      + {isMobile ? ( + "create a rewards pool after creating this contest to set your earnings to go to rewards." + ) : ( + <> + want your earnings to go towards rewards? finish creating this contest, then create a
      + rewards pool, and then you’ll have the option. + + )} +

      + ) : null}
      ); }; diff --git a/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/components/Vote/index.tsx b/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/components/Vote/index.tsx index 1a4430277..6687839d0 100644 --- a/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/components/Vote/index.tsx +++ b/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/components/Vote/index.tsx @@ -51,7 +51,7 @@ const ContestParamsChargeVote: FC = ({
      @@ -82,7 +82,7 @@ const ContestParamsChargeVote: FC = ({
      diff --git a/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/index.tsx b/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/index.tsx index e43ce7203..33e33eb6d 100644 --- a/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/index.tsx +++ b/packages/react-app-revamp/components/_pages/Create/pages/ContestMonetization/components/Charge/index.tsx @@ -144,6 +144,7 @@ const CreateContestCharge: FC = ({ isConnected, chain, splitFeeDestinationError={splitFeeDestinationError} onSplitFeeDestinationTypeChange={handleSplitFeeDestinationTypeChange} onSplitFeeDestinationAddressChange={handleSplitFeeDestinationAddressChange} + includeRewardsInfo />
      state); + const { + canUpdateVotesInRealTime, + charge, + contestAbi: abi, + anyoneCanVote, + rewardsModuleAddress, + } = useContestStore(state => state); const { updateProposal } = useProposal(); const { listProposalsData } = useProposalStore(state => state); const { @@ -50,6 +81,8 @@ export function useCastVotes() { contestAddress, pickedProposal ?? "", ); + const isEarningsTowardsRewards = rewardsModuleAddress === charge?.splitFeeDestination.address; + const { handleRefetchBalanceRewardsModule } = useRewardsModule(); const calculateChargeAmount = (amountOfVotes: number) => { if (!charge) return undefined; @@ -106,20 +139,20 @@ export function useCastVotes() { hash: hash, }); - try { - await addUserActionForAnalytics({ - contest_address: contestAddress, - user_address: userAddress, - network_name: chainName, - proposal_id: pickedProposal !== null ? pickedProposal : undefined, - vote_amount: amountOfVotes, - created_at: Math.floor(Date.now() / 1000), - amount_sent: costToVote ? formatChargeAmount(parseFloat(costToVote.toString())) : null, - percentage_to_creator: charge ? charge.percentageToCreator : null, - }); - } catch (error) { - console.error("Error in addUserActionForAnalytics:", error); - } + await performAnalytics({ + contestAddress, + userAddress, + chainName, + pickedProposal, + amountOfVotes, + costToVote, + charge, + isEarningsTowardsRewards, + address: contestAddress, + rewardsModuleAddress, + operation: "deposit", + token_address: null, + }); setTransactionData({ hash: receipt.transactionHash, @@ -165,6 +198,47 @@ export function useCastVotes() { } } + async function addUserActionAnalytics(params: UserAnalyticsParams) { + try { + await addUserActionForAnalytics({ + contest_address: params.contestAddress, + user_address: params.userAddress, + network_name: params.chainName, + proposal_id: params.pickedProposal !== null ? params.pickedProposal : undefined, + vote_amount: params.amountOfVotes, + created_at: Math.floor(Date.now() / 1000), + amount_sent: params.costToVote ? formatChargeAmount(parseFloat(params.costToVote.toString())) : null, + percentage_to_creator: params.charge ? params.charge.percentageToCreator : null, + }); + } catch (error) { + console.error("Error in addUserActionForAnalytics:", error); + } + } + + async function updateRewardAnalyticsIfNeeded(params: RewardsAnalyticsParams) { + if (params.isEarningsTowardsRewards && params.costToVote) { + try { + await updateRewardAnalytics({ + contest_address: params.address, + rewards_module_address: params.rewardsModuleAddress, + network_name: params.chainName, + amount: formatChargeAmount(parseFloat(params.costToVote.toString())) / 2, + operation: "deposit", + token_address: null, + created_at: Math.floor(Date.now() / 1000), + }); + + handleRefetchBalanceRewardsModule(); + } catch (error) { + console.error("Error while updating reward analytics", error); + } + } + } + + async function performAnalytics(params: CombinedAnalyticsParams) { + await Promise.all([addUserActionAnalytics(params), updateRewardAnalyticsIfNeeded(params)]); + } + return { setTransactionData, castVotes, diff --git a/packages/react-app-revamp/hooks/useContest/index.ts b/packages/react-app-revamp/hooks/useContest/index.ts index 1ae659ad8..ab1c14ea6 100644 --- a/packages/react-app-revamp/hooks/useContest/index.ts +++ b/packages/react-app-revamp/hooks/useContest/index.ts @@ -72,6 +72,7 @@ export function useContest() { setSortingEnabled, setVersion, setRewardsModuleAddress, + rewardsModuleAddress, setRewardsAbi, } = useContestStore(state => state); const { setIsListProposalsSuccess, setIsListProposalsLoading, setListProposalsIds } = useProposalStore( @@ -121,23 +122,11 @@ export function useContest() { } } - function determineSplitFeeDestination( - splitFeeDestination: string, - creatorWalletAddress: string, - percentageToCreator: number, - ): SplitFeeDestinationType { - if (percentageToCreator === 0) { - return SplitFeeDestinationType.NoSplit; - } - - if (!splitFeeDestination || splitFeeDestination === creatorWalletAddress) { - return SplitFeeDestinationType.CreatorWallet; - } - - return SplitFeeDestinationType.AnotherWallet; - } - - async function fetchContestContractData(contractConfig: ContractConfig, version: string) { + async function fetchContestContractData( + contractConfig: ContractConfig, + version: string, + rewardsModuleAddress?: string, + ) { const contracts = getContracts(contractConfig, version); const results = await readContracts(config, { contracts }); @@ -182,7 +171,12 @@ export function useContest() { percentageToCreator, voteType: payPerVote > 0 ? VoteType.PerVote : VoteType.PerTransaction, splitFeeDestination: { - type: determineSplitFeeDestination(creatorSplitDestination, contestAuthor, percentageToCreator), + type: determineSplitFeeDestination( + creatorSplitDestination, + contestAuthor, + percentageToCreator, + rewardsModuleAddress, + ), address: creatorSplitDestination, }, type: { @@ -236,12 +230,12 @@ export function useContest() { }); } - async function fetchV3ContestInfo(contractConfig: ContractConfig, version: string) { + async function fetchV3ContestInfo(contractConfig: ContractConfig, version: string, rewardsModuleAddress?: string) { try { setIsListProposalsLoading(false); await Promise.all([ - fetchContestContractData(contractConfig, version), + fetchContestContractData(contractConfig, version, rewardsModuleAddress), processUserQualifications(), processRequirementsData(), ]); @@ -330,43 +324,49 @@ export function useContest() { const { contractConfig, version } = abiResult; - let contestRewardModuleAddress = ""; - - if (contractConfig.abi?.filter((el: { name: string }) => el.name === "officialRewardsModule").length > 0) { - contestRewardModuleAddress = (await readContract(config, { - ...contractConfig, - functionName: "officialRewardsModule", - args: [], - })) as string; - if (contestRewardModuleAddress === "0x0000000000000000000000000000000000000000") { - setSupportsRewardsModule(false); - contestRewardModuleAddress = ""; - } else { - setSupportsRewardsModule(true); - } - } else { - setSupportsRewardsModule(false); - contestRewardModuleAddress = ""; - } - - setRewardsModuleAddress(contestRewardModuleAddress); - - if (contestRewardModuleAddress) { - const abiRewardsModule = await getRewardsModuleContractVersion(contestRewardModuleAddress, chainId); - //@ts-ignore - setRewardsAbi(abiRewardsModule); - } + const rewardsModuleAddress = await fetchRewardsModuleData(contractConfig); if (compareVersions(version, "3.0") == -1) { await fetchV1ContestInfo(contractConfig, version); } else { - await fetchV3ContestInfo(contractConfig, version); + await fetchV3ContestInfo(contractConfig, version, rewardsModuleAddress); } } catch (error) { console.error("An error occurred while fetching data:", error); } } + async function fetchRewardsModuleData(contractConfig: ContractConfig) { + let contestRewardModuleAddress = ""; + + if (contractConfig.abi?.filter((el: { name: string }) => el.name === "officialRewardsModule").length > 0) { + contestRewardModuleAddress = (await readContract(config, { + ...contractConfig, + functionName: "officialRewardsModule", + args: [], + })) as string; + if (contestRewardModuleAddress === "0x0000000000000000000000000000000000000000") { + setSupportsRewardsModule(false); + contestRewardModuleAddress = ""; + } else { + setSupportsRewardsModule(true); + } + } else { + setSupportsRewardsModule(false); + contestRewardModuleAddress = ""; + } + + setRewardsModuleAddress(contestRewardModuleAddress); + + if (contestRewardModuleAddress) { + const abiRewardsModule = await getRewardsModuleContractVersion(contestRewardModuleAddress, chainId); + //@ts-ignore + setRewardsAbi(abiRewardsModule); + } + + return contestRewardModuleAddress; + } + /** * Fetch merkle tree data from DB and re-create the tree */ @@ -416,6 +416,27 @@ export function useContest() { } } + function determineSplitFeeDestination( + splitFeeDestination: string, + creatorWalletAddress: string, + percentageToCreator: number, + rewardsModuleAddress?: string, + ): SplitFeeDestinationType { + if (percentageToCreator === 0) { + return SplitFeeDestinationType.NoSplit; + } + + if (!splitFeeDestination || splitFeeDestination === creatorWalletAddress) { + return SplitFeeDestinationType.CreatorWallet; + } + + if (rewardsModuleAddress && splitFeeDestination === rewardsModuleAddress) { + return SplitFeeDestinationType.RewardsPool; + } + + return SplitFeeDestinationType.AnotherWallet; + } + return { getContractConfig, address, diff --git a/packages/react-app-revamp/hooks/useContestState/index.ts b/packages/react-app-revamp/hooks/useContestState/index.ts index a0de4d337..77ed2445e 100644 --- a/packages/react-app-revamp/hooks/useContestState/index.ts +++ b/packages/react-app-revamp/hooks/useContestState/index.ts @@ -1,11 +1,11 @@ -import { toastDismiss, toastLoading, toastSuccess } from "@components/UI/Toast"; +import { toastLoading, toastSuccess } from "@components/UI/Toast"; import { chains, config } from "@config/wagmi"; import { extractPathSegments } from "@helpers/extractPath"; import { useContestStore } from "@hooks/useContest/store"; +import { useError } from "@hooks/useError"; import { simulateContract, waitForTransactionReceipt, writeContract } from "@wagmi/core"; import { usePathname } from "next/navigation"; import { useState } from "react"; -import { handleError } from "utils/error"; import { ContestStateEnum, useContestStateStore } from "./store"; interface CancelContestResult { @@ -20,9 +20,9 @@ export function useContestState(): CancelContestResult { const chainId = chains.find(chain => chain.name === chainName)?.id; const { contestAbi: abi } = useContestStore(state => state); const { setContestState } = useContestStateStore(state => state); - const [isLoading, setIsLoading] = useState(false); const [isConfirmed, setIsConfirmed] = useState(false); + const { handleError } = useError(); const cancelContest = async (): Promise => { setIsLoading(true); @@ -45,11 +45,9 @@ export function useContestState(): CancelContestResult { setIsConfirmed(true); toastSuccess("Contest cancelled successfully"); setContestState(ContestStateEnum.Canceled); - } else { - handleError("An error occurred while cancelling the contest"); } } catch (err: any) { - handleError(err); + handleError(err, "An error occurred while cancelling contest"); } finally { setIsLoading(false); } diff --git a/packages/react-app-revamp/hooks/useCreatorSplitDestination/index.ts b/packages/react-app-revamp/hooks/useCreatorSplitDestination/index.ts new file mode 100644 index 000000000..531f9913f --- /dev/null +++ b/packages/react-app-revamp/hooks/useCreatorSplitDestination/index.ts @@ -0,0 +1,64 @@ +import { toastLoading, toastSuccess } from "@components/UI/Toast"; +import { chains, config } from "@config/wagmi"; +import { extractPathSegments } from "@helpers/extractPath"; +import { useContestStore } from "@hooks/useContest/store"; +import { SplitFeeDestination } from "@hooks/useDeployContest/types"; +import { useError } from "@hooks/useError"; +import { simulateContract, waitForTransactionReceipt, writeContract } from "@wagmi/core"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; + +interface SetCreatorSplitDestinationResult { + setCreatorSplitDestination: (splitFeeDestination: SplitFeeDestination) => Promise; + isLoading: boolean; + isConfirmed: boolean; +} + +export function useCreatorSplitDestination(): SetCreatorSplitDestinationResult { + const asPath = usePathname(); + const { chainName, address } = extractPathSegments(asPath ?? ""); + const chainId = chains.find(chain => chain.name === chainName)?.id; + const { contestAbi: abi, setCharge, charge } = useContestStore(state => state); + const [isLoading, setIsLoading] = useState(false); + const [isConfirmed, setIsConfirmed] = useState(false); + const { handleError } = useError(); + + const setCreatorSplitDestination = async (splitFeeDestination: SplitFeeDestination): Promise => { + if (!splitFeeDestination.address || !charge) return; + setIsLoading(true); + setIsConfirmed(false); + toastLoading("Setting creator split destination..."); + + try { + const { request } = await simulateContract(config, { + chainId, + abi, + address: address as `0x${string}`, + functionName: "setCreatorSplitDestination", + args: [splitFeeDestination.address], + }); + + const txHash = await writeContract(config, request); + const receipt = await waitForTransactionReceipt(config, { hash: txHash }); + + if (receipt.status === "success") { + setIsConfirmed(true); + toastSuccess("Creator split destination set successfully"); + setCharge({ + ...charge, + splitFeeDestination, + }); + } + } catch (err: any) { + handleError(err, "An error occurred while setting creator split destination"); + } finally { + setIsLoading(false); + } + }; + + return { + setCreatorSplitDestination, + isLoading, + isConfirmed, + }; +} diff --git a/packages/react-app-revamp/hooks/useDeployContest/index.ts b/packages/react-app-revamp/hooks/useDeployContest/index.ts index 4400b65a6..4ac6a1caf 100644 --- a/packages/react-app-revamp/hooks/useDeployContest/index.ts +++ b/packages/react-app-revamp/hooks/useDeployContest/index.ts @@ -23,8 +23,9 @@ import { SplitFeeDestinationType, SubmissionMerkle, VoteType, VotingMerkle } fro export const MAX_SUBMISSIONS_LIMIT = 1000000; export const DEFAULT_SUBMISSIONS = 1000000; +export const JK_LABS_SPLIT_DESTINATION_DEFAULT = "0xDc652C746A8F85e18Ce632d97c6118e8a52fa738"; + const EMPTY_ROOT = "0x0000000000000000000000000000000000000000000000000000000000000000"; -const JK_LABS_SPLIT_DESTINATION_DEFAULT = "0xDc652C746A8F85e18Ce632d97c6118e8a52fa738"; export function useDeployContest() { const { indexContestV3 } = useV3ContestsIndex(); diff --git a/packages/react-app-revamp/hooks/useDeployContest/types/index.ts b/packages/react-app-revamp/hooks/useDeployContest/types/index.ts index 2f5c03c12..52f8606b0 100644 --- a/packages/react-app-revamp/hooks/useDeployContest/types/index.ts +++ b/packages/react-app-revamp/hooks/useDeployContest/types/index.ts @@ -47,12 +47,14 @@ export enum SplitFeeDestinationType { CreatorWallet = "CreatorWallet", AnotherWallet = "AnotherWallet", NoSplit = "NoSplit", + RewardsPool = "RewardsPool", } export type SplitFeeDestination = | { type: SplitFeeDestinationType.CreatorWallet; address?: string } | { type: SplitFeeDestinationType.AnotherWallet; address?: string } - | { type: SplitFeeDestinationType.NoSplit; address?: string }; + | { type: SplitFeeDestinationType.NoSplit; address?: string } + | { type: SplitFeeDestinationType.RewardsPool; address?: string }; export type Charge = { percentageToCreator: number; diff --git a/packages/react-app-revamp/hooks/useSubmitProposal/index.ts b/packages/react-app-revamp/hooks/useSubmitProposal/index.ts index 0c3d41e9b..dc85822b2 100644 --- a/packages/react-app-revamp/hooks/useSubmitProposal/index.ts +++ b/packages/react-app-revamp/hooks/useSubmitProposal/index.ts @@ -3,21 +3,24 @@ import { config } from "@config/wagmi"; import { TransactionResponse } from "@ethersproject/abstract-provider"; import { extractPathSegments } from "@helpers/extractPath"; import { getProposalId } from "@helpers/getProposalId"; +import { generateFieldInputsHTML, processFieldInputs } from "@helpers/metadata"; import { useContestStore } from "@hooks/useContest/store"; import { useError } from "@hooks/useError"; import { useGenerateProof } from "@hooks/useGenerateProof"; -import { MetadataFieldWithInput, useMetadataStore } from "@hooks/useMetadataFields/store"; +import { useMetadataStore } from "@hooks/useMetadataFields/store"; import useProposal from "@hooks/useProposal"; import { useProposalStore } from "@hooks/useProposal/store"; +import useRewardsModule from "@hooks/useRewards"; import { useUserStore } from "@hooks/useUser/store"; import { waitForTransactionReceipt, writeContract } from "@wagmi/core"; import { addUserActionForAnalytics } from "lib/analytics/participants"; +import { updateRewardAnalytics } from "lib/analytics/rewards"; import { usePathname } from "next/navigation"; import { useMediaQuery } from "react-responsive"; -import { formatEther, parseEther } from "viem"; +import { formatEther } from "viem"; import { useAccount } from "wagmi"; import { useSubmitProposalStore } from "./store"; -import { generateFieldInputsHTML, processFieldInputs } from "@helpers/metadata"; +import { Charge } from "@hooks/useDeployContest/types"; const targetMetadata = { targetAddress: "0x0000000000000000000000000000000000000000", @@ -28,13 +31,34 @@ const safeMetadata = { threshold: 1, }; +interface UserAnalyticsParams { + address: string; + userAddress: `0x${string}` | undefined; + chainName: string; + proposalId: string; + charge: Charge | null; +} + +interface RewardsAnalyticsParams { + isEarningsTowardsRewards: boolean; + address: string; + rewardsModuleAddress: string; + charge: Charge | null; + chainName: string; + amount: number; + operation: "deposit" | "withdraw"; + token_address: string | null; +} + +interface CombinedAnalyticsParams extends UserAnalyticsParams, RewardsAnalyticsParams {} + export function useSubmitProposal() { const { address: userAddress, chain } = useAccount(); const asPath = usePathname(); const { chainName, address } = extractPathSegments(asPath ?? ""); const isMobile = useMediaQuery({ maxWidth: "768px" }); const showToast = !isMobile; - const { charge, contestAbi: abi } = useContestStore(state => state); + const { charge, contestAbi: abi, rewardsModuleAddress } = useContestStore(state => state); const { error: errorMessage, handleError } = useError(); const { fetchSingleProposal } = useProposal(); const { setSubmissionsCount, submissionsCount } = useProposalStore(state => state); @@ -43,6 +67,8 @@ export function useSubmitProposal() { const { isLoading, isSuccess, error, setIsLoading, setIsSuccess, setError, setTransactionData } = useSubmitProposalStore(state => state); const { fields: metadataFields, setFields: setMetadataFields } = useMetadataStore(state => state); + const isEarningsTowardsRewards = rewardsModuleAddress === charge?.splitFeeDestination.address; + const { handleRefetchBalanceRewardsModule } = useRewardsModule(); const calculateChargeAmount = () => { if (!charge) return undefined; @@ -112,26 +138,25 @@ export function useSubmitProposal() { const proposalId = await getProposalId(proposalCore, contractConfig); - try { - await addUserActionForAnalytics({ - contest_address: address, - user_address: userAddress, - network_name: chainName, - proposal_id: proposalId, - created_at: Math.floor(Date.now() / 1000), - amount_sent: charge ? Number(formatEther(BigInt(charge.type.costToPropose))) : null, - percentage_to_creator: charge ? charge.percentageToCreator : null, - }); - } catch (error) { - console.error("Error in addUserActionForAnalytics:", error); - } - setTransactionData({ chainId: chain?.id, hash: receipt.transactionHash, transactionHref: `${chain?.blockExplorers?.default?.url}/tx/${txSendProposal?.hash}`, }); + await performAnalytics({ + address, + userAddress, + chainName, + proposalId, + charge, + isEarningsTowardsRewards, + rewardsModuleAddress, + amount: costToPropose ? Number(formatEther(costToPropose)) : 0, + operation: "deposit", + token_address: null, + }); + setIsLoading(false); setIsSuccess(true); if (showToast) toastSuccess("proposal submitted successfully!"); @@ -156,6 +181,46 @@ export function useSubmitProposal() { }); } + async function addUserActionAnalytics(params: UserAnalyticsParams) { + try { + await addUserActionForAnalytics({ + contest_address: params.address, + user_address: params.userAddress, + network_name: params.chainName, + proposal_id: params.proposalId, + created_at: Math.floor(Date.now() / 1000), + amount_sent: params.charge ? Number(formatEther(BigInt(params.charge.type.costToPropose))) : null, + percentage_to_creator: params.charge ? params.charge.percentageToCreator : null, + }); + } catch (error) { + console.error("Error in addUserActionForAnalytics:", error); + } + } + + async function updateRewardAnalyticsIfNeeded(params: RewardsAnalyticsParams) { + if (params.isEarningsTowardsRewards && params.charge) { + try { + await updateRewardAnalytics({ + contest_address: params.address, + rewards_module_address: params.rewardsModuleAddress, + network_name: params.chainName, + amount: Number(formatEther(BigInt(params.charge.type.costToPropose))) / 2, + operation: "deposit", + token_address: null, + created_at: Math.floor(Date.now() / 1000), + }); + + handleRefetchBalanceRewardsModule(); + } catch (error) { + console.error("Error while updating reward analytics", error); + } + } + } + + async function performAnalytics(params: CombinedAnalyticsParams) { + await Promise.all([addUserActionAnalytics(params), updateRewardAnalyticsIfNeeded(params)]); + } + return { sendProposal, isLoading, diff --git a/packages/react-app-revamp/styles/globals.css b/packages/react-app-revamp/styles/globals.css index cca37853b..2778c1748 100644 --- a/packages/react-app-revamp/styles/globals.css +++ b/packages/react-app-revamp/styles/globals.css @@ -303,7 +303,7 @@ li { } body { @apply text-base flex flex-col min-h-screen; - caret-color: #ffe25b !important; + caret-color: #bb65ff !important; } #__next,