diff --git a/src/components/Project/ProjectTabs/ProjectTab.tsx b/src/components/Project/ProjectTabs/ProjectTab.tsx index 25dbbbbbb2..1f4b84856e 100644 --- a/src/components/Project/ProjectTabs/ProjectTab.tsx +++ b/src/components/Project/ProjectTabs/ProjectTab.tsx @@ -1,5 +1,5 @@ import { Tab } from '@headlessui/react' -import { ReactNode, useRef } from 'react' +import React, { ReactNode, useRef } from 'react' import { twMerge } from 'tailwind-merge' interface ProjectTabProps { @@ -26,14 +26,6 @@ export const ProjectTab: React.FC = ({ onClick={onClick} > {({ selected }) => { - if (selected && tabRef.current) { - tabRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'start', - }) - } - return (
> +}) { + return ( + <> + + + + ) +} + +const MobileTimelineViewSelector = ({ timelineView, setTimelineView, }: { timelineView: ProjectTimelineView setTimelineView: React.Dispatch> -}) { +}) => { + const opts = (): { label: string; value: ProjectTimelineView }[] => [ + { + label: t`Volume`, + value: 'volume', + }, + { + label: t`In Juicebox`, + value: 'balance', + }, + { + label: t`Trending`, + value: 'trendingScore', + }, + ] + + const handleChange = (value: ProjectTimelineView) => { + setTimelineView(value) + } + + return ( + o.value === timelineView)} + onChange={v => handleChange(v.value)} + /> + ) +} + +const DesktopTimelineViewSelector = ({ + timelineView, + setTimelineView, +}: { + timelineView: ProjectTimelineView + setTimelineView: React.Dispatch> +}) => { const tab = (view: ProjectTimelineView) => { const selected = view === timelineView @@ -28,7 +78,7 @@ export default function TimelineViewSelector({ return (
) } - return ( -
+
{tab('volume')} {tab('balance')} {tab('trendingScore')} diff --git a/src/components/inputs/FormattedNumberInput.tsx b/src/components/inputs/FormattedNumberInput.tsx index a7f822ae45..944cea899e 100644 --- a/src/components/inputs/FormattedNumberInput.tsx +++ b/src/components/inputs/FormattedNumberInput.tsx @@ -110,5 +110,5 @@ export default function FormattedNumberInput({ {accessory &&
{accessory}
}
- ); + ) } diff --git a/src/components/strings.tsx b/src/components/strings.tsx index 2d80c61a48..300f160ff3 100644 --- a/src/components/strings.tsx +++ b/src/components/strings.tsx @@ -1,4 +1,8 @@ import { Trans } from '@lingui/macro' +import ExternalLink from 'components/ExternalLink' +import Link from 'next/link' +import { v2v3ProjectRoute } from 'packages/v2v3/utils/routes' +import { helpPagePath } from 'utils/helpPagePath' export const ISSUE_ERC20_EXPLANATION = ( @@ -29,15 +33,15 @@ export const CYCLE_EXPLANATION = ( supporters.

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

) export const RULESET_EXPLANATION = ( -

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

+

With unlocked ruleset cycles, 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 @@ -57,10 +61,6 @@ export const LOCKED_PAYOUT_EXPLANATION = ( ) -import ExternalLink from 'components/ExternalLink' -import Link from 'next/link' -import { v2v3ProjectRoute } from 'packages/v2v3/utils/routes' -import { helpPagePath } from 'utils/helpPagePath' export const DISTRIBUTION_LIMIT_EXPLANATION = ( @@ -216,6 +216,19 @@ export const RECONFIG_RULES_EXPLANATION = ( ) +export const DEADLINE_EXPLANATION = ( + +

+ Edits to this project must be made before this deadline. This gives token + holders time to verify the edits before they take effect. +

+

+ For example: with a 1-day edit deadline, edits must be made at least 1 day + before a ruleset cycle starts. +

+
+) + export const RECONFIG_RULES_WARN = ( Adding an edit deadline is recommended. Projects with no deadline will diff --git a/src/locales/messages.pot b/src/locales/messages.pot index 08f3ebe39c..59a1d53d17 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -92,6 +92,9 @@ msgstr "" msgid "Claim {tokensLabel} as ERC-20" msgstr "" +msgid "Save project" +msgstr "" + msgid "Total issuance" msgstr "" @@ -215,6 +218,9 @@ msgstr "" msgid "Staked balance" msgstr "" +msgid "<0>Edits to this project must be made before this deadline. This gives token holders time to verify the edits before they take effect.<1>For example: with a 1-day edit deadline, edits must be made at least 1 day before a ruleset cycle starts." +msgstr "" + msgid "While enabled, the project owner can mint any amount of project tokens." msgstr "" @@ -230,6 +236,9 @@ msgstr "" msgid "Set a future date & time to start your project's first cycle." msgstr "" +msgid "Ruleset #1 starts when you create your project. With locked ruleset cycles, if you edit your project's rules during Ruleset #1, those edits will be <0>queued for the next ruleset." +msgstr "" + msgid "No" msgstr "" @@ -242,6 +251,9 @@ msgstr "" msgid "Amount (USD)" msgstr "" +msgid "<0>With unlocked ruleset cycles, 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 "Payout recipients" msgstr "" @@ -293,6 +305,9 @@ msgstr "" msgid "Project tokens" msgstr "" +msgid "NFT Credits" +msgstr "" + msgid "You must grant permission to migrate your V1 tokens." msgstr "" @@ -1001,6 +1016,9 @@ msgstr "" msgid "Redeem {0} {tokensTextShort} for ETH" msgstr "" +msgid "<0>With Locked Ruleset Cycles, your project's rules are locked for a period of time.<1><2>This helps build trust with your contributors." +msgstr "" + msgid "Are you sure you want to remove {0}?" msgstr "" @@ -1295,6 +1313,9 @@ msgstr "" msgid "Use NFTs for redemptions" msgstr "" +msgid "Set a cycle duration for locked rulesets." +msgstr "" + msgid "Assets" msgstr "" @@ -1343,6 +1364,9 @@ msgstr "" msgid "Need help?" msgstr "" +msgid "{receivedTokenSymbolText}" +msgstr "" + msgid "Custom strategy" msgstr "" @@ -1451,9 +1475,6 @@ 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 "" @@ -1841,9 +1862,6 @@ msgstr "" msgid "Project rules" msgstr "" -msgid "Set a duration for locked rulesets." -msgstr "" - msgid "Upload" msgstr "" @@ -2465,9 +2483,6 @@ 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 "" @@ -2483,6 +2498,9 @@ msgstr "" msgid "Read case studies" msgstr "" +msgid "{days, plural, one {Edits to an upcoming ruleset cycle must be submitted at least # day before that ruleset cycle starts.} other {Edits to an upcoming ruleset cycle must be submitted at least # days before that ruleset cycle starts.}}" +msgstr "" + msgid "tokens" msgstr "" @@ -2807,6 +2825,9 @@ 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 "" @@ -3011,6 +3032,9 @@ msgstr "" msgid "Your legacy balance" msgstr "" +msgid "ERC-20" +msgstr "" + msgid "Error loading ETH transfers to project" msgstr "" @@ -3059,6 +3083,9 @@ msgstr "" msgid "Deploy NFT collection" msgstr "" +msgid "Edits to upcoming ruleset cycles will take effect when the current cycle ends. A project with no deadline is vulnerable to last-second edits by its owner." +msgstr "" + msgid "You must <0>Edit your Cycle to change your total payout amount." msgstr "" @@ -3200,13 +3227,13 @@ msgstr "" msgid "Payout total (max.)" msgstr "" -msgid "Prevent NFT overspending" +msgid "Locked Ruleset Cycles" msgstr "" -msgid "Yes, start over" +msgid "Prevent NFT overspending" 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." +msgid "Yes, start over" msgstr "" msgid "All {tokensText} will go to the project owner:" @@ -3392,6 +3419,9 @@ msgstr "" msgid "New" msgstr "" +msgid "<0>With unlocked cycles, you can edit your project's rules at any time.<1>With locked cycles, 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 "This project has no upcoming ruleset. Its rules can change at any time." msgstr "" @@ -3638,9 +3668,6 @@ msgstr "" msgid "Redeem {tokensLabel} for ETH" msgstr "" -msgid "Locked Rulesets" -msgstr "" - msgid "Made a mistake?" msgstr "" @@ -3680,9 +3707,6 @@ msgstr "" msgid "Request a feature" msgstr "" -msgid "<0>With unlocked cycles, you can edit your project's rules at any time.<1>With locked cycles, 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 cycles in the future." -msgstr "" - msgid "I confirm that the use and redemption of crypto tokens is legal in my jurisdiction, and that I am fully responsible for compliance with all relevant laws and regulations." msgstr "" diff --git a/src/packages/v1/components/V1Project/V1ProjectToolsDrawer/V1ProjectToolsDrawer.tsx b/src/packages/v1/components/V1Project/V1ProjectToolsDrawer/V1ProjectToolsDrawer.tsx index dc342a7ff0..884f91356f 100644 --- a/src/packages/v1/components/V1Project/V1ProjectToolsDrawer/V1ProjectToolsDrawer.tsx +++ b/src/packages/v1/components/V1Project/V1ProjectToolsDrawer/V1ProjectToolsDrawer.tsx @@ -1,13 +1,13 @@ import { Trans } from '@lingui/macro' import { Divider, Drawer, Space, Tabs } from 'antd' -import { AddToProjectBalanceForm } from 'components/Project/ProjectToolsDrawer/AddToProjectBalanceForm' -import { ExportSection } from 'components/Project/ProjectToolsDrawer/ExportSection' -import { TransferOwnershipForm } from 'components/Project/ProjectToolsDrawer/TransferOwnershipForm' import { useIsUserAddress } from 'hooks/useIsUserAddress' import { V1ProjectContext } from 'packages/v1/contexts/Project/V1ProjectContext' import { useAddToBalanceTx } from 'packages/v1/hooks/transactor/useAddToBalanceTx' import { useSafeTransferFromTx } from 'packages/v1/hooks/transactor/useSafeTransferFromTx' import { useSetProjectUriTx } from 'packages/v1/hooks/transactor/useSetProjectUriTx' +import { AddToProjectBalanceForm } from 'packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/AddToProjectBalanceForm' +import { ExportSection } from 'packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/ExportSection' +import { TransferOwnershipForm } from 'packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer/TransferOwnershipForm' import { useContext } from 'react' import ArchiveV1Project from './ArchiveV1Project' import { ExportPayoutModsButton } from './ExportPayoutModsButton' diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/PayProjectModal.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/PayProjectModal.tsx index 466aede953..24b0fbb66d 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/PayProjectModal.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/PayProjectModal.tsx @@ -3,11 +3,15 @@ import EtherscanLink from 'components/EtherscanLink' import ExternalLink from 'components/ExternalLink' import { JuiceModal } from 'components/modals/JuiceModal' import { Formik } from 'formik' -import Image from "next/legacy/image" +import { useWallet } from 'hooks/Wallet' +import Image from 'next/legacy/image' +import { useNftCredits } from 'packages/v2v3/hooks/JB721Delegate/useNftCredits' +import React, { ReactNode } from 'react' import { twMerge } from 'tailwind-merge' import { helpPagePath } from 'utils/helpPagePath' import { MessageSection } from './components/MessageSection' import { ReceiveSection } from './components/ReceiveSection' +import { usePayAmounts } from './hooks/usePayAmounts' import { PayProjectModalFormValues, usePayProjectModal, @@ -16,8 +20,6 @@ import { export const PayProjectModal: React.FC = () => { const { open, - primaryAmount, - secondaryAmount, validationSchema, isTransactionPending, isTransactionConfirmed, @@ -27,6 +29,7 @@ export const PayProjectModal: React.FC = () => { setOpen, onPaySubmit, } = usePayProjectModal() + const { formattedTotalAmount } = usePayAmounts() return ( @@ -50,7 +53,7 @@ export const PayProjectModal: React.FC = () => { position="top" okLoading={props.isSubmitting || isTransactionPending} okButtonForm="PayProjectModalForm" - okText={t`Pay ${primaryAmount}`} + okText={t`Pay ${formattedTotalAmount.primaryAmount}`} cancelText={ isTransactionPending || isTransactionConfirmed ? t`Close` @@ -97,19 +100,7 @@ export const PayProjectModal: React.FC = () => { ) : ( <>
-
- - Total amount - -
- {primaryAmount}{' '} - {secondaryAmount && ( - - ({secondaryAmount}) - - )} -
-
+ @@ -172,3 +163,61 @@ export const PayProjectModal: React.FC = () => { ) } + +const AmountSection = () => { + const { userAddress } = useWallet() + const { data: nftCredits } = useNftCredits(userAddress) + const { formattedAmount, formattedNftCredits, formattedTotalAmount } = + usePayAmounts() + + const RowData = ({ + label, + primaryAmount, + secondaryAmount, + }: { + label: ReactNode + primaryAmount: ReactNode + secondaryAmount: ReactNode + }) => ( +
+ {label} +
+ {primaryAmount}{' '} + {secondaryAmount && ( + + ({secondaryAmount}) + + )} +
+
+ ) + + if (!nftCredits?.gt(0) || !formattedNftCredits) + return ( + + ) + + return ( +
+ + + +
+ ) +} diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/hooks/usePayAmounts.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/hooks/usePayAmounts.ts new file mode 100644 index 0000000000..074455272f --- /dev/null +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/hooks/usePayAmounts.ts @@ -0,0 +1,137 @@ +import { useCurrencyConverter } from 'hooks/useCurrencyConverter' +import { useWallet } from 'hooks/Wallet' +import { useNftCredits } from 'packages/v2v3/hooks/JB721Delegate/useNftCredits' +import { + V2V3_CURRENCY_ETH, + V2V3_CURRENCY_USD, +} from 'packages/v2v3/utils/currency' +import { formatCurrencyAmount } from 'packages/v2v3/utils/formatCurrencyAmount' +import React from 'react' +import { fromWad, parseWad } from 'utils/format/formatNumber' +import { useProjectSelector } from '../../../../redux/hooks' +import { usePayProjectModal } from './usePayProjectModal/usePayProjectModal' + +export const usePayAmounts = () => { + const converter = useCurrencyConverter() + const { payAmount } = useProjectSelector(state => state.projectCart) + const { primaryAmount, secondaryAmount } = usePayProjectModal() + const { userAddress } = useWallet() + const { data: nftCreditsData } = useNftCredits(userAddress) + + const payAmountRaw = React.useMemo(() => { + if (!payAmount) return + + switch (payAmount.currency) { + case V2V3_CURRENCY_ETH: + return { + eth: parseWad(payAmount.amount), + usd: converter.weiToUsd(parseWad(payAmount.amount))!, + } + case V2V3_CURRENCY_USD: + return { + eth: converter.usdToWei(payAmount.amount), + usd: parseWad(payAmount.amount), + } + } + }, [converter, payAmount]) + + const appliedNFTCreditsRaw = React.useMemo(() => { + if (!payAmountRaw || !nftCreditsData) return + + const nftCreditsApplied = payAmountRaw.eth.lt(nftCreditsData) + ? payAmountRaw.eth + : nftCreditsData + + const eth = nftCreditsApplied + const usd = parseWad(converter.weiToUsd(nftCreditsApplied))! + + return { + eth, + usd, + } + }, [converter, nftCreditsData, payAmountRaw]) + + const formattedNftCredits = React.useMemo(() => { + if (!appliedNFTCreditsRaw || !payAmount) return + + switch (payAmount.currency) { + case V2V3_CURRENCY_ETH: + return { + primaryAmount: formatCurrencyAmount({ + amount: fromWad(appliedNFTCreditsRaw.eth), + currency: V2V3_CURRENCY_ETH, + }), + secondaryAmount: formatCurrencyAmount({ + amount: fromWad(appliedNFTCreditsRaw.usd), + currency: V2V3_CURRENCY_USD, + }), + } + case V2V3_CURRENCY_USD: + return { + primaryAmount: formatCurrencyAmount({ + amount: fromWad(appliedNFTCreditsRaw.usd), + currency: V2V3_CURRENCY_USD, + }), + secondaryAmount: formatCurrencyAmount({ + amount: fromWad(appliedNFTCreditsRaw.eth), + currency: V2V3_CURRENCY_ETH, + }), + } + } + }, [appliedNFTCreditsRaw, payAmount]) + + const formattedTotalAmount = React.useMemo(() => { + if (!payAmountRaw || !payAmount) return + + if (!appliedNFTCreditsRaw) { + return { + primaryAmount: primaryAmount, + secondaryAmount: secondaryAmount, + } + } + + const totalEth = payAmountRaw.eth.sub(appliedNFTCreditsRaw.eth) + const totalUsd = converter.weiToUsd(parseWad(totalEth)) + + const formattedEth = formatCurrencyAmount({ + amount: fromWad(totalEth), + currency: V2V3_CURRENCY_ETH, + }) + const formattedUsd = formatCurrencyAmount({ + amount: fromWad(totalUsd), + currency: V2V3_CURRENCY_USD, + }) + + switch (payAmount?.currency) { + case V2V3_CURRENCY_ETH: + return { + primaryAmount: formattedEth, + secondaryAmount: formattedUsd, + } + case V2V3_CURRENCY_USD: + return { + primaryAmount: formattedUsd, + secondaryAmount: formattedEth, + } + } + }, [ + appliedNFTCreditsRaw, + converter, + payAmount, + payAmountRaw, + primaryAmount, + secondaryAmount, + ]) + + return { + formattedAmount: { primaryAmount, secondaryAmount }, + formattedNftCredits: { + primaryAmount: formattedNftCredits?.primaryAmount, + secondaryAmount: formattedNftCredits?.secondaryAmount, + }, + formattedTotalAmount: { + primaryAmount: formattedTotalAmount?.primaryAmount, + secondaryAmount: formattedTotalAmount?.secondaryAmount, + }, + } +} diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts index f40a154bb2..b5d64a2cd7 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts @@ -5,6 +5,7 @@ import { useCurrencyConverter } from 'hooks/useCurrencyConverter' import { ProjectPayReceipt } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectPageQueries' import { useProjectSelector } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/redux/hooks' import { NftRewardsContext } from 'packages/v2v3/contexts/NftRewards/NftRewardsContext' +import { useNftCredits } from 'packages/v2v3/hooks/JB721Delegate/useNftCredits' import { usePayETHPaymentTerminalTx } from 'packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx' import { useProjectHasErc20 } from 'packages/v2v3/hooks/useProjectHasErc20' import { V2V3_CURRENCY_ETH } from 'packages/v2v3/utils/currency' @@ -36,6 +37,7 @@ export const usePayProjectTx = ({ const { payAmount, chosenNftRewards } = useProjectSelector( state => state.projectCart, ) + const { data: nftCredits } = useNftCredits(userAddress) const { nftRewards: { rewardTiers }, } = useContext(NftRewardsContext) @@ -64,12 +66,20 @@ export const usePayProjectTx = ({ const weiAmount = useMemo(() => { if (!payAmount) { return parseWad(0) - } else if (payAmount.currency === V2V3_CURRENCY_ETH) { - return parseWad(payAmount.amount) - } else { - return converter.usdToWei(payAmount.amount) } - }, [payAmount, converter]) + let weiAmount = + payAmount.currency === V2V3_CURRENCY_ETH + ? parseWad(payAmount.amount) + : converter.usdToWei(payAmount.amount) + if (nftCredits) { + if (nftCredits.gte(weiAmount)) { + weiAmount = parseWad(0) + } else { + weiAmount = weiAmount.sub(nftCredits) + } + } + return weiAmount + }, [payAmount, converter, nftCredits]) const prepareDelegateMetadata = usePrepareDelegatePayMetadata(weiAmount, { nftRewards: chosenNftRewards, @@ -136,17 +146,17 @@ export const usePayProjectTx = ({ } }, [ - projectHasErc20, - buildPayReceipt, chosenNftRewards, - onTransactionConfirmedCallback, - onTransactionErrorCallback, - onTransactionPendingCallback, - payProjectTx, - rewardTiers, - weiAmount, userAddress, + rewardTiers, + payProjectTx, prepareDelegateMetadata, + weiAmount, + projectHasErc20, + onTransactionConfirmedCallback, + buildPayReceipt, + onTransactionPendingCallback, + onTransactionErrorCallback, ], ) } diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderPopupMenu.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderPopupMenu.tsx new file mode 100644 index 0000000000..1e4e1a6d5c --- /dev/null +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/ProjectHeaderPopupMenu.tsx @@ -0,0 +1,120 @@ +import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline' +import { Trans } from '@lingui/macro' +import { SocialLinkButton } from 'components/Project/ProjectHeader/SocialLinkButton' +import { useSocialLinks } from 'components/Project/ProjectHeader/hooks/useSocialLinks' +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 { PopupMenu } from 'components/ui/PopupMenu' +import { PV_V2 } from 'constants/pv' +import useMobile from 'hooks/useMobile' +import { useMemo, useState } from 'react' +import { twJoin } from 'tailwind-merge' +import { V2V3ProjectToolsDrawer } from '../../V2V3ProjectToolsDrawer' + +type SocialLink = 'twitter' | 'discord' | 'telegram' | 'website' + +export function ProjectHeaderPopupMenu({ + className, + projectId, +}: { + className?: string + projectId: number +}) { + const socialLinks = useSocialLinks() + const isMobile = useMobile() + const [toolsIsOpen, setToolsIsOpen] = useState() + + const { isBookmarked, onBookmarkButtonClicked } = useBookmarkButton({ + projectId, + pv: PV_V2, + }) + const { isSubscribed, onSubscribeButtonClicked } = useSubscribeButton({ + projectId, + }) + + const socialItems = useMemo( + () => Object.entries(socialLinks).filter(([, href]) => !!href), + [socialLinks], + ) as [string, string][] + + return ( + <> + ({ + id: type, + label: ( + + ), + href, + })) + : []), + { + id: 'subscribe', + label: ( + <> + + + + Get notifications + + + ), + onClick: onSubscribeButtonClicked, + }, + { + id: 'bookmark', + label: ( + <> + + + Save project + + + ), + onClick(ev) { + ev.preventDefault() + ev.stopPropagation() + + onBookmarkButtonClicked() + }, + }, + { + id: 'tools', + label: ( + <> + + + + Tools + + + ), + onClick: ev => { + ev.preventDefault() + ev.stopPropagation() + + setToolsIsOpen(true) + }, + }, + ]} + /> + + setToolsIsOpen(false)} + /> + + ) +} diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/hooks/useReservedTokensSubPanel.test.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/hooks/useReservedTokensSubPanel.test.ts index eeebceb6e3..9f58b80e9b 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/hooks/useReservedTokensSubPanel.test.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/hooks/useReservedTokensSubPanel.test.ts @@ -57,7 +57,7 @@ describe('useReservedTokensSubPanel', () => { const { result } = renderHook(useReservedTokensSubPanel) expect(result.current.reservedList).toEqual([ { - projectId: 1, + projectId: 0, address: '0x0000000000000000000000000000000000000000', percent: '97%', }, diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/hooks/useReservedTokensSubPanel.ts b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/hooks/useReservedTokensSubPanel.ts index 866e083c40..30cb1ceab4 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/hooks/useReservedTokensSubPanel.ts +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokensPanel/hooks/useReservedTokensSubPanel.ts @@ -31,7 +31,7 @@ export const useReservedTokensSubPanel = () => { if (reservedTokensSplits?.length === 0) return [ { - projectId, + projectId: 0, address: projectOwnerAddress!, percent: `${formatSplitPercent(ONE_BILLION_BIG)}%`, }, @@ -72,7 +72,7 @@ export const useReservedTokensSubPanel = () => { // If it isn't, add a split at the beginning which brings the total percentage to 100%. else processedSplits.unshift({ - projectId, + projectId: 0, address: projectOwnerAddress!, percent: `${formatSplitPercent(remainingPercentage)}%`, }) diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/V2V3ProjectHeader.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/V2V3ProjectHeader.tsx index dca6c7b878..ddcff0e240 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/V2V3ProjectHeader.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/V2V3ProjectHeader.tsx @@ -6,7 +6,6 @@ import { DomainBadge } from 'components/DomainBadge' import EthereumAddress from 'components/EthereumAddress' import { GnosisSafeBadge } from 'components/Project/ProjectHeader/GnosisSafeBadge' import { ProjectHeaderLogo } from 'components/Project/ProjectHeader/ProjectHeaderLogo' -import { ProjectHeaderPopupMenu } from 'components/Project/ProjectHeader/ProjectHeaderPopupMenu' import { SocialLinkButton } from 'components/Project/ProjectHeader/SocialLinkButton' import { Subtitle } from 'components/Project/ProjectHeader/Subtitle' import { useSocialLinks } from 'components/Project/ProjectHeader/hooks/useSocialLinks' @@ -21,6 +20,7 @@ import { V2V3OperatorPermission } from 'packages/v2v3/models/v2v3Permissions' import { settingsPagePath, v2v3ProjectRoute } from 'packages/v2v3/utils/routes' import { twMerge } from 'tailwind-merge' import { SocialLink } from '../hooks/useAboutPanel' +import { ProjectHeaderPopupMenu } from './ProjectHeaderPopupMenu' export const V2V3ProjectHeader = ({ className }: { className?: string }) => { const socialLinks = useSocialLinks() diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/MintRateField.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/MintRateField.tsx index d9aaddb0e7..9b708bba3f 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/MintRateField.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/TokensSection/MintRateField.tsx @@ -3,10 +3,9 @@ import { Form } from 'antd' import FormattedNumberInput from 'components/inputs/FormattedNumberInput' import { MAX_MINT_RATE } from 'packages/v2v3/utils/math' -// Note: "issuanceRate" = "mintRate" export function MintRateField() { return ( - + Block number - setBlockNumber(val ? parseInt(val) : undefined)} - accessory={ - setBlockNumber(latestBlockNumber)} - disabled={blockNumber === latestBlockNumber} - /> - } - className="mb-4" - /> +
+ setBlockNumber(val ? parseInt(val) : undefined)} + accessory={ + setBlockNumber(latestBlockNumber)} + disabled={blockNumber === latestBlockNumber} + /> + } + /> +
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 ae6283fb0e..379752e218 100644 --- a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts +++ b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts @@ -1,10 +1,11 @@ -import { FixedInt } from 'fpnum' -import { getTokenAToBQuote, NATIVE_TOKEN_DECIMALS } from 'juice-sdk-core' +import { NATIVE_TOKEN_DECIMALS, getTokenAToBQuote } from 'juice-sdk-core' import { useJBRulesetContext, useJBTokenContext, useNativeTokenSymbol, } from 'juice-sdk-react' + +import { FixedInt } from 'fpnum' import { useProjectSelector } from 'packages/v4/components/ProjectDashboard/redux/hooks' import { V4_CURRENCY_USD } from 'packages/v4/utils/currency' import { tokenSymbolText } from 'utils/tokenSymbolText' @@ -48,7 +49,7 @@ export const useProjectPaymentTokens = () => { receivedTickets, receivedTokenSymbolText: receivedTokenSymbolText === 'tokens' - ? 'Project' - : receivedTokenSymbolText, + ? 'Project Token Credits' + : `${receivedTokenSymbolText} Token`, } } diff --git a/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx b/src/packages/v4/components/ProjectDashboard/components/ProjectHeaderPopupMenu.tsx similarity index 79% rename from src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx rename to src/packages/v4/components/ProjectDashboard/components/ProjectHeaderPopupMenu.tsx index dbe189de07..03267ec3eb 100644 --- a/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx +++ b/src/packages/v4/components/ProjectDashboard/components/ProjectHeaderPopupMenu.tsx @@ -1,15 +1,12 @@ import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline' import { Trans } from '@lingui/macro' -import { useBookmarkButton } from 'components/buttons/BookmarkButton/hooks/useBookmarkButton' -import { useSubscribeButton } from 'components/buttons/SubscribeButton/hooks/useSubscribeButton' -import { PV_V2 } from 'constants/pv' +import { SocialLinkButton } from 'components/Project/ProjectHeader/SocialLinkButton' +import { useSocialLinks } from 'components/Project/ProjectHeader/hooks/useSocialLinks' +import { PopupMenu } from 'components/ui/PopupMenu' import useMobile from 'hooks/useMobile' -import { V2V3ProjectToolsDrawer } from 'packages/v2v3/components/V2V3Project/V2V3ProjectToolsDrawer' import { useMemo, useState } from 'react' import { twJoin } from 'tailwind-merge' -import { PopupMenu } from '../../ui/PopupMenu' -import { SocialLinkButton } from './SocialLinkButton' -import { useSocialLinks } from './hooks/useSocialLinks' +import { V4ProjectToolsDrawer } from './V4ProjectToolsDrawer' type SocialLink = 'twitter' | 'discord' | 'telegram' | 'website' @@ -24,13 +21,13 @@ export function ProjectHeaderPopupMenu({ const isMobile = useMobile() const [toolsIsOpen, setToolsIsOpen] = useState() - const { isBookmarked, onBookmarkButtonClicked } = useBookmarkButton({ - projectId, - pv: PV_V2, - }) - const { isSubscribed, onSubscribeButtonClicked } = useSubscribeButton({ - projectId, - }) + // const { isBookmarked, onBookmarkButtonClicked } = useBookmarkButton({ + // projectId, + // pv: PV_V2, + // }) + // const { isSubscribed, onSubscribeButtonClicked } = useSubscribeButton({ + // projectId, + // }) const socialItems = useMemo( () => Object.entries(socialLinks).filter(([, href]) => !!href), @@ -109,7 +106,7 @@ export function ProjectHeaderPopupMenu({ ]} /> - setToolsIsOpen(false)} /> diff --git a/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/AddToProjectBalanceForm.tsx b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/AddToProjectBalanceForm.tsx new file mode 100644 index 0000000000..71fd4a421e --- /dev/null +++ b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/AddToProjectBalanceForm.tsx @@ -0,0 +1,100 @@ +import { Trans } from '@lingui/macro' +import { waitForTransactionReceipt } from '@wagmi/core' +import { Form } from 'antd' +import InputAccessoryButton from 'components/buttons/InputAccessoryButton' +import TransactorButton from 'components/buttons/TransactorButton' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { NATIVE_TOKEN } from 'juice-sdk-core' +import { useJBContractContext, useWriteJbMultiTerminalAddToBalanceOf } from 'juice-sdk-react' +import { wagmiConfig } from 'packages/v4/wagmiConfig' +import { useContext, useState } from 'react' +import { parseWad } from 'utils/format/formatNumber' +import { emitErrorNotification } from 'utils/notifications' +import { reloadWindow } from 'utils/windowUtils' + +export function AddToProjectBalanceForm() { + const { contracts, projectId } = useJBContractContext() + const { addTransaction } = useContext(TxHistoryContext) + + const [loadingAddToBalance, setLoadingAddToBalance] = useState() + + const { userAddress } = useWallet() + + const [addToBalanceForm] = Form.useForm<{ amount: string }>() + + const { writeContractAsync: writeAddToBalance } = + useWriteJbMultiTerminalAddToBalanceOf() + + + async function addToBalance() { + const amount = parseWad(addToBalanceForm.getFieldValue('amount')).toBigInt() + if ( + !amount || + !contracts.primaryNativeTerminal.data || + !projectId + ) + return + + setLoadingAddToBalance(true) + + const args = [ + projectId, + NATIVE_TOKEN, + amount, + false, // shouldReturnHeldFees + '', // memo + '0x', // metadata + ] as const + + try { + const hash = await writeAddToBalance({ + address: contracts.primaryNativeTerminal.data, + args, + }) + + addTransaction?.('Send payouts', { hash }) + await waitForTransactionReceipt(wagmiConfig, { + hash, + }) + + reloadWindow() + + setLoadingAddToBalance(false) + } catch (e) { + setLoadingAddToBalance(false) + + emitErrorNotification((e as unknown as Error).message) + } + } + + return ( +
+

+ Transfer ETH to this project +

+

+ + Transfer ETH from your wallet to this project without minting tokens. + +

+ + Transfer amount}> + } + /> + + addToBalance()} + loading={loadingAddToBalance} + size="small" + type="primary" + text={Transfer ETH to project} + disabled={!userAddress} + connectWalletText={Connect wallet to transfer ETH} + /> + + ) +} diff --git a/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/ExportSplitsButton.tsx b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/ExportSplitsButton.tsx new file mode 100644 index 0000000000..6f67f1fca1 --- /dev/null +++ b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/ExportSplitsButton.tsx @@ -0,0 +1,104 @@ +import { DownloadOutlined } from '@ant-design/icons' +import { t } from '@lingui/macro' +import { Button } from 'antd' +import { ETH_PAYOUT_SPLIT_GROUP } from 'constants/splits' +import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' +import { BigNumber } from 'ethers' +import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' +import { GroupedSplits, Split, SplitGroup } from 'packages/v2v3/models/splits' +import { formatSplitPercent } from 'packages/v2v3/utils/math' +import { getProjectOwnerRemainderSplit } from 'packages/v2v3/utils/v2v3Splits' +import { PropsWithChildren, useContext, useState } from 'react' +import { downloadCsvFile } from 'utils/csv' +import { emitErrorNotification } from 'utils/notifications' + +const CSV_HEADER = [ + 'beneficiary', + 'percent', + 'preferClaimed', + 'lockedUntil', + 'projectId', + 'allocator', +] + +const splitToCsvRow = (split: Split) => { + return [ + split.beneficiary, + `${parseFloat(formatSplitPercent(BigNumber.from(split.percent))) / 100}`, + `${split.preferClaimed}`, + `${split.lockedUntil}`, + split.projectId, + split.allocator, + ] +} + +const prepareSplitsCsv = ( + splits: Split[], + projectOwnerAddress: string, +): (string | undefined)[][] => { + const csvContent = splits.map(splitToCsvRow) + + const rows = [CSV_HEADER, ...csvContent] + + const projectOwnerSplit = getProjectOwnerRemainderSplit( + projectOwnerAddress, + splits, + ) + if (projectOwnerSplit.percent > 0) { + rows.push(splitToCsvRow(projectOwnerSplit)) + } + + return rows +} + +export function ExportSplitsButton({ + children, + groupedSplits, +}: PropsWithChildren<{ groupedSplits: GroupedSplits }>) { + const { handle, fundingCycle, projectOwnerAddress } = + useContext(V2V3ProjectContext) + const { projectId } = useContext(ProjectMetadataContext) + const [loading, setLoading] = useState(false) + + const onExportSplitsButtonClick = () => { + if (!groupedSplits || !fundingCycle || !projectOwnerAddress) { + emitErrorNotification( + t`CSV data wasn't ready for export. Wait a few seconds and try again.`, + ) + return + } + + setLoading(true) + + try { + const csvContent = prepareSplitsCsv( + groupedSplits.splits, + projectOwnerAddress, + ) + const projectIdentifier = handle ? `@${handle}` : `project-${projectId}` + const splitType = + groupedSplits.group === ETH_PAYOUT_SPLIT_GROUP + ? 'payouts' + : 'reserved-tokens' + const filename = `${projectIdentifier}_${splitType}_fc-${fundingCycle.number}` + + downloadCsvFile(filename, csvContent) + } catch (e) { + console.error(e) + emitErrorNotification(t`CSV download failed.`) + } finally { + setLoading(false) + } + } + + return ( + + ) +} diff --git a/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerButton.tsx b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerButton.tsx new file mode 100644 index 0000000000..5b8b43f584 --- /dev/null +++ b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerButton.tsx @@ -0,0 +1,23 @@ +import { Trans } from '@lingui/macro' +import { Button } from 'antd' +import { useState } from 'react' +import { LaunchProjectPayerModal } from './LaunchProjectPayerModal/LaunchProjectPayerModal' + +export function LaunchProjectPayerButton() { + const [modalVisible, setModalVisible] = useState(false) + + return ( + <> + + + setModalVisible(false)} + /> + + ) +} diff --git a/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerModal/AdvancedOptionsCollapse.tsx b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerModal/AdvancedOptionsCollapse.tsx new file mode 100644 index 0000000000..232e6a9ae0 --- /dev/null +++ b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerModal/AdvancedOptionsCollapse.tsx @@ -0,0 +1,132 @@ +import { t, Trans } from '@lingui/macro' +import { Form, FormInstance, Input, Switch } from 'antd' +import { EthAddressInput } from 'components/inputs/EthAddressInput' +import { FormImageUploader } from 'components/inputs/FormImageUploader' +import { MinimalCollapse } from 'components/MinimalCollapse' +import TooltipLabel from 'components/TooltipLabel' +import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' +import { useContext, useState } from 'react' +import { isZeroAddress } from 'utils/address' + +import type { AdvancedOptionsFields } from './LaunchProjectPayerModal' + +const defaultAdvancedOptions: AdvancedOptionsFields = { + memo: '', + memoImageUrl: undefined, + tokenMintingEnabled: true, + preferClaimed: false, + customBeneficiaryAddress: undefined, +} + +export default function AdvancedOptionsCollapse({ + form, +}: { + form: FormInstance +}) { + const { tokenAddress } = useContext(V2V3ProjectContext) + + // need state for this field to update dom + const [tokenMintingEnabled, setTokenMintingEnabled] = useState( + form.getFieldValue('tokenMintingEnabled') === false ? false : true, + ) + + const [customBeneficiaryEnabled, setCustomBeneficiaryEnabled] = + useState(Boolean(form.getFieldValue('customBeneficiaryAddress'))) + + return ( + Advanced (optional)}> +
+
+
+ + The onchain memo for each payment made through this address. + The project's payment feed will include the memo alongside the + payment. + + } + /> + + + + + + +
+
+ + + + +
+ {tokenMintingEnabled && + tokenAddress && + !isZeroAddress(tokenAddress) ? ( +
+ + When checked, payments made through this address will mint + the project's ERC-20 tokens. Payments will incur slightly + higher gas fees. When unchecked, the Juicebox protocol will + internally track the beneficiary's tokens, and they can + claim their ERC-20 tokens at any time. + + } + /> + + + +
+ ) : null} + + {tokenMintingEnabled ? ( +
+ + If enabled, project tokens will be minted to a custom + beneficiary address. By default, project tokens will be + minted to the wallet that pays this address. + + } + /> + { + setCustomBeneficiaryEnabled(checked) + if (!checked) { + form.setFieldsValue({ customBeneficiaryAddress: undefined }) + } + }} + checked={customBeneficiaryEnabled} + /> +
+ ) : null} + {tokenMintingEnabled && customBeneficiaryEnabled ? ( + + form.setFieldsValue({ customBeneficiaryAddress: value }) + } + /> + ) : null} + +
+
+ ) +} diff --git a/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerModal/LaunchProjectPayerModal.tsx b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerModal/LaunchProjectPayerModal.tsx new file mode 100644 index 0000000000..6eca533420 --- /dev/null +++ b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/LaunchProjectPayerModal/LaunchProjectPayerModal.tsx @@ -0,0 +1,159 @@ +import { ToolOutlined } from '@ant-design/icons' +import { t, Trans } from '@lingui/macro' +import { Modal } from 'antd' +import { useForm } from 'antd/lib/form/Form' +import CopyTextButton from 'components/buttons/CopyTextButton' +import { Callout } from 'components/Callout/Callout' +import EtherscanLink from 'components/EtherscanLink' +import TransactionModal from 'components/modals/TransactionModal' +import { PROJECT_PAYER_ADDRESS_EXPLANATION } from 'components/strings' +import { providers } from 'ethers' +import { useState } from 'react' +import AdvancedOptionsCollapse from './AdvancedOptionsCollapse' + +const DEPLOY_EVENT_IDX = 0 + +/** + * Return the address of the project payer created from a `deployProjectPayer` transaction. + * @param txReceipt receipt of `deployProjectPayer` transaction + */ +const getProjectPayerAddressFromReceipt = ( + txReceipt: providers.TransactionReceipt, +): string => { + const newProjectPayerAddress = txReceipt?.logs[DEPLOY_EVENT_IDX]?.address + return newProjectPayerAddress +} + +export interface AdvancedOptionsFields { + memo: string + memoImageUrl: string | undefined + tokenMintingEnabled: boolean + customBeneficiaryAddress: string | undefined + preferClaimed: boolean +} + +export function LaunchProjectPayerModal({ + open, + onClose, + onConfirmed, +}: { + open: boolean + onClose: VoidFunction + onConfirmed?: VoidFunction +}) { + const [loadingProjectPayer, setLoadingProjectPayer] = useState() + const [transactionPending, setTransactionPending] = useState() + const [projectPayerAddress, setProjectPayerAddress] = useState() + + const [advancedOptionsForm] = useForm() + + const [confirmedModalVisible, setConfirmedModalVisible] = useState() + + // const deployProjectPayerTx = useDeployProjectPayerTx() + + // async function deployProjectPayer() { + // if (!deployProjectPayerTx) return + + // setLoadingProjectPayer(true) + + // const fields = advancedOptionsForm.getFieldsValue(true) + // const memo = [fields.memo ?? '', fields.memoImageUrl ?? ''].join(' ').trim() + + // const txSuccess = await deployProjectPayerTx( + // { + // customBeneficiaryAddress: fields.customBeneficiaryAddress, + // customMemo: memo.length > 0 ? memo : undefined, + // tokenMintingEnabled: fields.tokenMintingEnabled, + // preferClaimed: fields.preferClaimed, + // }, + // { + // onDone() { + // setTransactionPending(true) + // }, + // async onConfirmed(tx) { + // const txHash = tx?.hash + // if (!txHash) { + // return + // } + + // const txReceipt = await readProvider.getTransactionReceipt(txHash) + // const newProjectPayerAddress = + // getProjectPayerAddressFromReceipt(txReceipt) + // if (newProjectPayerAddress === undefined) { + // emitErrorNotification(t`Something went wrong.`) + // return + // } + // if (onConfirmed) onConfirmed() + // onClose() + // setProjectPayerAddress(newProjectPayerAddress) + // setLoadingProjectPayer(false) + // setTransactionPending(false) + // setConfirmedModalVisible(true) + // advancedOptionsForm.resetFields() + // }, + // }, + // ) + // if (!txSuccess) { + // setLoadingProjectPayer(false) + // setTransactionPending(false) + // } + // } + + return ( + <> + null}//deployProjectPayer} + onCancel={() => onClose()} + confirmLoading={loadingProjectPayer} + transactionPending={transactionPending} + width={600} + > +
+
{PROJECT_PAYER_ADDRESS_EXPLANATION}
+
+ + By default, the payer will receive any project tokens minted from + the payment. + +
+ + + + Contributors who pay this address from a custodial service + platform (like Coinbase){' '} + won't receive project tokens. + + + +
+
+ setConfirmedModalVisible(false)} + cancelButtonProps={{ hidden: true }} + okText={t`Done`} + centered + > +

+ Your new project payer address: +

+ {' '} + +

+ + Existing project payer addresses can be found in the Tools drawer ( + ) on the project page. + +

+
+ + ) +} diff --git a/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/PaymentAddressSection.tsx b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/PaymentAddressSection.tsx new file mode 100644 index 0000000000..89e74c9358 --- /dev/null +++ b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/PaymentAddressSection/PaymentAddressSection.tsx @@ -0,0 +1,49 @@ +import { PROJECT_PAYER_ADDRESS_EXPLANATION } from 'components/strings' +import { LaunchProjectPayerButton } from './LaunchProjectPayerButton' + +export function PaymentAddressSection() { + // const { projectId } = useContext(ProjectMetadataContext) + + // const [projectPayersModalIsVisible, setProjectPayersModalIsVisible] = + // useState() + + // const { data, loading } = useEtherc20ProjectPayersQuery({ + // client, + // variables: { + // where: { + // projectId, + // }, + // }, + // }) + + // const projectPayers = data?.etherc20ProjectPayers + + return ( + <> +

{PROJECT_PAYER_ADDRESS_EXPLANATION}

+ +

+ {/* */} + {/* setProjectPayersModalIsVisible(false)} + projectPayers={projectPayers} + /> */} +

+ + + + ) +} diff --git a/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/V4ProjectToolsDrawer.tsx b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/V4ProjectToolsDrawer.tsx new file mode 100644 index 0000000000..a6bbde9e60 --- /dev/null +++ b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/V4ProjectToolsDrawer.tsx @@ -0,0 +1,92 @@ +import { Trans } from '@lingui/macro' +import { Divider, Drawer } from 'antd' +import useMobile from 'hooks/useMobile' +import { AddToProjectBalanceForm } from './AddToProjectBalanceForm' + +export function V4ProjectToolsDrawer({ + open, + onClose, +}: { + open?: boolean + onClose?: VoidFunction +}) { + // const hasOFAC = projectMetadata?.projectRequiredOFACCheck + + const isMobile = useMobile() + + return ( + +

+ Tools +

+ +
+ {/* {hasOFAC ? null : ( */} + <> +
+ +
+ + + {/* @v4todo:
+

+ Project payer addresses +

+ + +
+ */} + + {/* )} */} + + {/* + groupedSplits={{ + splits: payoutSplits, + group: ETH_PAYOUT_SPLIT_GROUP, + }} + > + Export payouts CSV + + ) : undefined + } + exportReservedTokensButton={ + reservedTokensSplits ? ( + + groupedSplits={{ + splits: reservedTokensSplits, + group: RESERVED_TOKEN_SPLIT_GROUP, + }} + > + Export reserved token recipient CSV + + ) : undefined + } + /> */} + + {/* + +
+

+ Project contracts directory +

+ +

+ Browse the project's smart contract addresses.{' '} + + Go to contracts directory + + . +

+
*/} +
+
+ ) +} diff --git a/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/index.tsx b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/index.tsx new file mode 100644 index 0000000000..321faa4723 --- /dev/null +++ b/src/packages/v4/components/ProjectDashboard/components/V4ProjectToolsDrawer/index.tsx @@ -0,0 +1,2 @@ +export { V4ProjectToolsDrawer } from './V4ProjectToolsDrawer'; + diff --git a/src/packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal.tsx b/src/packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal.tsx index 4b0e57d700..c0bd31d635 100644 --- a/src/packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal.tsx +++ b/src/packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal.tsx @@ -1,8 +1,7 @@ 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 { useJBContractContext, useJBTokenContext } 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' @@ -19,8 +18,10 @@ export const V4TokenHoldersModal = ({ onClose: VoidFunction }) => { const { projectId } = useJBContractContext() - const { data: tokenAddress } = useReadJbTokensTokenOf() - const { data: tokenSymbol } = useNameOfERC20(tokenAddress) + + const { token } = useJBTokenContext() + const tokenAddress = token?.data?.address + const tokenSymbol = token?.data?.symbol const { data: totalTokenSupply } = useV4TotalTokenSupply() diff --git a/src/packages/v4/constants/ballotStrategies/index.ts b/src/packages/v4/constants/ballotStrategies/index.ts new file mode 100644 index 0000000000..b09263a0b2 --- /dev/null +++ b/src/packages/v4/constants/ballotStrategies/index.ts @@ -0,0 +1,83 @@ +import { plural, t } from '@lingui/macro' + +import { readNetwork } from 'constants/networks' +import { SECONDS_IN_DAY } from 'constants/numbers' +import { constants } from 'ethers' +import { NetworkName } from 'models/networkName' +import { ReconfigurationStrategy } from 'models/reconfigurationStrategy' + +type BallotOption = Record< + 'ONE_DAY' | 'THREE_DAY' | 'SEVEN_DAY', + Partial> +> + + +// v4TODO: Apply real v4 addresses +const BALLOT_ADDRESSES: BallotOption = { + ONE_DAY: { + mainnet: '0xDd9303491328F899796319C2b6bD614324b86314', + sepolia: '0x34E2992ea3C3E6CcfCf5bC668B68F285C1EDFE24', + }, + THREE_DAY: { + mainnet: '0x19D8C293D35EA4b2879A864A68D45a2025694929', + sepolia: '0xa2154aBD135be068540073cB4390139906d0FDc6', + }, + SEVEN_DAY: { + mainnet: '0x8E1AEc30063565e597705E71Ba14Dffc4C390Ef0', + sepolia: '0xb958fEa9089208d0Cc990EceCEcc42458F1B618e', + }, +} + +interface BallotStrategy { + id: ReconfigurationStrategy + name: string + description: string + address: string + durationSeconds: number +} + +const durationBallotStrategyDescription = (days: number) => + plural(days, { + one: 'Edits to an upcoming ruleset cycle must be submitted at least # day before that ruleset cycle starts.', + other: + 'Edits to an upcoming ruleset cycle must be submitted at least # days before that ruleset cycle starts.', + }) + +export function ballotStrategiesFn({ + network, +}: { + network?: NetworkName +} = {}): BallotStrategy[] { + const ballotStrategies: BallotStrategy[] = [ + { + id: 'none', + name: t`No deadline`, + description: t`Edits to upcoming ruleset cycles will take effect when the current cycle ends. A project with no deadline is vulnerable to last-second edits by its owner.`, + address: constants.AddressZero, + durationSeconds: 0, + }, + { + id: 'oneDay', + name: t`1-day deadline`, + description: durationBallotStrategyDescription(1), + address: BALLOT_ADDRESSES.ONE_DAY[network ?? readNetwork.name]!, + durationSeconds: SECONDS_IN_DAY, + }, + { + id: 'threeDay', + name: t`3-day deadline`, + description: durationBallotStrategyDescription(3), + address: BALLOT_ADDRESSES.THREE_DAY[network ?? readNetwork.name]!, + durationSeconds: SECONDS_IN_DAY * 3, + }, + { + id: 'sevenDay', + name: t`7-day deadline`, + description: durationBallotStrategyDescription(7), + address: BALLOT_ADDRESSES.SEVEN_DAY[network ?? readNetwork.name]!, + durationSeconds: SECONDS_IN_DAY * 7, + }, + ] + return ballotStrategies +} + diff --git a/src/packages/v4/hooks/useProjectHasErc20Token.ts b/src/packages/v4/hooks/useProjectHasErc20Token.ts index a38b254f4a..4a62028232 100644 --- a/src/packages/v4/hooks/useProjectHasErc20Token.ts +++ b/src/packages/v4/hooks/useProjectHasErc20Token.ts @@ -1,8 +1,9 @@ -import { useReadJbTokensTokenOf } from 'juice-sdk-react' +import { useJBTokenContext } from 'juice-sdk-react' import { isZeroAddress } from 'utils/address' export const useProjectHasErc20Token = () => { - const { data: tokenAddress } = useReadJbTokensTokenOf() + const { token } = useJBTokenContext() + const tokenAddress = token?.data?.address return Boolean(tokenAddress && !isZeroAddress(tokenAddress)) } diff --git a/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts b/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts index aff75e3842..0edd5fbb6d 100644 --- a/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts +++ b/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts @@ -1,9 +1,11 @@ import { useCallback, useContext } from 'react' +import { waitForTransactionReceipt } from '@wagmi/core' import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' import { useJBContractContext, useWriteJbControllerDeployErc20For } from 'juice-sdk-react' import { Address, zeroAddress } from 'viem' import { BaseTxOpts } from '../models/transactions' +import { wagmiConfig } from '../wagmiConfig' export function useV4IssueErc20TokenTx() { const { addTransaction } = useContext(TxHistoryContext) @@ -47,12 +49,12 @@ export function useV4IssueErc20TokenTx() { onTransactionPendingCallback(hash) addTransaction?.('Launch ERC20 Token', { hash }) - // const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( - // wagmiConfig, - // { - // hash, - // }, - // ) + await waitForTransactionReceipt( + wagmiConfig, + { + hash, + }, + ) onTransactionConfirmedCallback() } catch (e) { diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx index db5bcef5f3..decdad49b9 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx @@ -11,6 +11,7 @@ import { SocialLinkButton } from 'components/Project/ProjectHeader/SocialLinkBut import { TruncatedText } from 'components/TruncatedText' import useMobile from 'hooks/useMobile' import Link from 'next/link' +import { ProjectHeaderPopupMenu } from 'packages/v4/components/ProjectDashboard/components/ProjectHeaderPopupMenu' import V4ProjectHandleLink from 'packages/v4/components/V4ProjectHandleLink' import { useV4WalletHasPermission } from 'packages/v4/hooks/useV4WalletHasPermission' import { V4OperatorPermission } from 'packages/v4/models/v4Permissions' @@ -57,8 +58,7 @@ export const V4ProjectHeader = ({ className }: { className?: string }) => {
{projectId ? ( isMobile ? ( - // - <> + ) : ( <>
@@ -73,7 +73,7 @@ export const V4ProjectHeader = ({ className }: { className?: string }) => { /> ))}
- {/* @v4todo: */} + {canQueueRuleSets && ( diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx index b559162cc0..ed88416781 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx @@ -6,7 +6,6 @@ import { Callout } from 'components/Callout/Callout' import FormattedNumberInput from 'components/inputs/FormattedNumberInput' import TransactionModal from 'components/modals/TransactionModal' import { FEES_EXPLANATION } from 'components/strings' -import { useProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' import { NATIVE_TOKEN, NATIVE_TOKEN_DECIMALS } from 'juice-sdk-core' import { @@ -35,8 +34,7 @@ export default function V4DistributePayoutsModal({ const { data: payoutSplits } = useV4CurrentPayoutSplits() const { data: payoutLimit } = usePayoutLimit() const { distributableAmount: distributable } = useV4DistributableAmount() - const { projectId } = useProjectMetadataContext() - const { contracts } = useJBContractContext() + const { contracts, projectId } = useJBContractContext() const { addTransaction } = useContext(TxHistoryContext) const payoutLimitAmountCurrency = payoutLimit?.currency ?? V4_CURRENCY_ETH @@ -45,7 +43,7 @@ export default function V4DistributePayoutsModal({ const [loading, setLoading] = useState() const [distributionAmount, setDistributionAmount] = useState() - const { writeContractAsync: writeSendPayouts, data } = + const { writeContractAsync: writeSendPayouts } = useWriteJbMultiTerminalSendPayoutsOf() async function executeDistributePayoutsTx() { 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 bca38922ca..ba6a1e5247 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts @@ -7,6 +7,7 @@ import { flagPairToDatum } from 'components/Project/ProjectTabs/utils/flagPairTo import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' import { JBRulesetData, JBRulesetMetadata } from 'juice-sdk-core' import { useMemo } from 'react' +import { formattedNum } from 'utils/format/formatNumber' import { tokenSymbolText } from 'utils/tokenSymbolText' export const useV4FormatConfigurationTokenSection = ({ @@ -32,6 +33,7 @@ export const useV4FormatConfigurationTokenSection = ({ const decayPercentFloat = ruleset?.decayPercent.toFloat() const currentTotalIssuanceRate = ruleset?.weight.toFloat() + const currentTotalIssuanceRateFormatted = formattedNum(currentTotalIssuanceRate) const queuedTotalIssuanceRate = upcomingRuleset ? upcomingRuleset?.weight.toFloat() @@ -39,10 +41,11 @@ export const useV4FormatConfigurationTokenSection = ({ typeof decayPercentFloat !== 'undefined' ? currentTotalIssuanceRate - currentTotalIssuanceRate * decayPercentFloat : undefined + const queuedTotalIssuanceRateFormatted = formattedNum(queuedTotalIssuanceRate) const totalIssuanceRateDatum: ConfigurationPanelDatum = useMemo(() => { - const current = currentTotalIssuanceRate !== undefined - ? `${currentTotalIssuanceRate} ${tokenSymbol}/ETH` + const current = currentTotalIssuanceRateFormatted !== undefined + ? `${currentTotalIssuanceRateFormatted} ${tokenSymbol}/ETH` : undefined if (upcomingRuleset === null || upcomingRulesetLoading) { @@ -50,16 +53,17 @@ export const useV4FormatConfigurationTokenSection = ({ } const queued = queuedTotalIssuanceRate !== undefined - ? `${queuedTotalIssuanceRate} ${tokenSymbol}/ETH` + ? `${queuedTotalIssuanceRateFormatted} ${tokenSymbol}/ETH` : undefined return pairToDatum(t`Total issuance rate`, current, queued) }, [ upcomingRuleset, - currentTotalIssuanceRate, tokenSymbol, queuedTotalIssuanceRate, upcomingRulesetLoading, + currentTotalIssuanceRateFormatted, + queuedTotalIssuanceRateFormatted ]) const reservedPercentFloat = rulesetMetadata?.reservedPercent.toFloat() @@ -73,9 +77,10 @@ export const useV4FormatConfigurationTokenSection = ({ ? currentTotalIssuanceRate - currentTotalIssuanceRate * reservedPercentFloat : undefined + const currentPayerIssuanceRateFormatted = formattedNum(currentPayerIssuanceRate) const current = currentPayerIssuanceRate !== undefined - ? `${currentPayerIssuanceRate} ${tokenSymbol}/ETH` + ? `${currentPayerIssuanceRateFormatted} ${tokenSymbol}/ETH` : undefined if ( @@ -88,11 +93,12 @@ export const useV4FormatConfigurationTokenSection = ({ const _reservedPercent = queuedReservedPercentFloat ?? reservedPercentFloat const queuedPayerIssuanceRate = - queuedTotalIssuanceRate && _reservedPercent + queuedTotalIssuanceRate && _reservedPercent !== undefined ? queuedTotalIssuanceRate - queuedTotalIssuanceRate * _reservedPercent : undefined + const queuedPayerIssuanceRateFormatted = formattedNum(queuedPayerIssuanceRate) const queued = queuedPayerIssuanceRate !== undefined - ? `${queuedPayerIssuanceRate} ${tokenSymbol}/ETH` + ? `${queuedPayerIssuanceRateFormatted} ${tokenSymbol}/ETH` : undefined return pairToDatum(t`Payer issuance rate`, current, queued) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ClaimTokensModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ClaimTokensModal.tsx new file mode 100644 index 0000000000..4198629406 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ClaimTokensModal.tsx @@ -0,0 +1,200 @@ +import { WarningOutlined } from '@ant-design/icons' +import { t, Trans } from '@lingui/macro' +import { waitForTransactionReceipt } from '@wagmi/core' +import { Descriptions, Form } from 'antd' +import InputAccessoryButton from 'components/buttons/InputAccessoryButton' +import EthereumAddress from 'components/EthereumAddress' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import TransactionModal from 'components/modals/TransactionModal' +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { Ether } from 'juice-sdk-core' +import { useJBContractContext, useJBTokenContext, useReadJbTokensCreditBalanceOf, useWriteJbControllerClaimTokensFor } from 'juice-sdk-react' +import { useProjectHasErc20Token } from 'packages/v4/hooks/useProjectHasErc20Token' +import { wagmiConfig } from 'packages/v4/wagmiConfig' +import { useContext, useLayoutEffect, useState } from 'react' +import { fromWad, parseWad } from 'utils/format/formatNumber' +import { emitErrorNotification } from 'utils/notifications' +import { tokenSymbolText } from 'utils/tokenSymbolText' +import { zeroAddress } from 'viem' + +export function V4ClaimTokensModal({ + open, + onCancel, + onConfirmed, +}: { + open?: boolean + onCancel?: VoidFunction + onConfirmed?: VoidFunction +}) { + const { projectId, contracts } = useJBContractContext() + const { addTransaction } = useContext(TxHistoryContext) + + const { token } = useJBTokenContext() + const tokenAddress = token?.data?.address + const tokenSymbol = token?.data?.symbol + + const [loading, setLoading] = useState() + const [transactionPending, setTransactionPending] = useState() + const [claimAmount, setClaimAmount] = useState() + + const { userAddress } = useWallet() + + const { writeContractAsync: writeClaimTokens } = + useWriteJbControllerClaimTokensFor() + + const hasIssuedTokens = useProjectHasErc20Token() + + const { data: unclaimedBalance } = useReadJbTokensCreditBalanceOf({ + args: [userAddress ?? zeroAddress, projectId], + }) + + useLayoutEffect(() => { + setClaimAmount(fromWad(unclaimedBalance)) + }, [unclaimedBalance]) + + async function executeClaimTokensTx() { + if ( + !contracts.controller.data || + !claimAmount || + !userAddress || + !projectId + ) + return + + setLoading(true) + + const args = [ + userAddress, + projectId, + parseWad(claimAmount).toBigInt(), + userAddress + ] as const + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbControllerAbi, + // functionName: 'claimTokensFor', + // args, + // }) + // console.log('encodedData:', encodedData) + // console.log('contract:', contracts.controller.data) + + const hash = await writeClaimTokens({ + address: contracts.controller.data, + args, + }) + setTransactionPending(true) + + addTransaction?.('Claim tokens as ERC20', { hash }) + await waitForTransactionReceipt(wagmiConfig, { + hash, + }) + + setLoading(false) + setTransactionPending(false) + onConfirmed?.() + } catch (e) { + setLoading(false) + + emitErrorNotification((e as unknown as Error).message) + } + } + + const tokenTextLong = tokenSymbolText({ + tokenSymbol, + plural: true, + includeTokenWord: true, + }) + + const tokenTextShort = tokenSymbolText({ + tokenSymbol, + plural: true, + }) + + return ( + +
+ {!hasIssuedTokens && ( +
+ {' '} + + Tokens cannot be claimed because the project owner has not issued + an ERC-20 for this project. + +
+ )} + +
+

+ + Claiming {tokenTextLong} will convert your {tokenTextShort}{' '} + balance to ERC-20 tokens and mint them to your wallet. + +

+

+ + If you're not sure if you need to claim, you probably don't. + +

+

+ + You can redeem your {tokenTextLong} for ETH without claiming them. + You can transfer your unclaimed {tokenTextLong} to another address + from the Tools menu, which can be accessed from the wrench icon in + the upper right-hand corner of this project. + +

+
+ + + Your unclaimed {tokenTextLong}} + > + {new Ether(unclaimedBalance ?? 0n).format()}{' '}{tokenTextShort} + + + {hasIssuedTokens && tokenSymbol && ( + {tokenSymbol} ERC-20 address} + > + + + )} + + +
+ + setClaimAmount(fromWad(unclaimedBalance))} + /> + } + onChange={val => setClaimAmount(val)} + /> + +
+
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4DistributeReservedTokensModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4DistributeReservedTokensModal.tsx index 981da7cab9..fd8c6a0f2c 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4DistributeReservedTokensModal.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4DistributeReservedTokensModal.tsx @@ -1,11 +1,14 @@ import { t, Trans } from '@lingui/macro' +import { waitForTransactionReceipt } from '@wagmi/core' import TransactionModal from 'components/modals/TransactionModal' -import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' -import { useJBContractContext, useReadJbTokensTokenOf } from 'juice-sdk-react' +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useJBContractContext, useJBTokenContext, useWriteJbControllerSendReservedTokensToSplitsOf } from 'juice-sdk-react' import SplitList from 'packages/v4/components/SplitList/SplitList' import useProjectOwnerOf from 'packages/v4/hooks/useV4ProjectOwnerOf' import { useV4ReservedSplits } from 'packages/v4/hooks/useV4ReservedSplits' -import { useState } from 'react' +import { wagmiConfig } from 'packages/v4/wagmiConfig' +import { useContext, useState } from 'react' +import { emitErrorNotification } from 'utils/notifications' import { tokenSymbolText } from 'utils/tokenSymbolText' import { useV4ReservedTokensSubPanel } from './hooks/useV4ReservedTokensSubPanel' @@ -18,40 +21,60 @@ export default function V4DistributeReservedTokensModal({ onCancel?: VoidFunction onConfirmed?: VoidFunction }) { - const { projectId } = useJBContractContext() + const { addTransaction } = useContext(TxHistoryContext) + + const { projectId, contracts } = useJBContractContext() const { splits: reservedTokensSplits } = useV4ReservedSplits() const { data: projectOwnerAddress } = useProjectOwnerOf() - const { data: tokenAddress } = useReadJbTokensTokenOf() - const { data: tokenSymbol } = useNameOfERC20(tokenAddress) + + const { token } = useJBTokenContext() + const tokenSymbol = token?.data?.symbol const [loading, setLoading] = useState() const [transactionPending, setTransactionPending] = useState() // const distributeReservedTokensTx = useDistributeReservedTokens() + const { writeContractAsync: writeSendReservedTokens, data } = + useWriteJbControllerSendReservedTokensToSplitsOf() + const { pendingReservedTokens, pendingReservedTokensFormatted } = useV4ReservedTokensSubPanel() - async function distributeReservedTokens() { + async function sendReservedTokens() { + if ( + // !payoutLimitAmountCurrency || + // !distributionAmount || + !contracts.controller.data || + !projectId + ) + return + setLoading(true) - // const txSuccessful = await distributeReservedTokensTx( - // {}, - // { - // onDone: () => { - // setTransactionPending(true) - // }, - // onConfirmed: () => { - // setLoading(false) - // setTransactionPending(false) - // onConfirmed?.() - // }, - // }, - // ) - - // if (!txSuccessful) { - setLoading(false) - setTransactionPending(false) - // } + const args = [ + BigInt(projectId) + ] as const + + try { + const hash = await writeSendReservedTokens({ + address: contracts.controller.data, + args, + }) + setTransactionPending(true) + + addTransaction?.('Send reserved tokens', { hash }) + await waitForTransactionReceipt(wagmiConfig, { + hash, + }) + + setLoading(false) + setTransactionPending(false) + onConfirmed?.() + } catch (e) { + setLoading(false) + + emitErrorNotification((e as unknown as Error).message) + } } const tokenTextPlural = tokenSymbolText({ @@ -70,7 +93,7 @@ export default function V4DistributeReservedTokensModal({ Send reserved {tokenTextPlural}} open={open} - onOk={() => distributeReservedTokens()} + onOk={() => sendReservedTokens()} okText={t`Send ${tokenTextPlural}`} connectWalletText={t`Connect wallet to send reserved ${tokenTextPlural}`} confirmLoading={loading} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4MintModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4MintModal.tsx new file mode 100644 index 0000000000..389b9f4b15 --- /dev/null +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4MintModal.tsx @@ -0,0 +1,161 @@ +import { t } from '@lingui/macro' +import { waitForTransactionReceipt } from '@wagmi/core' +import { Form, Input } from 'antd' +import { useForm } from 'antd/lib/form/Form' +import { EthAddressInput } from 'components/inputs/EthAddressInput' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' +import TransactionModal from 'components/modals/TransactionModal' +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { utils } from 'ethers' +import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' +import { useJBContractContext, useReadJbTokensTokenOf, useWriteJbControllerMintTokensOf } from 'juice-sdk-react' +import { wagmiConfig } from 'packages/v4/wagmiConfig' +import { useContext, useState } from 'react' +import { parseWad } from 'utils/format/formatNumber' +import { emitErrorNotification } from 'utils/notifications' +import { tokenSymbolText } from 'utils/tokenSymbolText' +import { Address } from 'viem' + +type MintForm = { + beneficary: string + memo: string + amount: string +} + +export function V4MintModal({ + open, + onCancel, + onConfirmed, +}: { + open?: boolean + onCancel?: VoidFunction + onConfirmed?: VoidFunction +}) { + const { writeContractAsync: writeMintTokens } = + useWriteJbControllerMintTokensOf() + const [form] = useForm() + + const [loading, setLoading] = useState() + const [transactionPending, setTransactionPending] = useState() + + const { projectId, contracts } = useJBContractContext() + const { addTransaction } = useContext(TxHistoryContext) + + const { data: tokenAddress } = useReadJbTokensTokenOf() + const { data: tokenSymbol } = useNameOfERC20(tokenAddress) + + async function executeMintTx() { + const formValues = form.getFieldsValue(true) as MintForm + const amount = parseWad(formValues.amount ?? '0').toBigInt() + const memo = formValues.memo + const beneficiary = formValues.beneficary as Address + + if ( + !contracts.controller.data || + !beneficiary || + !amount || + !projectId + ) + return + + setLoading(true) + + const args = [ + projectId, + amount, + beneficiary, + memo, + false, //useReservedPercent + ] as const + + try { + const hash = await writeMintTokens({ + address: contracts.controller.data, + args, + }) + setTransactionPending(true) + + addTransaction?.('Mint tokens', { hash }) + await waitForTransactionReceipt(wagmiConfig, { + hash, + }) + + setLoading(false) + setTransactionPending(false) + onConfirmed?.() + } catch (e) { + setLoading(false) + + emitErrorNotification((e as unknown as Error).message) + } + } + + const tokensTokenLower = tokenSymbolText({ + tokenSymbol, + capitalize: false, + plural: true, + }) + + const tokensTokenUpper = tokenSymbolText({ + tokenSymbol, + capitalize: true, + plural: false, + }) + + return ( + +

Mint new tokens to a specified address.

+ +
+ { + if (!value || !utils.isAddress(value)) + return Promise.reject('Not a valid ETH address') + else return Promise.resolve() + }, + }, + ]} + > + + + { + if (!value || value === '0') { + return Promise.reject('Invalid value') + } + return Promise.resolve() + }, + }, + ]} + required + > + + + + + +
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx index a302700ae0..de2f7b7234 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx @@ -15,9 +15,13 @@ 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 { reloadWindow } from 'utils/windowUtils' import { useChainId } from 'wagmi' +import { useV4BalanceMenuItemsUserFlags } from './hooks/useV4BalanceMenuItemsUserFlags' import { useV4TokensPanel } from './hooks/useV4TokensPanel' import { useV4YourBalanceMenuItems } from './hooks/useV4YourBalanceMenuItems' +import { V4ClaimTokensModal } from './V4ClaimTokensModal' +import { V4MintModal } from './V4MintModal' import { V4ReservedTokensSubPanel } from './V4ReservedTokensSubPanel' export const V4TokensPanel = () => { @@ -31,6 +35,8 @@ export const V4TokensPanel = () => { totalSupply, } = useV4TokensPanel() + const { canMintTokens } = useV4BalanceMenuItemsUserFlags() + const [tokenHolderModalOpen, setTokenHolderModalOpen] = useState(false) const openTokenHolderModal = useCallback( () => setTokenHolderModalOpen(true), @@ -45,10 +51,10 @@ export const V4TokensPanel = () => { items, // redeemModalVisible, // setRedeemModalVisible, - // claimTokensModalVisible, + claimTokensModalVisible, setClaimTokensModalVisible, - // mintModalVisible, - // setMintModalVisible, + mintModalVisible, + setMintModalVisible, // transferUnclaimedTokensModalVisible, // setTransferUnclaimedTokensModalVisible, } = useV4YourBalanceMenuItems() @@ -68,7 +74,7 @@ export const V4TokensPanel = () => { title={t`Your balance`} description={ - {userTokenBalance.format()} tokens + {userTokenBalance.format(8)} tokens
{/* {projectHasErc20Token && (