diff --git a/.github/workflows/ci-run.yml b/.github/workflows/ci-run.yml index 861018613c..245a58e779 100644 --- a/.github/workflows/ci-run.yml +++ b/.github/workflows/ci-run.yml @@ -24,6 +24,7 @@ env: SUBGRAPH_URL: ${{ secrets.SUBGRAPH_URL }} NEXT_PUBLIC_SUBGRAPH_URL: ${{ secrets.NEXT_PUBLIC_SUBGRAPH_URL }} NEXT_PUBLIC_V4_SUBGRAPH_URL: ${{ secrets.NEXT_PUBLIC_V4_SUBGRAPH_URL }} + NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL: ${{ secrets.NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL }} jobs: jest: diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03dc6..a4a7b3f5cf 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/package.json b/package.json index 26a2b85c5e..a3b93e6e65 100644 --- a/package.json +++ b/package.json @@ -107,8 +107,8 @@ "graphql": "^16.8.1", "he": "^1.2.0", "jsonwebtoken": "^9.0.0", - "juice-sdk-core": "^10.0.3-alpha", - "juice-sdk-react": "^10.0.1-alpha", + "juice-sdk-core": "^11.0.0-alpha", + "juice-sdk-react": "^11.0.0-alpha", "juicebox-metadata-helper": "0.1.7", "less": "4.1.2", "lodash": "^4.17.21", diff --git a/src/components/Home/HomepageProjectCard.tsx b/src/components/Home/HomepageProjectCard.tsx index 641175d84d..92db3ac357 100644 --- a/src/components/Home/HomepageProjectCard.tsx +++ b/src/components/Home/HomepageProjectCard.tsx @@ -3,10 +3,11 @@ import { Skeleton } from 'antd' import { HomepageCard } from 'components/Home/HomepageCard' import ProjectLogo from 'components/ProjectLogo' import ETHAmount from 'components/currency/ETHAmount' -import { PV_V2 } from 'constants/pv' +import { PV_V2, PV_V4 } from 'constants/pv' import { useProjectMetadata } from 'hooks/useProjectMetadata' import { SubgraphQueryProject } from 'models/subgraphProjects' import { v2v3ProjectRoute } from 'packages/v2v3/utils/routes' +import { v4ProjectRoute } from 'packages/v4/utils/routes' function Statistic({ name, @@ -44,7 +45,7 @@ export function HomepageProjectCard({ project: Pick< SubgraphQueryProject, 'metadataUri' | 'volume' | 'paymentsCount' | 'handle' | 'pv' | 'projectId' - > + > & { chainId?: number } lazyLoad?: boolean }) { const { data: metadata, isLoading } = useProjectMetadata(project.metadataUri) @@ -52,12 +53,17 @@ export function HomepageProjectCard({ return ( - {metadata.name} - - ) : ( + isLoading ? ( + ) : !metadata ? ( + '---' + ) : ( +
+ {metadata.name} +
) } description={ diff --git a/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx b/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx index ff07259f6b..dbe189de07 100644 --- a/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx +++ b/src/components/Project/ProjectHeader/ProjectHeaderPopupMenu.tsx @@ -1,8 +1,6 @@ import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline' import { Trans } from '@lingui/macro' -import { BookmarkButtonIcon } from 'components/buttons/BookmarkButton/BookmarkButtonIcon' import { useBookmarkButton } from 'components/buttons/BookmarkButton/hooks/useBookmarkButton' -import { SubscribeButtonIcon } from 'components/buttons/SubscribeButton/SubscribeButtonIcon' import { useSubscribeButton } from 'components/buttons/SubscribeButton/hooks/useSubscribeButton' import { PV_V2 } from 'constants/pv' import useMobile from 'hooks/useMobile' @@ -54,42 +52,42 @@ export function ProjectHeaderPopupMenu({ href, })) : []), - { - id: 'subscribe', - label: ( - <> - + // { + // id: 'subscribe', + // label: ( + // <> + // - - Get notifications - - - ), - onClick: onSubscribeButtonClicked, - }, - { - id: 'bookmark', - label: ( - <> - - - Save project - - - ), - onClick(ev) { - ev.preventDefault() - ev.stopPropagation() + // + // Get notifications + // + // + // ), + // onClick: onSubscribeButtonClicked, + // }, + // { + // id: 'bookmark', + // label: ( + // <> + // + // + // Save project + // + // + // ), + // onClick(ev) { + // ev.preventDefault() + // ev.stopPropagation() - onBookmarkButtonClicked() - }, - }, + // onBookmarkButtonClicked() + // }, + // }, { id: 'tools', label: ( diff --git a/src/components/Project/ProjectTabs/utils/pairToDatum.ts b/src/components/Project/ProjectTabs/utils/pairToDatum.ts index ad0aa0429d..23be161fa8 100644 --- a/src/components/Project/ProjectTabs/utils/pairToDatum.ts +++ b/src/components/Project/ProjectTabs/utils/pairToDatum.ts @@ -2,8 +2,8 @@ import { ConfigurationPanelDatum } from 'components/Project/ProjectTabs/CyclesPa export const pairToDatum = ( name: string, - current: string | undefined, - upcoming: string | undefined | null, + current: string | JSX.Element | undefined, + upcoming: string | JSX.Element | undefined | null, link?: string, easyCopy?: boolean, ): ConfigurationPanelDatum => { diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index 259bfda4d9..5b8acc8ff4 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -1,16 +1,16 @@ import * as constants from '@ethersproject/constants' import { BookmarkIcon as BookmarkIconSolid } from '@heroicons/react/24/solid' import { Skeleton } from 'antd' -import { PV_V2 } from 'constants/pv' +import { PV_V2, PV_V4 } from 'constants/pv' import { useProjectHandleText } from 'hooks/useProjectHandleText' -import Link from 'next/link' -import { isHardArchived } from 'utils/archived' -import { formatDate } from 'utils/format/formatDate' - import { useProjectMetadata } from 'hooks/useProjectMetadata' import { useSubtitle } from 'hooks/useSubtitle' import { SubgraphQueryProject } from 'models/subgraphProjects' +import Link from 'next/link' import { v2v3ProjectRoute } from 'packages/v2v3/utils/routes' +import { v4ProjectRoute } from 'packages/v4/utils/routes' +import { isHardArchived } from 'utils/archived' +import { formatDate } from 'utils/format/formatDate' import { ArchivedBadge } from './ArchivedBadge' import Loading from './Loading' import ProjectLogo from './ProjectLogo' @@ -21,7 +21,7 @@ export default function ProjectCard({ project, bookmarked, }: { - project?: SubgraphQueryProject + project?: SubgraphQueryProject & { chainId?: number } bookmarked?: boolean }) { const { data: metadata } = useProjectMetadata(project?.metadataUri) @@ -29,12 +29,11 @@ export default function ProjectCard({ handle: project?.handle, projectId: project?.projectId, }) - const subtitle = useSubtitle(metadata) if (!project) return null - const { volume, pv, handle, projectId, createdAt } = project + const { volume, pv, handle, projectId, createdAt, chainId } = project const tags = metadata?.tags // If the total paid is greater than 0, but less than 10 ETH, show two decimal places. @@ -58,7 +57,12 @@ export default function ProjectCard({ : `/p/${handle}` const projectCardUrl = - pv === PV_V2 + pv === PV_V4 && chainId + ? v4ProjectRoute({ + projectId, + chainId, + }) + : pv === PV_V2 ? v2v3ProjectRoute({ projectId, handle, diff --git a/src/components/Projects/ProjectsFilterAndSort.tsx b/src/components/Projects/ProjectsFilterAndSort.tsx index 29cdbb4123..3abd96435c 100644 --- a/src/components/Projects/ProjectsFilterAndSort.tsx +++ b/src/components/Projects/ProjectsFilterAndSort.tsx @@ -21,8 +21,13 @@ export type CheckboxOnChange = (checked: boolean) => void export default function ProjectsFilterAndSort({ includeV1, setIncludeV1, + includeV2, setIncludeV2, + + includeV4, + setIncludeV4, + showArchived, setShowArchived, searchTags, @@ -34,8 +39,13 @@ export default function ProjectsFilterAndSort({ }: { includeV1: boolean setIncludeV1: CheckboxOnChange + includeV2: boolean setIncludeV2: CheckboxOnChange + + includeV4: boolean + setIncludeV4: CheckboxOnChange + showArchived: boolean setShowArchived: CheckboxOnChange searchTags: ProjectTagName[] @@ -67,10 +77,7 @@ export default function ProjectsFilterAndSort({ return (
+ ('volume') const [includeV1, setIncludeV1] = useState(true) const [includeV2, setIncludeV2] = useState(true) + const [includeV4, setIncludeV4] = useState(true) const [showArchived, setShowArchived] = useState(false) const [reversed, setReversed] = useState(false) @@ -72,8 +73,10 @@ export function ProjectsView() { const _pv: PV[] = [] if (includeV1) _pv.push(PV_V1) if (includeV2) _pv.push(PV_V2) - return _pv.length ? _pv : [PV_V1, PV_V2] - }, [includeV1, includeV2]) + if (includeV4) _pv.push(PV_V4) + + return _pv.length ? _pv : [PV_V1, PV_V2, PV_V4] + }, [includeV1, includeV2, includeV4]) function updateRoute( _searchTags: ProjectTagName[], @@ -137,8 +140,10 @@ export function ProjectsView() { + > & { chainId?: number } rank: number size?: 'sm' | 'lg' bookmarked?: boolean @@ -62,7 +63,12 @@ export default function TrendingProjectCard({ prefetch={false} key={project.handle} href={ - project.pv === PV_V2 + project.pv === PV_V4 && project.chainId + ? v4ProjectRoute({ + projectId: project.projectId, + chainId: project.chainId, + }) + : project.pv === PV_V2 ? v2v3ProjectRoute(project) : `/p/${project.handle}` } diff --git a/src/components/VolumeChart/hooks/useProjectTimeline.ts b/src/components/VolumeChart/hooks/useProjectTimeline.ts index cc8fea805e..3410c66eae 100644 --- a/src/components/VolumeChart/hooks/useProjectTimeline.ts +++ b/src/components/VolumeChart/hooks/useProjectTimeline.ts @@ -1,12 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { PV_V2, PV_V4 } from 'constants/pv' import { readProvider } from 'constants/readProvider' +import { RomanStormVariables } from 'constants/romanStorm' import EthDater from 'ethereum-block-by-date' -import { - ProjectTlQuery, - useProjectsQuery, - useProjectTlQuery, -} from 'generated/graphql' +import { ProjectTlQuery, useProjectsQuery, useProjectTlQuery } from 'generated/graphql' import { client } from 'lib/apollo/client' import { PV } from 'models/pv' import { ProjectTlDocument } from 'packages/v4/graphql/client/graphql' @@ -15,7 +12,6 @@ import { useMemo } from 'react' import { wadToFloat } from 'utils/format/formatNumber' import { getSubgraphIdForProject } from 'utils/graph' import { daysToMS, minutesToMS } from 'utils/units' -import { RomanStormVariables } from 'constants/romanStorm' import { ProjectTimelinePoint, ProjectTimelineRange } from '../types' @@ -105,13 +101,14 @@ export function useProjectTimeline({ skip: pv === PV_V4, }) + const { data: v4QueryResult } = useSubgraphQuery({ - document: ProjectTlDocument, + document: ProjectTlDocument, variables: { id: blocks ? projectId.toString() : '', ...blocks, }, - enabled: pv === PV_V4, + enabled: pv === PV_V4 }) const points = useMemo(() => { @@ -121,9 +118,7 @@ export function useProjectTimeline({ const points: ProjectTimelinePoint[] = [] for (let i = 0; i < COUNT; i++) { - const point = (queryResult as ProjectTlQuery)[ - `p${i}` as keyof typeof queryResult - ] + const point = (queryResult as ProjectTlQuery)[`p${i}` as keyof typeof queryResult] if (!point) continue if (exceptionTimestamp && exceptionTimestamp > timestamps[i]) { @@ -149,7 +144,7 @@ export function useProjectTimeline({ } return points - }, [timestamps, v1v2v3QueryResult, v4QueryResult, pv]) + }, [timestamps, v1v2v3QueryResult, v4QueryResult, pv, projectId, exceptionTimestamp, romanStormData?.projects]) return { points, diff --git a/src/components/buttons/AddTokenToMetamaskButton.tsx b/src/components/buttons/AddTokenToMetamaskButton.tsx index f21a780758..f6a0a7e3ef 100644 --- a/src/components/buttons/AddTokenToMetamaskButton.tsx +++ b/src/components/buttons/AddTokenToMetamaskButton.tsx @@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro' import type { MetaMaskInpageProvider } from '@metamask/providers' import { Button } from 'antd' import { providers } from 'ethers' -import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' +import useSymbolOfERC20 from 'hooks/ERC20/useSymbolOfERC20' import { twMerge } from 'tailwind-merge' import { Hash } from 'viem' @@ -28,7 +28,7 @@ const useMetamask = () => { function useAddTokenToWalletRequest({ tokenAddress }: { tokenAddress: Hash }) { const ethereum = useMetamask() - const { data: tokenSymbol } = useNameOfERC20(tokenAddress) + const { data: tokenSymbol } = useSymbolOfERC20(tokenAddress) if (!ethereum) { return diff --git a/src/components/inputs/EthAddressInput.tsx b/src/components/inputs/EthAddressInput.tsx index fa88695f25..e99bd0fe4f 100644 --- a/src/components/inputs/EthAddressInput.tsx +++ b/src/components/inputs/EthAddressInput.tsx @@ -48,8 +48,8 @@ export function EthAddressInput({ async (address: string) => { onChange?.(address) - const ensNameForAddress = await resolveAddress(address) - if (ensNameForAddress.name) { + const ensNameForAddress = await resolveAddress(address).catch(() => {}) // noop, ignore errors + if (ensNameForAddress?.name) { setENSName(ensNameForAddress.name) setAddressForENSName(address) } diff --git a/src/components/strings.tsx b/src/components/strings.tsx index 36d781c32f..2d80c61a48 100644 --- a/src/components/strings.tsx +++ b/src/components/strings.tsx @@ -35,6 +35,21 @@ export const CYCLE_EXPLANATION = ( ) +export const RULESET_EXPLANATION = ( + +

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

+

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

+

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

+
+) + export const LOCKED_PAYOUT_EXPLANATION = ( If locked, this payout can't be edited or removed until the lock expires or diff --git a/src/packages/v2v3/constants/juiceboxTokens.ts b/src/constants/juiceboxTokens.ts similarity index 100% rename from src/packages/v2v3/constants/juiceboxTokens.ts rename to src/constants/juiceboxTokens.ts diff --git a/src/lib/api/supabase/projects/api.ts b/src/lib/api/supabase/projects/api.ts index b6e5679c2b..76b6193894 100644 --- a/src/lib/api/supabase/projects/api.ts +++ b/src/lib/api/supabase/projects/api.ts @@ -1,37 +1,65 @@ import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs' import { V2_BLOCKLISTED_PROJECTS } from 'constants/blocklist' +import { PV_V4 } from 'constants/pv' import { DbProjectsDocument, DbProjectsQuery, Project, QueryProjectsArgs, } from 'generated/graphql' + +import { readNetwork } from 'constants/networks' import { paginateDepleteQuery } from 'lib/apollo/paginateDepleteQuery' -import { serverClient } from 'lib/apollo/serverClient' +import { serverClient, v4SepoliaServerClient } from 'lib/apollo/serverClient' import { DBProject, DBProjectQueryOpts, SGSBCompareKey } from 'models/dbProject' import { Json } from 'models/json' import { NextApiRequest, NextApiResponse } from 'next' +import { + Dbv4ProjectsDocument, + Dbv4ProjectsQuery, +} from 'packages/v4/graphql/client/graphql' import { Database } from 'types/database.types' import { isHardArchived } from 'utils/archived' +import { getSubgraphIdForProject } from 'utils/graph' import { formatDBProjectRow, formatSGProjectForDB, parseDBProjectsRow, } from 'utils/sgDbProjects' +import { sepolia } from 'viem/chains' import { dbProjects } from '../clients' /** * Query all projects from subgraph using apollo serverClient which is safe to use in edge runtime. */ export async function queryAllSGProjectsForServer() { - const res = await paginateDepleteQuery({ - client: serverClient, - document: DbProjectsDocument, - }) + const [res, resSepoliaV4] = await Promise.all([ + paginateDepleteQuery({ + client: serverClient, + document: DbProjectsDocument, + }), + paginateDepleteQuery({ + client: v4SepoliaServerClient, + document: Dbv4ProjectsDocument, + }), + ]) // Response must be retyped with Json<>, because the serverClient does not perform the parsing expected by generated types - const _res = res as unknown as Json>[] - - return _res.map(formatSGProjectForDB) + const _res = res.map(p => { + return { + ...p, + chainId: readNetwork.chainId, + } + }) as unknown as Json>[] + const _resSepoliaV4 = resSepoliaV4.map(p => { + return { + ...p, + id: getSubgraphIdForProject(PV_V4, p.projectId), // Patch in the subgraph ID for V4 projects (to be consitent with legacy subgraph) + pv: PV_V4, // Patch in the PV for V4 projects, + chainId: sepolia.id, + } + }) as unknown as Json>[] + + return [..._res, ..._resSepoliaV4].map(formatSGProjectForDB) } /** @@ -93,7 +121,6 @@ export async function queryDBProjects( const pageSize = opts.pageSize ?? 20 // Only sort ascending if orderBy is defined and orderDirection is 'asc' const ascending = opts.orderBy ? opts.orderDirection === 'asc' : false - const searchFilter = createSearchFilter(opts.text) const supabase = createServerSupabaseClient({ req, res }) diff --git a/src/lib/apollo/serverClient.ts b/src/lib/apollo/serverClient.ts index d023d1255e..7cf67db4c4 100644 --- a/src/lib/apollo/serverClient.ts +++ b/src/lib/apollo/serverClient.ts @@ -1,13 +1,21 @@ import { ApolloClient, InMemoryCache } from '@apollo/client' -import { subgraphUri } from './subgraphUri' +import { sepolia } from 'viem/chains' +import { subgraphUri, v4SubgraphUri } from './subgraphUri' /** - * Unlike `client`, `serverClient` is safe to use in the edge runtime. However this client does not perform parsing on the response, meaning returned objects may not match the auto-generated types. + * Unlike `client`, `serverClient` is safe to use in the edge runtime. + * However, this client does not perform parsing on the response, + * meaning returned objects may not match the auto-generated types. */ const serverClient = new ApolloClient({ uri: subgraphUri(), cache: new InMemoryCache(), }) -export { serverClient } +const v4SepoliaServerClient = new ApolloClient({ + uri: v4SubgraphUri(sepolia.id), + cache: new InMemoryCache(), +}) + +export { serverClient, v4SepoliaServerClient } diff --git a/src/lib/apollo/subgraphUri.ts b/src/lib/apollo/subgraphUri.ts index d82b4506a2..cd94b8d3cf 100644 --- a/src/lib/apollo/subgraphUri.ts +++ b/src/lib/apollo/subgraphUri.ts @@ -1,4 +1,6 @@ +import { JBChainId } from 'juice-sdk-react' import { isBrowser } from 'utils/isBrowser' +import { sepolia } from 'viem/chains' export const subgraphUri = () => { let uri: string | undefined @@ -20,5 +22,43 @@ export const subgraphUri = () => { if (url.pathname.match(/graphql$/g)) { return url.href.slice(0, url.href.lastIndexOf('/')) } + + return url.href +} + +export const v4SubgraphUri = (chainId: JBChainId) => { + let uri: string | undefined + + const env: { + [k in JBChainId]?: { + browserUrl?: string + serverUrl?: string + } + } = { + [sepolia.id]: { + browserUrl: process.env.NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL, + serverUrl: process.env.V4_SEPOLIA_SUBGRAPH_URL, + }, + } as const + + if (isBrowser()) { + uri = env?.[chainId]?.browserUrl + if (!uri) { + throw new Error( + 'NEXT_PUBLIC_V4_SUBGRAPH_URL environment variable not defined', + ) + } + } else { + uri = env?.[chainId]?.serverUrl + if (!uri) { + throw new Error('V4_SUBGRAPH_URL environment variable not defined') + } + } + + const url = new URL(uri) + if (url.pathname.match(/graphql$/g)) { + return url.href.slice(0, url.href.lastIndexOf('/')) + } + return url.href } diff --git a/src/locales/messages.pot b/src/locales/messages.pot index 1dfce92901..08f3ebe39c 100644 --- a/src/locales/messages.pot +++ b/src/locales/messages.pot @@ -92,9 +92,6 @@ msgstr "" msgid "Claim {tokensLabel} as ERC-20" msgstr "" -msgid "Save project" -msgstr "" - msgid "Total issuance" msgstr "" @@ -179,6 +176,9 @@ msgstr "" msgid "Payout recipients:" msgstr "" +msgid "Your project's first ruleset will start on <0>{0} at {1}. Your project will be visible on <1>juicebox.money once you finish setting your project up, but supporters won't be able to pay or interact with it until the first ruleset begins." +msgstr "" + msgid "Lock until" msgstr "" @@ -224,6 +224,9 @@ msgstr "" msgid "Error downloading participants, try again." msgstr "" +msgid "Edit next ruleset" +msgstr "" + msgid "Set a future date & time to start your project's first cycle." msgstr "" @@ -437,6 +440,9 @@ msgstr "" msgid "Payer issuance rate" msgstr "" +msgid "None of your project's ETH can be paid out. All ETH will stay in your project for token redemptions or use in future rulesets." +msgstr "" + msgid "Project Details" msgstr "" @@ -542,6 +548,9 @@ msgstr "" msgid "This cycle has upcoming changes" msgstr "" +msgid "<0/> Your project's rules cannot be edited during the first ruleset." +msgstr "" + msgid "While enabled, the project owner can change the project's <0>payment terminals at any time." msgstr "" @@ -557,6 +566,9 @@ msgstr "" msgid "You would receive <0/>" msgstr "" +msgid "Simple token rules that will work for most projects. You can edit these rules in future rulesets." +msgstr "" + msgid "The issuance reduction rate is disabled if you are using unlocked cycles (because they have no duration)." msgstr "" @@ -833,6 +845,9 @@ msgstr "" msgid "Automated" msgstr "" +msgid "Ruleset" +msgstr "" + msgid "Back to settings" msgstr "" @@ -1046,6 +1061,9 @@ msgstr "" msgid "No overflow" msgstr "" +msgid "A fixed amount of ETH can be paid out from your project each ruleset. You can send specific ETH amounts (or ETH amounts based on USD values) to one or more recipients. Any remaining ETH will stay in your project for token redemptions or use in future rulesets." +msgstr "" + msgid "Export tokens CSV" msgstr "" @@ -1181,6 +1199,9 @@ msgstr "" msgid "No results" msgstr "" +msgid "The project's owner can edit the project's rules and start new rulesets at any time." +msgstr "" + msgid "ETH transfers to project" msgstr "" @@ -1202,6 +1223,9 @@ msgstr "" msgid "Fee from <0><1/>" msgstr "" +msgid "Reserved percent" +msgstr "" + msgid "Later" msgstr "" @@ -1214,6 +1238,9 @@ msgstr "" msgid "{receivedTokenSymbolText} Token" msgstr "" +msgid "Ruleset #1 starts when you create your project. With unlocked rulesets, you can edit your project's rules at any time. This gives you more flexibility, but may appear risky to supporters. Switching to locked rulesets will help you build supporter confidence." +msgstr "" + msgid "Issuance reduction rate:" msgstr "" @@ -1286,6 +1313,9 @@ msgstr "" msgid "Migrate payment terminal" msgstr "" +msgid "Failed to create ERC20 token: {0}" +msgstr "" + msgid "Payments" msgstr "" @@ -1397,6 +1427,9 @@ msgstr "" msgid "What do we value?" msgstr "" +msgid "Ruleset configuration" +msgstr "" + msgid "Add a brief one-sentence summary of your project." msgstr "" @@ -1418,6 +1451,9 @@ msgstr "" msgid "<0>Juicebox is a <1>governance-minimal protocol. There are only a few levers that can be tuned, none of which impose changes for users without their consent. The Juicebox governance smart contract can adjust these levers.<2>The Juicebox protocol is governed by a community of JBX token holders who vote on proposals fortnightly.<3>Juicebox is on-chain and non-custodial. Project creators actually own their projects, and JuiceboxDAO has no way to access project's ETH or change their rules." msgstr "" +msgid "<0>With unlocked rulesets, you can edit your project's rules at any time.<1>With locked rulesets, you can lock your project's rules for a period of time (like 3 minutes, 2 years, or 14 days), helping you build trust with your supporters.<2>This choice isn't permanent — you can switch between locked and unlocked rulesets in the future." +msgstr "" + msgid "Pay {projectTitle}" msgstr "" @@ -1784,6 +1820,9 @@ msgstr "" msgid "Created project" msgstr "" +msgid "Next ruleset, the project will issue {0} tokens per 1 ETH. The ruleset after that, the project will issue {1} tokens per 1 ETH." +msgstr "" + msgid "An address is required" msgstr "" @@ -1802,6 +1841,9 @@ msgstr "" msgid "Project rules" msgstr "" +msgid "Set a duration for locked rulesets." +msgstr "" + msgid "Upload" msgstr "" @@ -2105,6 +2147,9 @@ msgstr "" msgid "Reset website" msgstr "" +msgid "Decay percent" +msgstr "" + msgid "Payout allocated to this project's {versionName} payment terminal. <0>Learn more." msgstr "" @@ -2420,6 +2465,9 @@ msgstr "" msgid "While enabled, the project owner can change the project's <0>controller at any time." msgstr "" +msgid "<0>With Locked Rulesets, your project's rules are locked for a period of time.<1><2>This helps build trust with your contributors." +msgstr "" + msgid "Ruleset #" msgstr "" @@ -2501,6 +2549,9 @@ msgstr "" msgid "Disclose any details to your contributors before they pay your project." msgstr "" +msgid "The issuance rate is reduced by this percentage every ruleset (every <0>{0}). The higher this rate, the more incentive to pay this project earlier." +msgstr "" + msgid "One or more reserved token recipients" msgstr "" @@ -2615,6 +2666,9 @@ msgstr "" msgid "Your edits will take effect in <0>cycle #{0}. The current cycle (#{currentFCNumber}) won't be altered." msgstr "" +msgid "Leave this blank to start your first ruleset immediately after you finish setting up your project." +msgstr "" + msgid "All of this project's ETH will be paid out. Token holders will receive <0>no ETH when redeeming their tokens." msgstr "" @@ -2642,6 +2696,9 @@ msgstr "" msgid "Give permissions to {0} on project #{projectId}" msgstr "" +msgid "Pay out ETH from your project to any Ethereum wallet or Juicebox project. ETH which <0>isn't paid out will be available for token redemptions, or for use in future rulesets. Payouts reset each ruleset." +msgstr "" + msgid "<0>Juicebox has had <1>multiple security audits, and has handled tens of thousands of ETH through its protocol.<2>However, Juicebox is still experimental software. Although the Juicebox contract team have done their part to shape the smart contracts for public use and have tested the code thoroughly, the risk of exploits is never 0%.<3>Due to their public nature, any exploits to the contracts may have irreversible consequences, including loss of ETH. Please use Juicebox with caution.<4><5>Learn more about the risks." msgstr "" @@ -2750,9 +2807,6 @@ msgstr "" msgid "We've disabled payments because the project has opted to reserve 100% of new tokens. You would receive no tokens from your payment." msgstr "" -msgid "Get notifications" -msgstr "" - msgid "Unarchiving your project has the following effects:" msgstr "" @@ -2855,6 +2909,9 @@ msgstr "" msgid "Paying another Juicebox project may mint its tokens. Select an address to receive these tokens." msgstr "" +msgid "Set a future date & time to start your project's first ruleset." +msgstr "" + msgid "Get help planning or setting up my project." msgstr "" @@ -3041,6 +3098,9 @@ msgstr "" msgid "Unwatch" msgstr "" +msgid "V4" +msgstr "" + msgid "Locked until <0>{value}" msgstr "" @@ -3071,6 +3131,9 @@ msgstr "" msgid "Connect wallet to deploy" msgstr "" +msgid "Unlocked Rulesets" +msgstr "" + msgid "Your wallet isn't allowed to process held fees." msgstr "" @@ -3086,6 +3149,9 @@ msgstr "" msgid "Check User Wallet Address" msgstr "" +msgid "Rulesets" +msgstr "" + msgid "Set ENS text record for {ensName}" msgstr "" @@ -3140,6 +3206,9 @@ msgstr "" msgid "Yes, start over" msgstr "" +msgid "Ruleset #1 starts when you create your project. With locked rulesets, if you edit your project's rules during Ruleset #1, those edits will be <0>queued for the next ruleset." +msgstr "" + msgid "All {tokensText} will go to the project owner:" msgstr "" @@ -3314,6 +3383,9 @@ msgstr "" msgid "{0} is not a valid integer" msgstr "" +msgid "Rulesets & Payouts" +msgstr "" + msgid "The unallocated portion of your total will go to the wallet that owns the project by default." msgstr "" @@ -3431,6 +3503,9 @@ msgstr "" msgid "No changes" msgstr "" +msgid "After {0} (your first ruleset), your project will not issue any tokens unless you edit the issuance rate." +msgstr "" + msgid "Project ENS name" msgstr "" @@ -3446,6 +3521,9 @@ msgstr "" msgid "After {0} (your first cycle), your project will not issue any tokens unless you edit the issuance rate." msgstr "" +msgid "In other words: instead of taking effect immediately, those edits will take effect when the next ruleset starts (Ruleset #2). If you need more flexibility, switch to unlocked rulesets." +msgstr "" + msgid "New NFTs will available on your project page shortly." msgstr "" @@ -3560,6 +3638,9 @@ msgstr "" msgid "Redeem {tokensLabel} for ETH" msgstr "" +msgid "Locked Rulesets" +msgstr "" + msgid "Made a mistake?" msgstr "" @@ -3737,6 +3818,9 @@ msgstr "" msgid "DeFi" msgstr "" +msgid "Make changes to your ruleset settings and rules" +msgstr "" + msgid "While enabled, this project will use the custom behavior defined in the contract above when somebody redeems from this project. Exercise caution." msgstr "" @@ -4223,6 +4307,9 @@ msgstr "" msgid "The maximum supply of this NFT in circulation." msgstr "" +msgid "Each ruleset, the project will issue {discountRate}% fewer tokens per ETH." +msgstr "" + msgid "Payout and reserved token recipients cannot exceed 100%" msgstr "" @@ -4301,6 +4388,9 @@ msgstr "" msgid "End" msgstr "" +msgid "The issuance reduction rate is disabled if you are using unlocked rulesets (because they have no duration)." +msgstr "" + msgid "Message sent!" msgstr "" diff --git a/src/models/dbProject.ts b/src/models/dbProject.ts index 5deb193a58..91a07158b4 100644 --- a/src/models/dbProject.ts +++ b/src/models/dbProject.ts @@ -4,8 +4,8 @@ import { Database } from 'types/database.types' import { Project } from 'generated/graphql' import { ProjectTagName } from './project-tags' import { PV } from './pv' - -export type SGSBCompareKey = Extract +type P = Project & { chainId: number } +export type SGSBCompareKey = Extract /** * @param text Text to use for string search @@ -36,6 +36,7 @@ export type DBProject = { projectId: number createdAt: number pv: PV + chainId: number handle: string | null metadataUri: string | null diff --git a/src/packages/v1/components/V1Project/TokensSection.tsx b/src/packages/v1/components/V1Project/TokensSection.tsx index 8bdd9884ba..6a7ec6d507 100644 --- a/src/packages/v1/components/V1Project/TokensSection.tsx +++ b/src/packages/v1/components/V1Project/TokensSection.tsx @@ -3,7 +3,6 @@ import { Button, Descriptions, Space, Statistic } from 'antd' import { IssueErc20TokenButton } from 'components/buttons/IssueErc20TokenButton' import EthereumAddress from 'components/EthereumAddress' import ManageTokensModal from 'components/modals/ManageTokensModal' -import ParticipantsModal from 'components/modals/ParticipantsModal' import SectionHeader from 'components/SectionHeader' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { BigNumber } from 'ethers' @@ -19,6 +18,7 @@ import { useV1UnclaimedBalance } from 'packages/v1/hooks/contractReader/useV1Unc import { useTransferTokensTx } from 'packages/v1/hooks/transactor/useTransferTokensTx' import { V1OperatorPermission } from 'packages/v1/models/permissions' import { decodeFundingCycleMetadata } from 'packages/v1/utils/fundingCycle' +import ParticipantsModal from 'packages/v2v3/components/V2V3Project/modals/ParticipantsModal' import { CSSProperties, useContext, useState } from 'react' import { isZeroAddress } from 'utils/address' import { formatPercent, formatWad } from 'utils/format/formatNumber' diff --git a/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx b/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx index bc568c0773..9668a3c2d6 100644 --- a/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx +++ b/src/packages/v2v3/components/Create/components/Wizard/PageButtonControl/PageButtonControl.tsx @@ -1,7 +1,7 @@ +import { Tooltip } from 'antd' import { useContext } from 'react' import { PageContext } from '../contexts/PageContext' import { BackButton } from './components/BackButton' -import { DoneButton } from './components/DoneButton' import { NextButton } from './components/NextButton' export const PageButtonControl = ({ @@ -27,12 +27,15 @@ export const PageButtonControl = ({ onClick={onPageDone} /> ) : ( - + +
Launch project
+ {/* */} +
)}
diff --git a/src/packages/v2v3/components/Create/hooks/DeployProject/useDeployProject.ts b/src/packages/v2v3/components/Create/hooks/DeployProject/useDeployProject.ts index cb09e58ba1..fa363ac619 100644 --- a/src/packages/v2v3/components/Create/hooks/DeployProject/useDeployProject.ts +++ b/src/packages/v2v3/components/Create/hooks/DeployProject/useDeployProject.ts @@ -56,7 +56,7 @@ const getProjectIdFromLaunchReceipt = ( * Attempt to find the transaction receipt from a transaction hash. * Will retry up to 5 times with a 2 second delay between each attempt. If no - * receipt is found after 5 attempts, undefined is returned. + * receipt is not found after 5 attempts, undefined is returned. * * @param txHash transaction hash * @returns transaction receipt or undefined diff --git a/src/packages/v2v3/components/Create/hooks/useLoadInitialStateFromQuery.ts b/src/packages/v2v3/components/Create/hooks/useLoadInitialStateFromQuery.ts index ae6e947017..a2d3097086 100644 --- a/src/packages/v2v3/components/Create/hooks/useLoadInitialStateFromQuery.ts +++ b/src/packages/v2v3/components/Create/hooks/useLoadInitialStateFromQuery.ts @@ -1,10 +1,10 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import isEqual from 'lodash/isEqual' import { CreatePage } from 'models/createPage' import { ProjectTokensSelection } from 'models/projectTokenSelection' import { TreasurySelection } from 'models/treasurySelection' import { useRouter } from 'next/router' import { ballotStrategiesFn } from 'packages/v2v3/constants/ballotStrategies' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { useDefaultJBETHPaymentTerminal } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBETHPaymentTerminal' import { MAX_DISTRIBUTION_LIMIT } from 'packages/v2v3/utils/math' import { useEffect, useState } from 'react' diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistorySubPanel.test.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistorySubPanel.test.tsx deleted file mode 100644 index 49e3116fd3..0000000000 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/CyclesPayoutsPanel/components/HistorySubPanel.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @jest-environment jsdom - */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { render, screen } from '@testing-library/react' -import { BigNumber } from 'ethers' -import { FundingCyclesQuery } from 'generated/graphql' -import { usePastFundingCycles } from '../hooks/usePastFundingCycles' -import { HistorySubPanel } from './HistorySubPanel' - -jest.mock('../hooks/usePastFundingCycles') -jest.mock('@headlessui/react', () => { - return { - __esModule: true, - Disclosure: jest.requireActual('@headlessui/react').Disclosure, - Transition: jest - .fn() - .mockImplementation(({ children, show }) => ( -
{show && children}
- )), - } -}) -jest.mock('./HistoricalConfigurationPanel', () => { - return { - __esModule: true, - HistoricalConfigurationPanel: jest - .fn() - .mockImplementation(({ fundingCycle, metadata }) => ( -
{JSON.stringify(metadata)}
- )), - } -}) - -describe('HistorySubPanel', () => { - const mockFundingCycles: FundingCyclesQuery['fundingCycles'] = [ - { - ballot: '0x4b9f876c7fc5f6def8991fde639b2c812a85fb12', - ballotRedemptionRate: 6000, - basedOn: 1685615915, - burnPaused: false, - configuration: BigNumber.from('1686266495'), - controllerMigrationAllowed: true, - dataSource: '0x0000000000000000000000000000000000000000', - discountRate: BigNumber.from('15000000'), - distributionsPaused: false, - duration: 604800, - id: '2-397-37', - metadata: BigNumber.from('453635417129768049443073'), - metametadata: 0, - mintingAllowed: false, - mustStartAtOrAfter: null, - number: 37, - pausePay: false, - preferClaimedTokenOverride: false, - projectId: 397, - redeemPaused: false, - redemptionRate: 6000, - reservedRate: 5000, - setControllerAllowed: false, - setTerminalsAllowed: true, - shouldHoldFees: false, - startTimestamp: 1694997023, - terminalMigrationAllowed: true, - transfersPaused: false, - useDataSourceForPay: false, - useDataSourceForRedeem: false, - useTotalOverflowForRedemptions: false, - weight: BigNumber.from('341957057837004498728584'), - withdrawnAmount: BigNumber.from('30779487181046138000000'), - }, - ] - - beforeEach(() => { - ;(usePastFundingCycles as jest.Mock).mockReturnValue({ - loading: false, - data: { - fundingCycles: mockFundingCycles, - }, - error: null, - }) - }) - - it('renders without crashing', () => { - render() - }) - - it('displays correct cycle data', () => { - render() - expect( - screen.getByText(`#${mockFundingCycles[0].number}`), - ).toBeInTheDocument() - }) -}) diff --git a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokenHoldersModal/TokenHoldersModal.tsx b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokenHoldersModal/TokenHoldersModal.tsx index 2ee4b5018c..8194368674 100644 --- a/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokenHoldersModal/TokenHoldersModal.tsx +++ b/src/packages/v2v3/components/V2V3Project/ProjectDashboard/components/TokenHoldersModal/TokenHoldersModal.tsx @@ -1,4 +1,4 @@ -import ParticipantsModal from 'components/modals/ParticipantsModal' +import ParticipantsModal from 'packages/v2v3/components/V2V3Project/modals/ParticipantsModal' import { useProjectContext } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectContext' // TODO: This is hacked together - we should consider rebuilding diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts index e5a7cb07bc..d2a9075db0 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/hooks/useInitialEditingData.ts @@ -1,9 +1,9 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { ETH_PAYOUT_SPLIT_GROUP, RESERVED_TOKEN_SPLIT_GROUP, } from 'constants/splits' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ContractsContext } from 'packages/v2v3/contexts/Contracts/V2V3ContractsContext' import { NftRewardsContext } from 'packages/v2v3/contexts/NftRewards/NftRewardsContext' import { V2V3ProjectContext } from 'packages/v2v3/contexts/Project/V2V3ProjectContext' diff --git a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/usePrepareSaveEditCycleData.tsx b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/usePrepareSaveEditCycleData.tsx index 54a83857fc..4153ce30b5 100644 --- a/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/usePrepareSaveEditCycleData.tsx +++ b/src/packages/v2v3/components/V2V3Project/V2V3ProjectSettings/pages/EditCyclePage/hooks/usePrepareSaveEditCycleData.tsx @@ -1,5 +1,5 @@ import { BigNumber } from '@ethersproject/bignumber' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { V2V3FundAccessConstraint, diff --git a/src/components/modals/DownloadParticipantsModal.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/DownloadParticipantsModal.tsx similarity index 100% rename from src/components/modals/DownloadParticipantsModal.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/DownloadParticipantsModal.tsx diff --git a/src/components/modals/ParticipantsModal/HoldersList.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/HoldersList.tsx similarity index 98% rename from src/components/modals/ParticipantsModal/HoldersList.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/HoldersList.tsx index 104ae4a9e4..34b5424d81 100644 --- a/src/components/modals/ParticipantsModal/HoldersList.tsx +++ b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/HoldersList.tsx @@ -21,7 +21,7 @@ import { PV } from 'models/pv' import { useState } from 'react' import { formatPercent } from 'utils/format/formatNumber' import { tokenSymbolText } from 'utils/tokenSymbolText' -import { DownloadParticipantsModal } from '../DownloadParticipantsModal' +import { DownloadParticipantsModal } from './DownloadParticipantsModal' interface ParticipantOption { label: string diff --git a/src/components/TokenDistributionChart/TokenAreaChart.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/TokenAreaChart.tsx similarity index 100% rename from src/components/TokenDistributionChart/TokenAreaChart.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/TokenAreaChart.tsx diff --git a/src/components/TokenDistributionChart/TokenPieChart.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/TokenPieChart.tsx similarity index 100% rename from src/components/TokenDistributionChart/TokenPieChart.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/TokenPieChart.tsx diff --git a/src/components/TokenDistributionChart/index.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/index.tsx similarity index 100% rename from src/components/TokenDistributionChart/index.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart/index.tsx diff --git a/src/components/modals/ParticipantsModal/index.tsx b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/index.tsx similarity index 87% rename from src/components/modals/ParticipantsModal/index.tsx rename to src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/index.tsx index 6d8d03aeb9..99db1d5b18 100644 --- a/src/components/modals/ParticipantsModal/index.tsx +++ b/src/packages/v2v3/components/V2V3Project/modals/ParticipantsModal/index.tsx @@ -9,16 +9,16 @@ import { isZeroAddress } from 'utils/address' import { tokenSymbolText } from 'utils/tokenSymbolText' import { useQuery } from '@tanstack/react-query' -import TokenDistributionChart from 'components/TokenDistributionChart' import { OrderDirection, - Participant_OrderBy, - ParticipantsDocument, ParticipantsQuery, QueryParticipantsArgs, + Participant_OrderBy as V1V2V3Participant_OrderBy, + ParticipantsDocument as V1V2V3ParticipantsDocument, } from 'generated/graphql' import { client } from 'lib/apollo/client' import { paginateDepleteQuery } from 'lib/apollo/paginateDepleteQuery' +import TokenDistributionChart from 'packages/v2v3/components/V2V3Project/modals/ParticipantsModal/TokenDistributionChart' import HoldersList from './HoldersList' export default function ParticipantsModal({ @@ -41,20 +41,19 @@ export default function ParticipantsModal({ queryFn: () => paginateDepleteQuery({ client, - document: ParticipantsDocument, + document: V1V2V3ParticipantsDocument, variables: { orderDirection: OrderDirection.desc, - orderBy: Participant_OrderBy.balance, + orderBy: V1V2V3Participant_OrderBy.balance, where: { projectId, pv, - balance_gt: BigNumber.from(0), wallet_not: constants.AddressZero, }, - }, + } }), staleTime: 5 * 60 * 1000, // 5 min - enabled: Boolean(projectId && pv && open), + enabled: Boolean(projectId && open), }) return ( diff --git a/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts b/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts index c28f7ef2e0..cbbeba406d 100644 --- a/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts +++ b/src/packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx.ts @@ -20,7 +20,7 @@ import { useJBPrices } from 'packages/v2v3/hooks/JBPrices' import { DEFAULT_JB_721_DELEGATE_VERSION } from 'packages/v2v3/hooks/defaultContracts/useDefaultJB721Delegate' import { useDefaultJBController } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBController' import { useDefaultJBETHPaymentTerminal } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBETHPaymentTerminal' -import { LaunchProjectData } from 'packages/v2v3/hooks/transactor/useLaunchProjectTx' +import { LaunchV2V3ProjectData } from 'packages/v2v3/hooks/transactor/useLaunchProjectTx' import { useV2ProjectTitle } from 'packages/v2v3/hooks/useProjectTitle' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' import { @@ -61,7 +61,7 @@ interface JB721DelegateLaunchFundingCycleData { interface LaunchProjectWithNftsTxArgs { tiered721DelegateData: DeployTiered721DelegateData - projectData: LaunchProjectData + projectData: LaunchV2V3ProjectData } type JB721DelegateLaunchProjectData = JB721DelegateLaunchFundingCycleData & { diff --git a/src/packages/v2v3/hooks/contractReader/useProjectDistributionLimit.ts b/src/packages/v2v3/hooks/contractReader/useProjectDistributionLimit.ts index 8654993b24..2bba1d8a25 100644 --- a/src/packages/v2v3/hooks/contractReader/useProjectDistributionLimit.ts +++ b/src/packages/v2v3/hooks/contractReader/useProjectDistributionLimit.ts @@ -1,5 +1,5 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { BigNumber } from 'ethers' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { V2V3ContractName } from 'packages/v2v3/models/contracts' import { useContext } from 'react' diff --git a/src/packages/v2v3/hooks/contractReader/useProjectPrimaryEthTerminalAddress.ts b/src/packages/v2v3/hooks/contractReader/useProjectPrimaryEthTerminalAddress.ts index 000b2d67e9..93f34c7b29 100644 --- a/src/packages/v2v3/hooks/contractReader/useProjectPrimaryEthTerminalAddress.ts +++ b/src/packages/v2v3/hooks/contractReader/useProjectPrimaryEthTerminalAddress.ts @@ -1,4 +1,4 @@ -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { V2V3ContractName } from 'packages/v2v3/models/contracts' import useV2ContractReader from './useV2ContractReader' diff --git a/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3.ts b/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3.ts index 9fd936041a..7b597cf2ca 100644 --- a/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3.ts +++ b/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3.ts @@ -1,6 +1,6 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { DEFAULT_MEMO, DEFAULT_METADATA } from 'constants/transactionDefaults' import { BigNumber } from 'ethers' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' export function getAddToBalanceArgsV3({ projectId, diff --git a/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3_1.ts b/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3_1.ts index 06f2c558c8..bf643fad7f 100644 --- a/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3_1.ts +++ b/src/packages/v2v3/hooks/transactor/AddToBalanceTx/useAddToBalanceArgsV3_1.ts @@ -1,6 +1,6 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { DEFAULT_MEMO, DEFAULT_METADATA } from 'constants/transactionDefaults' import { BigNumber } from 'ethers' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' export function getAddToBalanceArgsV3_1({ projectId, diff --git a/src/packages/v2v3/hooks/transactor/useDistributePayouts.ts b/src/packages/v2v3/hooks/transactor/useDistributePayouts.ts index 1c299b93e1..250fac7d28 100644 --- a/src/packages/v2v3/hooks/transactor/useDistributePayouts.ts +++ b/src/packages/v2v3/hooks/transactor/useDistributePayouts.ts @@ -1,4 +1,5 @@ import { t } from '@lingui/macro' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { DEFAULT_MEMO, DEFAULT_METADATA, @@ -8,7 +9,6 @@ import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { TransactionContext } from 'contexts/Transaction/TransactionContext' import { BigNumber } from 'ethers' import { TransactorInstance } from 'hooks/useTransactor' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { PaymentTerminalVersion, diff --git a/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts b/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts index 4a427fc026..83384ddcf6 100644 --- a/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts +++ b/src/packages/v2v3/hooks/transactor/useLaunchProjectTx.ts @@ -21,7 +21,7 @@ import { useContext } from 'react' import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' import { useV2ProjectTitle } from '../useProjectTitle' -export interface LaunchProjectData { +export interface LaunchV2V3ProjectData { projectMetadataCID: string fundingCycleData: V2V3FundingCycleData fundingCycleMetadata: V2V3FundingCycleMetadata @@ -31,7 +31,7 @@ export interface LaunchProjectData { owner?: string // If not provided, the current user's address will be used. } -export function useLaunchProjectTx(): TransactorInstance { +export function useLaunchProjectTx(): TransactorInstance { const { transactor } = useContext(TransactionContext) const { contracts } = useContext(V2V3ContractsContext) const defaultJBController = useDefaultJBController() diff --git a/src/packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx.ts b/src/packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx.ts index e31c3b08b7..94ac58f052 100644 --- a/src/packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx.ts +++ b/src/packages/v2v3/hooks/transactor/usePayETHPaymentTerminalTx.ts @@ -1,11 +1,11 @@ import { t } from '@lingui/macro' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { DEFAULT_MIN_RETURNED_TOKENS } from 'constants/transactionDefaults' import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' import { TransactionContext } from 'contexts/Transaction/TransactionContext' import { BigNumber } from 'ethers' import { TransactorInstance } from 'hooks/useTransactor' import { useProjectIsOFACListed } from 'packages/v2v3/components/V2V3Project/ProjectDashboard/hooks/useProjectIsOFACListed' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { V2V3ProjectContractsContext } from 'packages/v2v3/contexts/ProjectContracts/V2V3ProjectContractsContext' import { useContext } from 'react' import { useV2V3BlockedProject } from '../useBlockedProject' diff --git a/src/packages/v2v3/hooks/transactor/useReconfigureV2V3FundingCycleTx.ts b/src/packages/v2v3/hooks/transactor/useReconfigureV2V3FundingCycleTx.ts index 53f1cec29c..ca4b0c94ac 100644 --- a/src/packages/v2v3/hooks/transactor/useReconfigureV2V3FundingCycleTx.ts +++ b/src/packages/v2v3/hooks/transactor/useReconfigureV2V3FundingCycleTx.ts @@ -8,10 +8,10 @@ import { isValidMustStartAtOrAfter } from 'packages/v2v3/utils/fundingCycle' import { useContext } from 'react' import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' import { useV2ProjectTitle } from '../useProjectTitle' -import { LaunchProjectData } from './useLaunchProjectTx' +import { LaunchV2V3ProjectData } from './useLaunchProjectTx' export type ReconfigureFundingCycleTxParams = Omit< - LaunchProjectData, + LaunchV2V3ProjectData, 'projectMetadataCID' > & { memo?: string diff --git a/src/packages/v4/components/Create/Create.tsx b/src/packages/v4/components/Create/Create.tsx new file mode 100644 index 0000000000..e641cd834c --- /dev/null +++ b/src/packages/v4/components/Create/Create.tsx @@ -0,0 +1,126 @@ +import { t, Trans } from '@lingui/macro' +import { Badge } from 'components/Badge' +import { DeployButtonText } from 'components/buttons/DeployProjectButtonText' +import Loading from 'components/Loading' +import { + RECONFIG_RULES_EXPLANATION, + RULESET_EXPLANATION, +} from 'components/strings' +import { readNetwork } from 'constants/networks' +import { NetworkName } from 'models/networkName' +import { useRouter } from 'next/router' +import { FundingCyclesPage } from './components/pages/FundingCycles/FundingCyclesPage' +import { PayoutsPage } from './components/pages/PayoutsPage/PayoutsPage' +import { ProjectDetailsPage } from './components/pages/ProjectDetails/ProjectDetailsPage' +import { ProjectTokenPage } from './components/pages/ProjectToken/ProjectTokenPage' +import { ReconfigurationRulesPage } from './components/pages/ReconfigurationRules/ReconfigurationRulesPage' +import { DeploySuccess } from './components/pages/ReviewDeploy/components/DeploySuccess' +import { ReviewDeployPage } from './components/pages/ReviewDeploy/ReviewDeployPage' +import { Wizard } from './components/Wizard/Wizard' +import { useLoadingInitialStateFromQuery } from './hooks/useLoadInitialStateFromQuery' + +export function Create() { + const router = useRouter() + const deployedProjectId = router.query.deployedProjectId as string + const initialStateLoading = useLoadingInitialStateFromQuery() + + if (initialStateLoading) return + + if (deployedProjectId) { + const projectId = parseInt(deployedProjectId) + return + } + + return ( +
+

+ + Create a project + Beta + +

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

+ {title} +

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

{description}

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

+ Create a project +

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

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

+

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

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

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

+

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

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

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

+

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

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

+ Configuration rules +

+
+ + + + + + +
+

+ Migration rules +

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

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

+ ) +} + +export const ReviewDeployPage = () => { + useSetCreateFurthestPageReached('reviewDeploy') + const { goToPage } = useContext(WizardContext) + const isMobile = useMobile() + const { chainUnsupported, changeNetworks, isConnected, connect } = useWallet() + const router = useRouter() + const [form] = Form.useForm<{ termsAccepted: boolean }>() + const termsAccepted = Form.useWatch('termsAccepted', form) + const transactionModal = useModal() + const { deployProject, isDeploying, deployTransactionPending } = + useDeployProject() + const nftRewards = useAppSelector( + state => state.editingV2Project.nftRewards.rewardTiers, + ) + + const nftRewardsAreSet = useMemo( + () => nftRewards && nftRewards?.length > 0, + [nftRewards], + ) + + const dispatch = useDispatch() + + const handleStartOverClicked = useCallback(() => { + router.push('/create') + goToPage?.('projectDetails') + dispatch(editingV2ProjectActions.resetState()) + }, [dispatch, goToPage, router]) + + const onFinish = useCallback(async () => { + if (chainUnsupported) { + await changeNetworks() + return + } + if (!isConnected) { + await connect() + return + } + + transactionModal.open() + await deployProject({ + onProjectDeployed: (deployedProjectId: number) => { + router.push({ query: { deployedProjectId } }, '/create', { + shallow: true, + }) + transactionModal.close() + }, + }) + }, [ + chainUnsupported, + changeNetworks, + connect, + deployProject, + isConnected, + router, + transactionModal, + ]) + + const [activeKey, setActiveKey] = useState( + !isMobile ? [ReviewDeployKey.ProjectDetails] : [], + ) + + const handleOnChange = (key: string | string[]) => { + if (typeof key === 'string') { + setActiveKey([parseInt(key)]) + } else { + setActiveKey(key.map(k => parseInt(k))) + } + } + + // Remove the nft rewards panel if there are no nft rewards + useEffect(() => { + if (!nftRewardsAreSet) { + setActiveKey(activeKey.filter(k => k !== ReviewDeployKey.Rewards)) + } + // Only run this effect when the nft rewards are set + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nftRewardsAreSet]) + + const isNextEnabled = termsAccepted + return ( + <> + + + Project Details + + } + > + + + + Rulesets & Payouts + + } + > + + + + Token + + } + > + + + {/* + NFTs + + } + > + + */} + + Other Rules + + } + > + + + +
+ +
+ + + + +
+
+ + + + + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/DeploySuccess.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/DeploySuccess.tsx new file mode 100644 index 0000000000..5adf7380ba --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/DeploySuccess.tsx @@ -0,0 +1,115 @@ +import { ShareAltOutlined, TwitterOutlined } from '@ant-design/icons' +import { ArrowRightIcon } from '@heroicons/react/24/solid' +import { Trans, t } from '@lingui/macro' +import { Button } from 'antd' +import ExternalLink from 'components/ExternalLink' +import { XLButton } from 'components/buttons/XLButton' +import { readNetwork } from 'constants/networks' +import { useWallet } from 'hooks/Wallet' +import { NetworkName } from 'models/networkName' +import Image from "next/legacy/image" +import { useRouter } from 'next/router' +import { v4ProjectRoute } from 'packages/v4/utils/routes' +import { useCallback, useMemo, useState } from 'react' +import { useChainId } from 'wagmi' +import DeploySuccessHero from '/public/assets/images/create-success-hero.webp' + +const NEW_DEPLOY_QUERY_PARAM = 'np' + +export const DeploySuccess = ({ projectId }: { projectId: number }) => { + console.info('Deploy: SUCCESS', projectId) + const router = useRouter() + const { chain } = useWallet() + const chainId = useChainId() + let deployGreeting = t`Your project was successfully created!` + if (chain?.name) { + deployGreeting = t`Your project was successfully created on ${chain.name}!` + } + + const projectRoute = v4ProjectRoute({ projectId, chainId }) + + const [gotoProjectClicked, setGotoProjectClicked] = useState(false) + + /** + * Generate a twitter share link based on the project id. + */ + const twitterShareUrl = useMemo(() => { + const juiceboxUrl = + readNetwork.name === NetworkName.mainnet + ? `https://juicebox.money/v2/p/${projectId}` + : `https://${readNetwork.name}.juicebox.money/v2/p/${projectId}` + + const message = `Check out my project on ${ + chain?.name ? `${chain.name} ` : '' + }Juicebox!\n${juiceboxUrl}` + return `https://twitter.com/intent/tweet?text=${encodeURIComponent( + message, + )}` + }, [chain, projectId]) + + const handleGoToProject = useCallback(() => { + setGotoProjectClicked(true) + router.push( + `${projectRoute}?${NEW_DEPLOY_QUERY_PARAM}=1`, + projectRoute, + ) + }, [router, projectRoute]) + + return ( +
+ Project created successfully image +
+ Congratulations! +
+
+ {deployGreeting} +
+ + + Go to project + + + + + +
+ + + + + + +
+
+ ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx new file mode 100644 index 0000000000..40a6578c03 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/FundingConfigurationReview.tsx @@ -0,0 +1,50 @@ +import { t, Trans } from '@lingui/macro' +import { Tooltip } from 'antd' +import { CreateFlowPayoutsTable } from '../../../PayoutsPage/components/CreateFlowPayoutsTable' +import { ReviewDescription } from '../ReviewDescription' +import { useFundingConfigurationReview } from './hooks/useFundingConfigurationReview' + +export const FundingConfigurationReview = () => { + const { duration, fundingCycles, launchDate } = + useFundingConfigurationReview() + + return ( + <> +
+ {fundingCycles}
} + /> + {duration} + ) : null + } + /> + + {launchDate ? ( + + {launchDate.clone().format('MMMM Do YYYY, h:mma z')} + + ) : ( + Immediately + )} + + } + /> + + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts new file mode 100644 index 0000000000..83b7f6a8e3 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/FundingConfigurationReview/hooks/useFundingConfigurationReview.ts @@ -0,0 +1,75 @@ +import { t } from '@lingui/macro' +import moment from 'moment' +import { useAvailablePayoutsSelections } from 'packages/v2v3/components/Create/components/pages/PayoutsPage/hooks/useAvailablePayoutsSelections' +import { formatFundingCycleDuration } from 'packages/v2v3/components/Create/utils/formatFundingCycleDuration' +import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' +import { useCallback, useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { useEditingDistributionLimit } from 'redux/hooks/useEditingDistributionLimit' +import { useEditingPayoutSplits } from 'redux/hooks/useEditingPayoutSplits' +import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' +import { formatFundingTarget } from 'utils/format/formatFundingTarget' + +export const useFundingConfigurationReview = () => { + const { fundingCycleData, payoutsSelection, mustStartAtOrAfter } = + useAppSelector(state => state.editingV2Project) + const [distributionLimit] = useEditingDistributionLimit() + const [payoutSplits, setPayoutSplits] = useEditingPayoutSplits() + + const fundingCycles = useMemo( + () => (fundingCycleData.duration == '0' ? t`Manual` : t`Automated`), + [fundingCycleData.duration], + ) + + const duration = useMemo( + () => formatFundingCycleDuration(fundingCycleData.duration), + [fundingCycleData.duration], + ) + + const fundingTarget = useMemo( + () => + formatFundingTarget({ + distributionLimitWad: distributionLimit?.amount, + distributionLimitCurrency: distributionLimit?.currency, + }), + [distributionLimit?.amount, distributionLimit?.currency], + ) + + const availableSelections = useAvailablePayoutsSelections() + const selection = useMemo(() => { + const overrideSelection = + availableSelections.size === 1 ? [...availableSelections][0] : undefined + return overrideSelection || payoutsSelection + }, [availableSelections, payoutsSelection]) + + const launchDate = useMemo( + () => + mustStartAtOrAfter && + mustStartAtOrAfter !== DEFAULT_MUST_START_AT_OR_AFTER && + !isNaN(parseFloat(mustStartAtOrAfter)) + ? moment.unix(parseFloat(mustStartAtOrAfter)) + : undefined, + [mustStartAtOrAfter], + ) + + const allocationSplits = useMemo( + () => payoutSplits.map(splitToAllocation), + [payoutSplits], + ) + const setAllocationSplits = useCallback( + (allocations: AllocationSplit[]) => + setPayoutSplits(allocations.map(allocationToSplit)), + [setPayoutSplits], + ) + + return { + selection, + fundingCycles, + duration, + fundingTarget, + allocationSplits, + setAllocationSplits, + launchDate, + } +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectDetailsReview/ProjectDetailsReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectDetailsReview/ProjectDetailsReview.tsx new file mode 100644 index 0000000000..b7b74c3504 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectDetailsReview/ProjectDetailsReview.tsx @@ -0,0 +1,171 @@ +import { Trans, t } from '@lingui/macro' +import EthereumAddress from 'components/EthereumAddress' +import ProjectLogo from 'components/ProjectLogo' +import { ProjectTagsList } from 'components/ProjectTags/ProjectTagsList' +import { RichPreview } from 'components/RichPreview/RichPreview' +import { useWallet } from 'hooks/Wallet' +import { useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { ipfsUriToGatewayUrl } from 'utils/ipfs' +import { wrapNonAnchorsInAnchor } from 'utils/wrapNonAnchorsInAnchor' +import { ReviewDescription } from '../ReviewDescription' + +export const ProjectDetailsReview = () => { + const { userAddress } = useWallet() + const { + projectMetadata: { + description, + discord, + telegram, + logoUri, + coverImageUri, + infoUri, + name, + payDisclosure, + twitter, + projectTagline, + tags, + introVideoUrl, + introImageUri, + softTargetAmount, + softTargetCurrency, + }, + inputProjectOwner, + } = useAppSelector(state => state.editingV2Project) + + const youtubeUrl = useMemo(() => { + if (!introVideoUrl) return undefined + const url = new URL(introVideoUrl) + const videoId = url.searchParams.get('v') + if (!videoId) return undefined + return `https://www.youtube.com/embed/${videoId}` + }, [introVideoUrl]) + + const ownerAddress = inputProjectOwner ?? userAddress + + const wrappedDescription = useMemo(() => { + if (!description) return undefined + return wrapNonAnchorsInAnchor(description) + }, [description]) + + const coverImageSrc = coverImageUri + ? ipfsUriToGatewayUrl(coverImageUri) + : undefined + + const introImageSrc = introImageUri + ? ipfsUriToGatewayUrl(introImageUri) + : undefined + + return ( +
+ {/* START: Top */} + + {name} +
+ } + /> + + {projectTagline} + + ) : null + } + /> + } + /> + {/* END: Top */} + + {/* START: Bottom */} + } + /> + + {twitter} + + ) : null + } + /> + + {discord} + + ) : null + } + /> + + {telegram} + + ) : null + } + /> + + {infoUri} + + ) : null + } + /> + : t`No tags`} + /> + + {payDisclosure} + + ) : null + } + /> + {coverImageSrc ? ( + + } + /> + ) : null} + {/* END: Bottom */} + + ) : ( + Wallet not connected + ) + } + /> + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/ProjectTokenReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/ProjectTokenReview.tsx new file mode 100644 index 0000000000..09ec75dcf9 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/ProjectTokenReview.tsx @@ -0,0 +1,97 @@ +import { t } from '@lingui/macro' +import { ReservedTokensList } from 'packages/v2v3/components/shared/ReservedTokensList' +import { + formatDiscountRate, + formatIssuanceRate, + formatRedemptionRate, + formatReservedRate, +} from 'packages/v2v3/utils/math' +import { formatAmount } from 'utils/format/formatAmount' +import * as ProjectTokenForm from '../../../ProjectToken/hooks/useProjectTokenForm' +import { ReviewDescription } from '../ReviewDescription' +import { useProjectTokenReview } from './hooks/useProjectTokenReview' + +export const ProjectTokenReview = () => { + const { + allocationSplits, + allowTokenMinting, + pauseTransfers, + discountRate, + redemptionRate, + reservedRate, + setAllocationSplits, + weight, + } = useProjectTokenReview() + + return ( +
+ + {formatAmount( + weight + ? formatIssuanceRate(weight) + : ProjectTokenForm.DefaultSettings.initialMintRate, + )} +
+ } + /> + + {formatReservedRate( + reservedRate + ? reservedRate + : ProjectTokenForm.DefaultSettings.reservedTokensPercentage.toString(), + ) + '%'} + + } + /> + + } + /> + + {formatDiscountRate( + discountRate + ? discountRate + : ProjectTokenForm.DefaultSettings.discountRate.toString(), + ) + '%'} + + } + /> + + {formatRedemptionRate( + redemptionRate + ? redemptionRate + : ProjectTokenForm.DefaultSettings.redemptionRate.toString(), + ) + '%'} + + } + /> + {allowTokenMinting}} + /> + + {pauseTransfers}} + /> + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts new file mode 100644 index 0000000000..c5f4b5da21 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ProjectTokenReview/hooks/useProjectTokenReview.ts @@ -0,0 +1,50 @@ +import { AllocationSplit } from 'packages/v2v3/components/shared/Allocation/Allocation' +import { allocationToSplit, splitToAllocation } from 'packages/v2v3/utils/splitToAllocation' +import { useCallback, useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { useEditingReservedTokensSplits } from 'redux/hooks/useEditingReservedTokensSplits' +import { formatEnabled, formatPaused } from 'utils/format/formatBoolean' + +export const useProjectTokenReview = () => { + const { + fundingCycleData: { weight, discountRate }, + fundingCycleMetadata: { + allowMinting, + reservedRate, + redemptionRate, + global, + }, + } = useAppSelector(state => state.editingV2Project) + const [tokenSplits, setTokenSplits] = useEditingReservedTokensSplits() + + const allocationSplits = useMemo( + () => tokenSplits.map(splitToAllocation), + [tokenSplits], + ) + const setAllocationSplits = useCallback( + (allocations: AllocationSplit[]) => + setTokenSplits(allocations.map(allocationToSplit)), + [setTokenSplits], + ) + + const allowTokenMinting = useMemo( + () => formatEnabled(allowMinting), + [allowMinting], + ) + + const pauseTransfers = useMemo( + () => formatPaused(global.pauseTransfers), + [global.pauseTransfers], + ) + + return { + weight, + discountRate, + allowTokenMinting, + pauseTransfers, + reservedRate, + redemptionRate, + allocationSplits, + setAllocationSplits, + } +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ReviewDescription.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ReviewDescription.tsx new file mode 100644 index 0000000000..11604f1d32 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/ReviewDescription.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react' +import { twMerge } from 'tailwind-merge' + +export const ReviewDescription = ({ + className, + title, + desc, + placeholder = '-', +}: { + className?: string + title: ReactNode + desc: ReactNode + placeholder?: ReactNode +}) => { + return ( +
+
+ {title} +
+
{desc ? desc : {placeholder}}
+
+ ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RewardsReview/RewardsReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RewardsReview/RewardsReview.tsx new file mode 100644 index 0000000000..23c08e274d --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RewardsReview/RewardsReview.tsx @@ -0,0 +1,90 @@ +import { t } from '@lingui/macro' +import { RewardsList } from 'components/NftRewards/RewardsList/RewardsList' +import { NftRewardTier } from 'models/nftRewards' +import { useCallback, useMemo } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { editingV2ProjectActions } from 'redux/slices/editingV2Project' +import { formatEnabled } from 'utils/format/formatBoolean' +import { v4 } from 'uuid' +import { ReviewDescription } from '../ReviewDescription' + +export const RewardsReview = () => { + const { + nftRewards: { rewardTiers, flags }, + fundingCycleMetadata, + } = useAppSelector(state => state.editingV2Project) + + const dispatch = useAppDispatch() + + const rewards: NftRewardTier[] = useMemo(() => { + return ( + rewardTiers?.map(t => ({ + id: parseInt(v4()), + name: t.name, + contributionFloor: t.contributionFloor, + description: t.description, + maxSupply: t.maxSupply, + remainingSupply: t.maxSupply, + externalLink: t.externalLink, + fileUrl: t.fileUrl, + beneficiary: t.beneficiary, + reservedRate: t.reservedRate, + votingWeight: t.votingWeight, + })) ?? [] + ) + }, [rewardTiers]) + + const setRewards = useCallback( + (rewards: NftRewardTier[]) => { + dispatch( + editingV2ProjectActions.setNftRewardTiers( + rewards.map(reward => ({ + contributionFloor: reward.contributionFloor, + maxSupply: reward.maxSupply, + remainingSupply: reward.maxSupply, + id: reward.id, + fileUrl: reward.fileUrl, + name: reward.name, + externalLink: reward.externalLink, + description: reward.description, + beneficiary: reward.beneficiary, + reservedRate: reward.reservedRate, + votingWeight: reward.votingWeight, + })), + ), + ) + }, + [dispatch], + ) + + const shouldUseDataSourceForRedeem = useMemo(() => { + return formatEnabled(fundingCycleMetadata.useDataSourceForRedeem) + }, [fundingCycleMetadata.useDataSourceForRedeem]) + + const preventOverspending = useMemo(() => { + return formatEnabled(flags.preventOverspending) + }, [flags.preventOverspending]) + + return ( +
+ +
+ + {shouldUseDataSourceForRedeem} +
+ } + /> + {preventOverspending}
+ } + /> + + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/RulesReview.tsx b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/RulesReview.tsx new file mode 100644 index 0000000000..12ef73ebd2 --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/RulesReview.tsx @@ -0,0 +1,76 @@ +import { t } from '@lingui/macro' +import EthereumAddress from 'components/EthereumAddress' +import { FEATURE_FLAGS } from 'constants/featureFlags' +import { featureFlagEnabled } from 'utils/featureFlags' +import { ReviewDescription } from '../ReviewDescription' +import { useRulesReview } from './hooks/useRulesReview' + +export const RulesReview = () => { + const { + customAddress, + pausePayments, + strategy, + terminalConfiguration, + controllerConfiguration, + terminalMigration, + controllerMigration, + holdFees, + ofac, + } = useRulesReview() + + return ( +
+ + {strategy ? ( + strategy + ) : customAddress ? ( + + ) : ( + '??' + )} +
+ } + /> + {pausePayments}} + /> + {holdFees}} + /> + {terminalConfiguration} + } + /> + {controllerConfiguration} + } + /> + {terminalMigration}} + /> + {controllerMigration} + } + /> + {featureFlagEnabled(FEATURE_FLAGS.OFAC) ? ( + {ofac}} + /> + ) : null} + + ) +} diff --git a/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/hooks/useRulesReview.ts b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/hooks/useRulesReview.ts new file mode 100644 index 0000000000..d80302184d --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/ReviewDeploy/components/RulesReview/hooks/useRulesReview.ts @@ -0,0 +1,66 @@ +import { readNetwork } from 'constants/networks' +import { useAvailableReconfigurationStrategies } from 'packages/v2v3/components/Create/hooks/useAvailableReconfigurationStrategies' +import { useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { + formatAllowed, + formatBoolean, + formatPaused, +} from 'utils/format/formatBoolean' + +export const useRulesReview = () => { + const availableBallotStrategies = useAvailableReconfigurationStrategies( + readNetwork.name, + ) + const { + fundingCycleData: { ballot: customAddress }, + reconfigurationRuleSelection, + fundingCycleMetadata, + projectMetadata, + } = useAppSelector(state => state.editingV2Project) + + const pausePayments = useMemo(() => { + return formatPaused(fundingCycleMetadata.pausePay) + }, [fundingCycleMetadata.pausePay]) + + const terminalConfiguration = useMemo(() => { + return formatAllowed(fundingCycleMetadata.global.allowSetTerminals) + }, [fundingCycleMetadata.global.allowSetTerminals]) + + const controllerConfiguration = useMemo(() => { + return formatAllowed(fundingCycleMetadata.global.allowSetController) + }, [fundingCycleMetadata.global.allowSetController]) + + const terminalMigration = useMemo(() => { + return formatAllowed(fundingCycleMetadata.allowTerminalMigration) + }, [fundingCycleMetadata.allowTerminalMigration]) + + const controllerMigration = useMemo(() => { + return formatAllowed(fundingCycleMetadata.allowControllerMigration) + }, [fundingCycleMetadata.allowControllerMigration]) + + const strategy = useMemo(() => { + return availableBallotStrategies.find( + strategy => strategy.id === reconfigurationRuleSelection, + )?.name + }, [availableBallotStrategies, reconfigurationRuleSelection]) + + const holdFees = useMemo(() => { + return formatBoolean(fundingCycleMetadata.holdFees) + }, [fundingCycleMetadata.holdFees]) + const ofac = useMemo(() => { + return formatBoolean(projectMetadata.projectRequiredOFACCheck) + }, [projectMetadata.projectRequiredOFACCheck]) + + return { + customAddress, + pausePayments, + terminalConfiguration, + controllerConfiguration, + terminalMigration, + controllerMigration, + strategy, + holdFees, + ofac, + } +} diff --git a/src/packages/v4/components/Create/components/pages/hooks/useFormDispatchWatch.ts b/src/packages/v4/components/Create/components/pages/hooks/useFormDispatchWatch.ts new file mode 100644 index 0000000000..2f5fdccdaf --- /dev/null +++ b/src/packages/v4/components/Create/components/pages/hooks/useFormDispatchWatch.ts @@ -0,0 +1,48 @@ +import { ActionCreatorWithPayload } from '@reduxjs/toolkit' +import { FormInstance } from 'antd' +import { useWatch } from 'antd/lib/form/Form' +import isEqual from 'lodash/isEqual' +import { useEffect } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' + +/** + * Watches a form field and updates a redux state when it changes. + */ +export const useFormDispatchWatch = < + FormValues extends Record, + FieldName extends keyof FormValues, + Payload = FormValues[FieldName], +>({ + form, + fieldName, + ignoreUndefined, + currentValue, + dispatchFunction, + formatter, +}: { + form: FormInstance + fieldName: FieldName + ignoreUndefined?: boolean + currentValue?: Payload + dispatchFunction: ActionCreatorWithPayload + formatter: (v: FormValues[FieldName]) => Payload +}) => { + const fieldValue = useWatch(fieldName, form) + const dispatch = useAppDispatch() + + useEffect(() => { + if (ignoreUndefined && fieldValue === undefined) return + + const v = formatter(fieldValue) + if (isEqual(v, currentValue)) return + + dispatch(dispatchFunction(v)) + }, [ + currentValue, + dispatch, + dispatchFunction, + fieldValue, + formatter, + ignoreUndefined, + ]) +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useDeployNftProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useDeployNftProject.ts new file mode 100644 index 0000000000..75ab2342d8 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useDeployNftProject.ts @@ -0,0 +1,137 @@ +import { TransactionCallbacks } from 'models/transaction' +import { useLaunchProjectWithNftsTx } from 'packages/v2v3/hooks/JB721Delegate/transactor/useLaunchProjectWithNftsTx' +import { DEFAULT_JB_721_DELEGATE_VERSION } from 'packages/v2v3/hooks/defaultContracts/useDefaultJB721Delegate' +import { useCallback, useMemo } from 'react' +import { + useAppSelector, + useEditingV2V3FundAccessConstraintsSelector, + useEditingV2V3FundingCycleDataSelector, + useEditingV2V3FundingCycleMetadataSelector, +} from 'redux/hooks/useAppSelector' +import { DEFAULT_NFT_FLAGS } from 'redux/slices/editingV2Project' +import { NFT_FUNDING_CYCLE_METADATA_OVERRIDES } from 'utils/nftFundingCycleMetadataOverrides' +import { buildJB721TierParams } from 'utils/nftRewards' + +/** + * Hook that returns a function that deploys a project with NFT rewards. + + * The distinction is made between standard and NFT projects because the NFT + * project contract uses more gas. + * @returns A function that deploys a project with NFT rewards. + */ +export const useDeployNftProject = () => { + const launchProjectWithNftsTx = useLaunchProjectWithNftsTx() + const { + projectMetadata, + nftRewards, + payoutGroupedSplits, + reservedTokensGroupedSplits, + inputProjectOwner, + mustStartAtOrAfter, + } = useAppSelector(state => state.editingV2Project) + const fundingCycleMetadata = useEditingV2V3FundingCycleMetadataSelector() + const fundingCycleData = useEditingV2V3FundingCycleDataSelector() + const fundAccessConstraints = useEditingV2V3FundAccessConstraintsSelector() + + const collectionName = useMemo( + () => + nftRewards.collectionMetadata.name + ? nftRewards.collectionMetadata.name + : projectMetadata.name, + [nftRewards.collectionMetadata.name, projectMetadata.name], + ) + const collectionSymbol = useMemo( + () => nftRewards.collectionMetadata.symbol ?? '', + [nftRewards.collectionMetadata.symbol], + ) + const nftFlags = useMemo( + () => nftRewards.flags ?? DEFAULT_NFT_FLAGS, + [nftRewards.flags], + ) + const governanceType = nftRewards.governanceType + const currency = nftRewards.pricing.currency + + /** + * Deploy a project with NFT rewards. + * @param metadataCid IPFS CID of the project metadata. + * @param rewardTierCids IPFS CIDs of the reward tiers. + * @param nftCollectionMetadataCid IPFS CID of the NFT collection metadata. + */ + const deployNftProjectCallback = useCallback( + async ({ + metadataCid, + rewardTierCids, + nftCollectionMetadataUri, + + onDone, + onConfirmed, + onCancelled, + onError, + }: { + metadataCid: string + rewardTierCids: string[] + nftCollectionMetadataUri: string + } & TransactionCallbacks) => { + if (!collectionName) throw new Error('No collection name or project name') + if (!(rewardTierCids.length && nftRewards.rewardTiers)) + throw new Error('No NFTs') + + const groupedSplits = [payoutGroupedSplits, reservedTokensGroupedSplits] + const tiers = buildJB721TierParams({ + cids: rewardTierCids, + rewardTiers: nftRewards.rewardTiers, + version: DEFAULT_JB_721_DELEGATE_VERSION, + }) + + return await launchProjectWithNftsTx( + { + tiered721DelegateData: { + collectionUri: nftCollectionMetadataUri, + collectionName, + collectionSymbol, + currency, + governanceType, + tiers, + flags: nftFlags, + }, + projectData: { + owner: inputProjectOwner?.length ? inputProjectOwner : undefined, + projectMetadataCID: metadataCid, + fundingCycleData, + mustStartAtOrAfter, + fundingCycleMetadata: { + ...fundingCycleMetadata, + ...NFT_FUNDING_CYCLE_METADATA_OVERRIDES, + }, + fundAccessConstraints, + groupedSplits, + }, + }, + { + onDone, + onConfirmed, + onCancelled, + onError, + }, + ) + }, + [ + collectionName, + nftRewards.rewardTiers, + currency, + payoutGroupedSplits, + reservedTokensGroupedSplits, + launchProjectWithNftsTx, + collectionSymbol, + governanceType, + inputProjectOwner, + fundingCycleData, + mustStartAtOrAfter, + fundingCycleMetadata, + fundAccessConstraints, + nftFlags, + ], + ) + + return deployNftProjectCallback +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useIsNftProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useIsNftProject.ts new file mode 100644 index 0000000000..5d38d3d9d3 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useIsNftProject.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' + +/** + * Hook that returns whether the project to be deployed is an NFT project. + * @returns Whether the project to be deployed is an NFT project. + */ +export const useIsNftProject = (): boolean => { + const { nftRewards } = useAppSelector(state => state.editingV2Project) + + return useMemo( + () => + Boolean(nftRewards?.rewardTiers && nftRewards?.rewardTiers.length > 0), + [nftRewards.rewardTiers], + ) +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useUploadNftRewards.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useUploadNftRewards.ts new file mode 100644 index 0000000000..5b4193e286 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/NFT/useUploadNftRewards.ts @@ -0,0 +1,62 @@ +import { useCallback } from 'react' +import { useAppSelector } from 'redux/hooks/useAppSelector' +import { + defaultNftCollectionDescription, + pinNftCollectionMetadata, + pinNftRewards, +} from 'utils/nftRewards' + +/** + * Hook that returns a function that uploads NFT rewards to IPFS. + * @returns A function that uploads NFT rewards to IPFS. + */ +export const useUploadNftRewards = () => { + const { + nftRewards, + projectMetadata: { name: projectName, logoUri, infoUri }, + } = useAppSelector(state => state.editingV2Project) + + return useCallback(async () => { + if (!nftRewards?.rewardTiers || !nftRewards?.collectionMetadata) return + + const [rewardTiersCids, nftCollectionMetadataCid] = await Promise.all([ + pinNftRewards(nftRewards.rewardTiers), + pinNftCollectionMetadata({ + collectionName: + nftRewards.collectionMetadata.name ?? + defaultNftCollectionDescription(projectName), + collectionDescription: + nftRewards.collectionMetadata.description ?? + defaultNftCollectionDescription(projectName), + collectionLogoUri: logoUri, + collectionInfoUri: infoUri, + }), + ]) + + if (rewardTiersCids.length !== nftRewards.rewardTiers.length) { + console.error('Failed to upload all NFT tiers', { + rewardTiersCids, + inputRewardTiers: nftRewards.rewardTiers, + }) + throw new Error('Failed to upload all NFT tiers') + } + if (!nftCollectionMetadataCid.length) { + console.error('Failed to upload NFT collection metadata', { + nftCollectionMetadataCid, + inputNftCollectionMetadata: nftRewards.collectionMetadata, + }) + throw new Error('Failed to upload NFT collection metadata') + } + + return { + rewardTiers: rewardTiersCids, + nfCollectionMetadata: nftCollectionMetadataCid, + } + }, [ + infoUri, + logoUri, + nftRewards.collectionMetadata, + nftRewards.rewardTiers, + projectName, + ]) +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts new file mode 100644 index 0000000000..2ec6c7a771 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/hooks/useDeployStandardProject.ts @@ -0,0 +1,68 @@ +import { LaunchTxOpts, useLaunchProjectTx } from 'packages/v4/hooks/useLaunchProjectTx' +import { useCallback } from 'react' +import { + useAppSelector, + useEditingV2V3FundAccessConstraintsSelector, + useEditingV2V3FundingCycleDataSelector, + useEditingV2V3FundingCycleMetadataSelector, +} from 'redux/hooks/useAppSelector' + +/** + * Hook that returns a function that deploys a v4 project. + * + * Takes data from the redux store built for v2v3 projects, data is converted to v4 format in useLaunchProjectTx. + * @returns A function that deploys a project. + */ +export const useDeployStandardProject = () => { + const launchProjectTx = useLaunchProjectTx() + const { + payoutGroupedSplits, + reservedTokensGroupedSplits, + inputProjectOwner, + mustStartAtOrAfter, + } = useAppSelector(state => state.editingV2Project) + const fundingCycleMetadata = useEditingV2V3FundingCycleMetadataSelector() + const fundingCycleData = useEditingV2V3FundingCycleDataSelector() + const fundAccessConstraints = useEditingV2V3FundAccessConstraintsSelector() + + const deployStandardProjectCallback = useCallback( + async ({ + metadataCid, + onTransactionPending, + onTransactionConfirmed, + onTransactionError + }: { + metadataCid: string + } & LaunchTxOpts) => { + const groupedSplits = [payoutGroupedSplits, reservedTokensGroupedSplits] + return await launchProjectTx( + { + owner: inputProjectOwner?.length ? inputProjectOwner : undefined, + projectMetadataCID: metadataCid, + fundingCycleData, + fundingCycleMetadata, + mustStartAtOrAfter, + fundAccessConstraints, + groupedSplits, + }, + { + onTransactionPending, + onTransactionConfirmed, + onTransactionError + }, + ) + }, + [ + payoutGroupedSplits, + reservedTokensGroupedSplits, + launchProjectTx, + inputProjectOwner, + fundingCycleData, + fundingCycleMetadata, + mustStartAtOrAfter, + fundAccessConstraints, + ], + ) + + return deployStandardProjectCallback +} diff --git a/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts b/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts new file mode 100644 index 0000000000..a0de85a48a --- /dev/null +++ b/src/packages/v4/components/Create/hooks/DeployProject/useDeployProject.ts @@ -0,0 +1,166 @@ +import { uploadProjectMetadata } from 'lib/api/ipfs' +import { LaunchTxOpts } from 'packages/v4/hooks/useLaunchProjectTx' +import { useCallback, useState } from 'react' +import { useAppDispatch } from 'redux/hooks/useAppDispatch' +import { + useAppSelector, + useEditingV2V3FundAccessConstraintsSelector, + useEditingV2V3FundingCycleDataSelector, + useEditingV2V3FundingCycleMetadataSelector, +} from 'redux/hooks/useAppSelector' +import { editingV2ProjectActions } from 'redux/slices/editingV2Project' +import { emitErrorNotification } from 'utils/notifications' +import { useDeployStandardProject } from './hooks/useDeployStandardProject' + +const JUICEBOX_DOMAIN = 'juicebox' + +/** + * Hook that returns a function that deploys a project. + * @returns A function that deploys a project. + */ +export const useDeployProject = () => { + const [isDeploying, setIsDeploying] = useState(false) + const [transactionPending, setTransactionPending] = useState() + + // const isNftProject = useIsNftProject() + // const uploadNftRewards = useUploadNftRewards() + // const deployNftProject = useDeployNftProject() + + const deployStandardProject = useDeployStandardProject() + + const { + projectMetadata, + nftRewards: { postPayModal }, + } = useAppSelector(state => state.editingV2Project) + const fundingCycleMetadata = useEditingV2V3FundingCycleMetadataSelector() + const fundingCycleData = useEditingV2V3FundingCycleDataSelector() + const fundAccessConstraints = useEditingV2V3FundAccessConstraintsSelector() + + const dispatch = useAppDispatch() + + const handleDeployFailure = useCallback((error: unknown) => { + console.error(error) + emitErrorNotification(`Error deploying project: ${error}`) + setIsDeploying(false) + setTransactionPending(false) + }, []) + + const operationCallbacks = useCallback( + ( + onProjectDeployed?: (projectId: number) => void, + ): LaunchTxOpts => ({ + onTransactionPending: () => { + console.info('Project transaction executed. Await confirmation...') + setTransactionPending(true) + }, + onTransactionConfirmed: async (hash, projectId) => { + // Reset the project state + dispatch(editingV2ProjectActions.resetState()) + onProjectDeployed?.(projectId) + }, + onTransactionError: error => { + console.error(error) + emitErrorNotification(`Error deploying project: ${error}`) + }, + }), + [dispatch], + ) + + /** + * Deploy a project. + * @param onProjectDeployed Callback to be called when the project is deployed. + * @returns The project ID of the deployed project. + */ + const deployProject = useCallback( + async ({ + onProjectDeployed, + }: { + onProjectDeployed?: (projectId: number) => void + }) => { + setIsDeploying(true) + if ( + !( + projectMetadata.name && + fundingCycleData && + fundingCycleMetadata && + fundAccessConstraints + ) + ) { + setIsDeploying(false) + throw new Error('Error deploying project.') + } + // let nftCids: Awaited> | undefined + // try { + // if (isNftProject) { + // nftCids = await uploadNftRewards() + // } + // } catch (error) { + // handleDeployFailure(error) + // return + // } + + let softTargetAmount: string | undefined + let softTargetCurrency: string | undefined + let domain = JUICEBOX_DOMAIN + + let projectMetadataCid: string | undefined + try { + projectMetadataCid = ( + await uploadProjectMetadata({ + ...projectMetadata, + domain, + softTargetAmount, + softTargetCurrency, + nftPaymentSuccessModal: postPayModal, + }) + ).Hash + } catch (error) { + handleDeployFailure(error) + return + } + + try { + // let tx + // if (isNftProject) { + // tx = await deployNftProject({ + // metadataCid: projectMetadataCid, + // rewardTierCids: nftCids!.rewardTiers, + // nftCollectionMetadataUri: nftCids!.nfCollectionMetadata, + // ...operationCallbacks(onProjectDeployed), + // }) + // } else { + const tx = await deployStandardProject({ + metadataCid: projectMetadataCid, + ...operationCallbacks(onProjectDeployed), + }) + // } + // if (!tx) { + setIsDeploying(false) + setTransactionPending(false) + return + // } + } catch (error) { + handleDeployFailure(error) + return + } + }, + [ + // deployNftProject, + deployStandardProject, + fundAccessConstraints, + fundingCycleData, + fundingCycleMetadata, + handleDeployFailure, + // isNftProject, + operationCallbacks, + postPayModal, + projectMetadata, + // uploadNftRewards, + ], + ) + return { + isDeploying, + deployTransactionPending: transactionPending, + deployProject, + } +} diff --git a/src/packages/v4/components/Create/hooks/useAvailableReconfigurationStrategies.ts b/src/packages/v4/components/Create/hooks/useAvailableReconfigurationStrategies.ts new file mode 100644 index 0000000000..535913f719 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/useAvailableReconfigurationStrategies.ts @@ -0,0 +1,31 @@ +import { NetworkName } from 'models/networkName' +import { ballotStrategiesFn } from 'packages/v2v3/constants/ballotStrategies' +import { ArrayElement } from 'utils/arrayElement' + +export const useAvailableReconfigurationStrategies = (network: NetworkName) => { + const strategies = ballotStrategiesFn({ network }).map(s => + s.id === 'threeDay' + ? { ...s, isDefault: true } + : { ...s, isDefault: false }, + ) + const threeDay = strategies.find(s => s.id === 'threeDay') + const oneDay = strategies.find(s => s.id === 'oneDay') + const sevenDay = strategies.find(s => s.id === 'sevenDay') + const none = strategies.find(s => s.id === 'none') + + if (!threeDay || !oneDay || !sevenDay || !none) { + console.error( + 'Unexpected error occurred - missing field in edit deadlines', + { threeDay, oneDay, sevenDay, none }, + ) + throw new Error( + 'Unexpected error occurred - missing field in edit deadlines', + ) + } + + return [threeDay, oneDay, sevenDay, none] +} + +export type AvailableReconfigurationStrategy = ArrayElement< + ReturnType +> diff --git a/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts new file mode 100644 index 0000000000..efe17f29bb --- /dev/null +++ b/src/packages/v4/components/Create/hooks/useLoadInitialStateFromQuery.ts @@ -0,0 +1,177 @@ +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' +import isEqual from 'lodash/isEqual' +import { CreatePage } from 'models/createPage' +import { ProjectTokensSelection } from 'models/projectTokenSelection' +import { TreasurySelection } from 'models/treasurySelection' +import { useRouter } from 'next/router' +import { ballotStrategiesFn } from 'packages/v2v3/constants/ballotStrategies' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' +import { useEffect, useState } from 'react' +import { useDispatch } from 'react-redux' +import { + DEFAULT_REDUX_STATE, + INITIAL_REDUX_STATE, + editingV2ProjectActions, +} from 'redux/slices/editingV2Project' +import { CreateState, ProjectState } from 'redux/slices/editingV2Project/types' +import { isEqualAddress } from 'utils/address' +import { parseWad } from 'utils/format/formatNumber' +import { zeroAddress } from 'viem' +import { DefaultSettings as DefaultTokenSettings } from '../components/pages/ProjectToken/hooks/useProjectTokenForm' +import { projectTokenSettingsToReduxFormat } from '../utils/projectTokenSettingsToReduxFormat' + +const ReduxDefaultTokenSettings = + projectTokenSettingsToReduxFormat(DefaultTokenSettings) + +const parseCreateFlowStateFromInitialState = ( + initialState: ProjectState, +): CreateState => { + const duration = initialState.fundingCycleData.duration + + let fundingCyclesPageSelection: 'manual' | 'automated' | undefined = undefined + switch (duration) { + case '': + fundingCyclesPageSelection = undefined + break + case '0': + fundingCyclesPageSelection = 'manual' + break + default: + fundingCyclesPageSelection = 'automated' + } + + const distributionLimit = initialState.fundAccessConstraints[0] + ?.distributionLimit + ? parseWad(initialState.fundAccessConstraints[0]?.distributionLimit) + : undefined + + let treasurySelection: TreasurySelection | undefined + + if (distributionLimit === undefined) { + treasurySelection = undefined + } else if (distributionLimit.eq(MAX_PAYOUT_LIMIT)) { + treasurySelection = 'unlimited' + } else if (distributionLimit.eq(0)) { + treasurySelection = 'zero' + } else { + treasurySelection = 'amount' + } + + let projectTokensSelection: ProjectTokensSelection | undefined + const initialTokenData = { + weight: initialState.fundingCycleData.weight, + reservedRate: initialState.fundingCycleMetadata.reservedRate, + reservedTokensGroupedSplits: initialState.reservedTokensGroupedSplits, + discountRate: initialState.fundingCycleData.discountRate, + redemptionRate: initialState.fundingCycleMetadata.redemptionRate, + allowMinting: initialState.fundingCycleMetadata.allowMinting, + } + if (isEqual(initialTokenData, ReduxDefaultTokenSettings)) { + projectTokensSelection = 'default' + } else { + projectTokensSelection = 'custom' + } + + const reconfigurationRuleSelection = + ballotStrategiesFn({}).find(s => + isEqualAddress(s.address, initialState.fundingCycleData.ballot), + )?.id ?? 'threeDay' + + let createFurthestPageReached: CreatePage = 'projectDetails' + if (fundingCyclesPageSelection) { + createFurthestPageReached = 'fundingCycles' + } + if (treasurySelection) { + createFurthestPageReached = 'payouts' + } + if (projectTokensSelection) { + createFurthestPageReached = 'projectToken' + } + if (reconfigurationRuleSelection) { + createFurthestPageReached = 'reconfigurationRules' + } + if ( + fundingCyclesPageSelection && + treasurySelection && + projectTokensSelection && + reconfigurationRuleSelection + ) { + createFurthestPageReached = 'reviewDeploy' + } + + return { + fundingCyclesPageSelection, + treasurySelection, + fundingTargetSelection: undefined, // TODO: Remove + payoutsSelection: undefined, // TODO: Remove + projectTokensSelection, + reconfigurationRuleSelection, + createFurthestPageReached, + createSoftLockPageQueue: undefined, // Not supported, this feature is used only for fully fledged projects. + } +} + +/** + * Load redux state from a URL query parameter. + */ +export function useLoadingInitialStateFromQuery() { + const dispatch = useDispatch() + const router = useRouter() + + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!router.isReady) return + + const { initialState } = router.query + if (!initialState) { + setLoading(false) + return + } + + try { + // TODO we can probably validate this object better in future. + // But worst case, if it's invalid, we'll just ignore it. + const parsedInitialState = JSON.parse( + initialState as string, + ) as ProjectState + const createFlowState = + parseCreateFlowStateFromInitialState(parsedInitialState) + + dispatch( + editingV2ProjectActions.setState({ + ...INITIAL_REDUX_STATE, + ...createFlowState, + ...parsedInitialState, + ...{ + projectMetadata: { + ...DEFAULT_REDUX_STATE.projectMetadata, + ...parsedInitialState.projectMetadata, + }, + fundingCycleMetadata: { + ...DEFAULT_REDUX_STATE.fundingCycleMetadata, + ...parsedInitialState.fundingCycleMetadata, + }, + fundingCycleData: { + ...DEFAULT_REDUX_STATE.fundingCycleData, + ...parsedInitialState.fundingCycleData, + }, + fundAccessConstraints: [ + { + ...DEFAULT_REDUX_STATE.fundAccessConstraints[0], + ...parsedInitialState.fundAccessConstraints[0], + terminal: zeroAddress, // filled later + token: ETH_TOKEN_ADDRESS, + }, + ], + }, + }), + ) + } catch (e) { + console.warn('Error parsing initialState:', e) + } + setLoading(false) + }, [router, dispatch]) + + return loading +} diff --git a/src/packages/v4/components/Create/hooks/useLockPageRulesWrapper.ts b/src/packages/v4/components/Create/hooks/useLockPageRulesWrapper.ts new file mode 100644 index 0000000000..ec4c2120c1 --- /dev/null +++ b/src/packages/v4/components/Create/hooks/useLockPageRulesWrapper.ts @@ -0,0 +1,36 @@ +import { Rule, RuleObject } from 'antd/lib/form' +import { useCallback, useContext } from 'react' +import { PageContext } from '../components/Wizard/contexts/PageContext' + +/** + * A hook that returns a function wrapper that wraps all rules in a array and + * soft locks the page if any of the rules fail. + */ +export const useLockPageRulesWrapper = () => { + const { lockPageProgress, unlockPageProgress } = useContext(PageContext) + + const ruleWrapper = useCallback( + (rule: Rule) => { + return { + ...rule, + validator: async (r: RuleObject, v: unknown) => { + if (typeof rule === 'function') { + throw new Error('Unsupported use of RuleRender') + } + try { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const validator = await rule.validator?.(r, v, () => {}) + unlockPageProgress?.() + return validator + } catch (e) { + lockPageProgress?.() + throw e + } + }, + } + }, + [lockPageProgress, unlockPageProgress], + ) + + return useCallback((rules: Rule[]) => rules.map(ruleWrapper), [ruleWrapper]) +} diff --git a/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts b/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts new file mode 100644 index 0000000000..5bede951b0 --- /dev/null +++ b/src/packages/v4/components/Create/utils/determineAvailablePayoutsSelections.ts @@ -0,0 +1,18 @@ +import { BigNumber } from 'ethers' +import { PayoutsSelection } from 'models/payoutsSelection' +import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' + +export const determineAvailablePayoutsSelections = ( + distributionLimit: BigNumber | undefined, +): Set => { + if (!distributionLimit) { + return new Set() + } + if (distributionLimit.eq(0)) { + return new Set(['amounts']) + } + if (distributionLimit.eq(MAX_PAYOUT_LIMIT)) { + return new Set(['percentages']) + } + return new Set(['amounts', 'percentages']) +} diff --git a/src/packages/v4/components/Create/utils/formatFundingCycleDuration.ts b/src/packages/v4/components/Create/utils/formatFundingCycleDuration.ts new file mode 100644 index 0000000000..93986f4c44 --- /dev/null +++ b/src/packages/v4/components/Create/utils/formatFundingCycleDuration.ts @@ -0,0 +1,31 @@ +import { deriveDurationUnit, secondsToOtherUnit } from 'utils/format/formatTime' + +const formatUnit = ({ unit, plural }: { unit: string; plural: boolean }) => { + let formatted = unit.charAt(0).toUpperCase() + unit.slice(1) + if (formatted.endsWith('s')) { + if (!plural) { + formatted = formatted.slice(0, -1) + } + } + return formatted +} + +export const formatFundingCycleDuration = (duration: string) => { + const durationAsNumber = parseInt(duration) + if (isNaN(durationAsNumber)) return 'No duration' + + const derivedUnit = deriveDurationUnit(durationAsNumber) + const formattedDuration = secondsToOtherUnit({ + duration: durationAsNumber, + unit: derivedUnit, + }) + + const formattedUnit = formatUnit({ + unit: derivedUnit, + plural: formattedDuration > 1, + }) + return `${secondsToOtherUnit({ + duration: durationAsNumber, + unit: derivedUnit, + })} ${formattedUnit}` +} diff --git a/src/packages/v4/components/Create/utils/projectTokenSettingsToReduxFormat.ts b/src/packages/v4/components/Create/utils/projectTokenSettingsToReduxFormat.ts new file mode 100644 index 0000000000..de6f55ff2d --- /dev/null +++ b/src/packages/v4/components/Create/utils/projectTokenSettingsToReduxFormat.ts @@ -0,0 +1,40 @@ +import { + discountRateFrom, + formatIssuanceRate, + redemptionRateFrom, + reservedRateFrom, +} from 'packages/v2v3/utils/math' +import { allocationToSplit } from 'packages/v2v3/utils/splitToAllocation' +import { EMPTY_RESERVED_TOKENS_GROUPED_SPLITS } from 'redux/slices/editingV2Project' +import { ProjectTokensFormProps } from '../components/pages/ProjectToken/hooks/useProjectTokenForm' + +export const projectTokenSettingsToReduxFormat = ( + projectTokenSettings: Required>, +) => { + const weight = formatIssuanceRate(projectTokenSettings.initialMintRate) + const reservedRate = reservedRateFrom( + projectTokenSettings.reservedTokensPercentage, + ).toHexString() + const reservedTokensGroupedSplits = { + ...EMPTY_RESERVED_TOKENS_GROUPED_SPLITS, + splits: projectTokenSettings.reservedTokenAllocation.map(allocationToSplit), + } + const discountRate = discountRateFrom( + projectTokenSettings.discountRate, + ).toHexString() + const redemptionRate = redemptionRateFrom( + projectTokenSettings.redemptionRate, + ).toHexString() + const allowMinting = projectTokenSettings.tokenMinting + const pauseTransfers = projectTokenSettings.pauseTransfers + + return { + weight, + reservedRate, + reservedTokensGroupedSplits, + discountRate, + redemptionRate, + allowMinting, + pauseTransfers, + } +} diff --git a/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx b/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx index d4b77219c0..846dd4cd20 100644 --- a/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx +++ b/src/packages/v4/components/PayoutsTable/hooks/usePayoutsTable.tsx @@ -324,9 +324,11 @@ export const usePayoutsTable = () => { }) : undefined // undefined means DL is infinite - const newSplitPercentPPB = round( - (_amount / (newDistributionLimit ?? 0)) * ONE_BILLION, + const newSplitPercentPPB = round(newDistributionLimit ? + (_amount / (newDistributionLimit)) * ONE_BILLION + : 0 ) + let adjustedSplits: Split[] = newSplits ?? payoutSplits // recalculate all split percents based on newly added split amount if (newDistributionLimit && !distributionLimitIsInfinite) { diff --git a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts index 67d648d0dc..16005cb97f 100644 --- a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts +++ b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/usePayProjectModal/usePayProjectTx.ts @@ -118,7 +118,7 @@ export const usePayProjectTx = ({ weiAmount, beneficiary, 0n, - `JBM V4 ${projectId}`, // TODO update + memo, '0x0', ] as const diff --git a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts index cc8599c872..ae6283fb0e 100644 --- a/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts +++ b/src/packages/v4/components/ProjectDashboard/V4PayRedeemCard/PayProjectModal/hooks/useProjectPaymentTokens.ts @@ -1,5 +1,5 @@ import { FixedInt } from 'fpnum' -import { getTokenAToBQuote } from 'juice-sdk-core' +import { getTokenAToBQuote, NATIVE_TOKEN_DECIMALS } from 'juice-sdk-core' import { useJBRulesetContext, useJBTokenContext, @@ -35,8 +35,8 @@ export const useProjectPaymentTokens = () => { : null const receivedTickets = - token.data?.decimals && amountBQuote?.payerTokens - ? formatUnits(amountBQuote?.payerTokens, token.data?.decimals) + amountBQuote?.payerTokens + ? formatUnits(amountBQuote?.payerTokens, token.data?.decimals ?? NATIVE_TOKEN_DECIMALS) : null const receivedTokenSymbolText = tokenSymbolText({ tokenSymbol: token.data?.symbol, diff --git a/src/packages/v4/components/V4TokenHoldersModal.tsx b/src/packages/v4/components/V4TokenHoldersModal.tsx deleted file mode 100644 index 798980d654..0000000000 --- a/src/packages/v4/components/V4TokenHoldersModal.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// import { BigNumber } from '@ethersproject/bignumber' -// import ParticipantsModal from 'components/modals/ParticipantsModal' -import useNameOfERC20 from 'hooks/ERC20/useNameOfERC20' -import { useReadJbTokensTokenOf } from 'juice-sdk-react' -import { useV4TotalTokenSupply } from '../hooks/useV4TotalTokenSupply' - -export const V4TokenHoldersModal = ({ - open, - onClose, -}: { - open: boolean - onClose: VoidFunction -}) => { - const { data: tokenAddress } = useReadJbTokensTokenOf() - const { data: tokenSymbol } = useNameOfERC20(tokenAddress) - - const { data: totalTokenSupply } = useV4TotalTokenSupply() - return null - // return ( - // - // ) -} diff --git a/src/packages/v4/components/modals/V4TokenHoldersModal/DownloadTokenHoldersModal.tsx b/src/packages/v4/components/modals/V4TokenHoldersModal/DownloadTokenHoldersModal.tsx new file mode 100644 index 0000000000..0ed03d2f4f --- /dev/null +++ b/src/packages/v4/components/modals/V4TokenHoldersModal/DownloadTokenHoldersModal.tsx @@ -0,0 +1,135 @@ +import { t, Trans } from '@lingui/macro' +import { Modal } from 'antd' +import InputAccessoryButton from 'components/buttons/InputAccessoryButton' +import FormattedNumberInput from 'components/inputs/FormattedNumberInput' + +import { useBlockNumber } from 'hooks/useBlockNumber' +import { useJBContractContext } from 'juice-sdk-react' +import { ParticipantsDownloadDocument } from 'packages/v4/graphql/client/graphql' +import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' +import { useCallback, useEffect, useState } from 'react' +import { downloadCsvFile } from 'utils/csv' +import { fromWad } from 'utils/format/formatNumber' +import { emitErrorNotification } from 'utils/notifications' +import { tokenSymbolText } from 'utils/tokenSymbolText' + +export function DownloadTokenHoldersModal({ + tokenSymbol, + open, + onCancel, +}: { + tokenSymbol: string | undefined + open: boolean | undefined + onCancel: VoidFunction | undefined +}) { + const { projectId } = useJBContractContext() + + const [blockNumber, setBlockNumber] = useState() + const [loading, setLoading] = useState() + + // Use block number 5 blocks behind chain head to allow for subgraph being a bit behind on indexing. + const { data: latestBlockNumber } = useBlockNumber({ behindChainHeight: 5 }) + + const { data } = useSubgraphQuery({ + document: ParticipantsDownloadDocument, + variables: { + where: { + projectId: Number(projectId), + }, + block: { + number: blockNumber + } + }, + enabled: Boolean(projectId && open), + }) + + const participants = data?.participants + + useEffect(() => { + setBlockNumber(latestBlockNumber) + }, [latestBlockNumber]) + + const download = useCallback(async () => { + if (blockNumber === undefined || !projectId) return + + const rows = [ + [ + 'Wallet address', + `Total ${tokenSymbolText({ tokenSymbol })} balance`, + 'Unclaimed balance', + 'Claimed balance', + 'Total ETH paid', + 'Last paid timestamp', + ], // CSV header row + ] + + setLoading(true) + try { + if (!participants) { + emitErrorNotification(t`Error loading holders`) + throw new Error('No data.') + } + + participants.forEach(p => { + let date = new Date((p.lastPaidTimestamp ?? 0) * 1000).toUTCString() + + if (date.includes(',')) date = date.split(',')[1] + + rows.push([ + p.wallet.id ?? '--', + fromWad(p.balance), + fromWad(p.stakedBalance), + fromWad(p.erc20Balance), + fromWad(p.volume), + date, + ]) + }) + + downloadCsvFile( + `@v4-project-${projectId}_holders-block${blockNumber}.csv`, + rows, + ) + + setLoading(false) + } catch (e) { + console.error('Error downloading participants', e) + setLoading(false) + } + }, [blockNumber, projectId, tokenSymbol, participants]) + + return ( + +
+

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

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

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

+
+
+ {tokenAddress && !isZeroAddress(tokenAddress) && ( +
+ + Token address: + +
+ )} +
{allParticipants?.length} wallets
+
+ +
+ +
+ + +
+
+
+ ) +} diff --git a/src/packages/v4/graphql/codegen.yml b/src/packages/v4/graphql/codegen.yml index 406a351c79..f2cd24e8f9 100644 --- a/src/packages/v4/graphql/codegen.yml +++ b/src/packages/v4/graphql/codegen.yml @@ -1,5 +1,5 @@ overwrite: true -schema: ${NEXT_PUBLIC_V4_SUBGRAPH_URL} +schema: ${NEXT_PUBLIC_V4_SEPOLIA_SUBGRAPH_URL} documents: 'src/packages/v4/graphql/queries/**/*.graphql' generates: src/packages/v4/graphql/client/: diff --git a/src/packages/v4/graphql/queries/dbV4Projects.graphql b/src/packages/v4/graphql/queries/dbV4Projects.graphql new file mode 100644 index 0000000000..ea6f313484 --- /dev/null +++ b/src/packages/v4/graphql/queries/dbV4Projects.graphql @@ -0,0 +1,25 @@ +query DBV4Projects($first: Int, $skip: Int) { + projects(first: $first, skip: $skip) { + id + projectId + handle + metadataUri + currentBalance + volume + volumeUSD + redeemVolume + redeemVolumeUSD + redeemCount + creator + owner + contributorsCount + nftsMintedCount + createdAt + trendingScore + trendingVolume + deployer + paymentsCount + trendingPaymentsCount + createdWithinTrendingWindow + } +} diff --git a/src/packages/v4/graphql/queries/participants.graphql b/src/packages/v4/graphql/queries/participants.graphql new file mode 100644 index 0000000000..9e028259e5 --- /dev/null +++ b/src/packages/v4/graphql/queries/participants.graphql @@ -0,0 +1,24 @@ +query Participants( + $where: Participant_filter + $first: Int + $skip: Int + $orderBy: Participant_orderBy + $orderDirection: OrderDirection +) { + participants( + where: $where + first: $first + skip: $skip + orderBy: $orderBy + orderDirection: $orderDirection + ) { + wallet { + id + } + volume + lastPaidTimestamp + balance + stakedBalance + id + } +} diff --git a/src/packages/v4/graphql/queries/participantsDownload.graphql b/src/packages/v4/graphql/queries/participantsDownload.graphql new file mode 100644 index 0000000000..0537b8a03f --- /dev/null +++ b/src/packages/v4/graphql/queries/participantsDownload.graphql @@ -0,0 +1,25 @@ +query ParticipantsDownload( + $where: Participant_filter + $first: Int + $skip: Int + $block: Block_height +) { + participants( + where: $where + first: $first + skip: $skip + block: $block + orderBy: balance + orderDirection: desc + ) { + wallet { + id + } + volume + volumeUSD + balance + stakedBalance + erc20Balance + lastPaidTimestamp + } +} diff --git a/src/packages/v4/graphql/queries/project.graphql b/src/packages/v4/graphql/queries/project.graphql index 0c51032449..a5f9c08fb7 100644 --- a/src/packages/v4/graphql/queries/project.graphql +++ b/src/packages/v4/graphql/queries/project.graphql @@ -1,7 +1,7 @@ query Projects($where: Project_filter, $first: Int, $skip: Int, $block: Block_height) { projects(where: $where, first: $first, skip: $skip, block: $block) { projectId - metadata + metadataUri handle contributorsCount createdAt diff --git a/src/packages/v4/graphql/queries/trendingProjectsV4.graphql b/src/packages/v4/graphql/queries/trendingProjectsV4.graphql new file mode 100644 index 0000000000..949b14097d --- /dev/null +++ b/src/packages/v4/graphql/queries/trendingProjectsV4.graphql @@ -0,0 +1,29 @@ +query TrendingProjectsV4( + $where: Project_filter + $first: Int + $skip: Int + $orderBy: Project_orderBy + $orderDirection: OrderDirection + $block: Block_height +) { + projects( + where: $where + first: $first + skip: $skip + orderBy: $orderBy + orderDirection: desc + block: $block + ) { + id + projectId + handle + createdAt + metadataUri + volume + trendingScore + paymentsCount + trendingPaymentsCount + trendingVolume + createdWithinTrendingWindow + } +} diff --git a/src/packages/v4/graphql/useSubgraphQuery.ts b/src/packages/v4/graphql/useSubgraphQuery.ts index e8c6a7586c..f9437349ac 100644 --- a/src/packages/v4/graphql/useSubgraphQuery.ts +++ b/src/packages/v4/graphql/useSubgraphQuery.ts @@ -1,6 +1,8 @@ import { type TypedDocumentNode } from '@graphql-typed-document-node/core' import { useQuery, type UseQueryResult } from '@tanstack/react-query' import request from 'graphql-request' +import { useJBChainId } from 'juice-sdk-react' +import { v4SubgraphUri } from 'lib/apollo/subgraphUri' // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt // @ts-ignore @@ -8,29 +10,27 @@ BigInt.prototype.toJSON = function () { return { $bigint: this.toString() } } -export function useSubgraphQuery({ - document, - enabled = true, - variables +export function useSubgraphQuery({ + document, + enabled = true, + variables, }: { - document: TypedDocumentNode, - enabled?: boolean, + document: TypedDocumentNode + enabled?: boolean variables?: TVariables }): UseQueryResult { + const chainId = useJBChainId() return useQuery({ // eslint-disable-next-line @typescript-eslint/no-explicit-any queryKey: [(document.definitions[0] as any).name.value, variables], queryFn: async ({ queryKey }) => { - if (!process.env.NEXT_PUBLIC_V4_SUBGRAPH_URL) { - throw new Error('NEXT_PUBLIC_V4_SUBGRAPH_URL is not set') + if (!chainId) { + throw new Error('useSubgraphQuery needs a chainId, none provided') } + const uri = v4SubgraphUri(chainId) - return request( - process.env.NEXT_PUBLIC_V4_SUBGRAPH_URL, - document, - queryKey[1] ? queryKey[1] : undefined, - ) + return request(uri, document, queryKey[1] ? queryKey[1] : undefined) }, - enabled + enabled, }) } diff --git a/src/packages/v4/hooks/useEditProjectDetailsTx.ts b/src/packages/v4/hooks/useEditProjectDetailsTx.ts new file mode 100644 index 0000000000..25aa19497f --- /dev/null +++ b/src/packages/v4/hooks/useEditProjectDetailsTx.ts @@ -0,0 +1,83 @@ +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { useJBContractContext, useWriteJbControllerSetUriOf } from 'juice-sdk-react' +import { useCallback, useContext } from 'react' + +export interface EditRulesetTxOpts { + onTransactionPending: (hash: `0x${string}`) => void + onTransactionConfirmed: () => void + onTransactionError: (error: Error) => void +} + +/** + * Takes data in EditCycleFormFields format, converts it to Edit Ruleset tx format and passes it to `writeEditRuleset` + * @returns A function that deploys a project. + */ +export function useEditProjectDetailsTx() { + const { writeContractAsync: writeEditMetadata } = useWriteJbControllerSetUriOf() + const { contracts, projectId } = useJBContractContext() + + const { addTransaction } = useContext(TxHistoryContext) + + const { userAddress } = useWallet() + + return useCallback( + async (cid: `0x${string}`, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: EditRulesetTxOpts + ) => { + if ( + !contracts.controller.data || + !contracts.primaryNativeTerminal.data || + !userAddress + ) { + return + } + + const args = [ + projectId, + cid + ] as const + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbControllerAbi, // ABI of the contract + // functionName: 'setUriOf', + // args, + // }) + + const hash = await writeEditMetadata({ + address: contracts.controller.data, + args + }) + + onTransactionPendingCallback(hash) + addTransaction?.('Edit Metadata', { hash }) + // const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( + // wagmiConfig, + // { + // hash, + // }, + // ) + + onTransactionConfirmedCallback() + } catch (e) { + onTransactionErrorCallback( + (e as Error) ?? new Error('Transaction failed'), + ) + } + }, + [ + contracts.controller.data, + userAddress, + writeEditMetadata, + contracts.primaryNativeTerminal.data, + projectId, + addTransaction, + ], + ) +} diff --git a/src/packages/v4/hooks/useEditRulesetTx.ts b/src/packages/v4/hooks/useEditRulesetTx.ts new file mode 100644 index 0000000000..3728579a7d --- /dev/null +++ b/src/packages/v4/hooks/useEditRulesetTx.ts @@ -0,0 +1,91 @@ +import { waitForTransactionReceipt } from '@wagmi/core' +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { NATIVE_TOKEN } from 'juice-sdk-core' +import { useJBContractContext, useWriteJbControllerQueueRulesetsOf } from 'juice-sdk-react' +import { wagmiConfig } from 'packages/v4/wagmiConfig' +import { useCallback, useContext } from 'react' +import { transformEditCycleFormFieldsToTxArgs } from '../utils/editRuleset' +import { EditCycleFormFields } from '../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields' + +export interface EditMetadataTxOpts { + onTransactionPending: (hash: `0x${string}`) => void + onTransactionConfirmed: () => void + onTransactionError: (error: Error) => void +} + +/** + * Takes data in EditCycleFormFields format, converts it to Edit Ruleset tx format and passes it to `writeEditRuleset` + * @returns A function that deploys a project. + */ +export function useEditRulesetTx() { + const { writeContractAsync: writeEditRuleset } = useWriteJbControllerQueueRulesetsOf() + const { contracts, projectId } = useJBContractContext() + + const { addTransaction } = useContext(TxHistoryContext) + + const { userAddress } = useWallet() + + return useCallback( + async (formValues: EditCycleFormFields, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: EditMetadataTxOpts + ) => { + if ( + !contracts.controller.data || + !contracts.primaryNativeTerminal.data || + !userAddress + ) { + return + } + + const args = transformEditCycleFormFieldsToTxArgs({ + formValues, + primaryNativeTerminal: contracts.primaryNativeTerminal.data, + tokenAddress: NATIVE_TOKEN, + projectId + }) + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbControllerAbi, // ABI of the contract + // functionName: 'queueRulesetsOf', + // args, + // }) + // console.log('contracts address: ', contracts.controller.data) + // console.log('encodedData: ', encodedData) + + const hash = await writeEditRuleset({ + address: contracts.controller.data, + args, + }) + + onTransactionPendingCallback(hash) + addTransaction?.('Edit Ruleset', { hash }) + await waitForTransactionReceipt( + wagmiConfig, + { + hash, + }, + ) + + onTransactionConfirmedCallback() + } catch (e) { + onTransactionErrorCallback( + (e as Error) ?? new Error('Transaction failed'), + ) + } + }, + [ + contracts.controller.data, + userAddress, + writeEditRuleset, + contracts.primaryNativeTerminal.data, + addTransaction, + ], + ) +} diff --git a/src/packages/v4/hooks/useLaunchProjectTx.ts b/src/packages/v4/hooks/useLaunchProjectTx.ts new file mode 100644 index 0000000000..a337c808aa --- /dev/null +++ b/src/packages/v4/hooks/useLaunchProjectTx.ts @@ -0,0 +1,169 @@ +import { waitForTransactionReceipt } from '@wagmi/core' +import { JUICEBOX_MONEY_PROJECT_METADATA_DOMAIN } from 'constants/metadataDomain' +import { DEFAULT_MEMO } from 'constants/transactionDefaults' +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useWallet } from 'hooks/Wallet' +import { NATIVE_TOKEN } from 'juice-sdk-core' +import { useWriteJbControllerLaunchProjectFor } from 'juice-sdk-react' +import { LaunchV2V3ProjectData } from 'packages/v2v3/hooks/transactor/useLaunchProjectTx' +import { useCallback, useContext } from 'react' +import { DEFAULT_MUST_START_AT_OR_AFTER } from 'redux/slices/editingV2Project' +import { Address, WaitForTransactionReceiptReturnType } from 'viem' +import { sepolia } from 'viem/chains' +import { + LaunchV2V3ProjectArgs, + transformV2V3CreateArgsToV4, +} from '../utils/launchProject' +import { wagmiConfig } from '../wagmiConfig' +import { useCurrentRouteChainId } from './useCurrentRouteChainId' + +const CREATE_EVENT_IDX = 2 +const PROJECT_ID_TOPIC_IDX = 1 +const HEX_BASE = 16 + +export interface LaunchTxOpts { + onTransactionPending: (hash: `0x${string}`) => void + onTransactionConfirmed: (hash: `0x${string}`, projectId: number) => void + onTransactionError: (error: Error) => void +} + +/** + * Return the project ID created from a `launchProjectFor` transaction. + * @param txReceipt receipt of `launchProjectFor` transaction + */ +const getProjectIdFromLaunchReceipt = ( + txReceipt: WaitForTransactionReceiptReturnType, +): number => { + const projectIdHex: string | undefined = + txReceipt?.logs[CREATE_EVENT_IDX]?.topics?.[PROJECT_ID_TOPIC_IDX] + if (!projectIdHex) return 0 + + const projectId = parseInt(projectIdHex, HEX_BASE) + return projectId +} + +/** + * The contract addresses to use for deployment + * @todo not ideal to hardcode these addresses + */ +const SUPPORTED_JB_MULTITERMINAL_ADDRESS = { + '84532': '0x4DeF0AA5B9CA095d11705284221b2878731ab4EF' as Address, + '421614': '0x4DeF0AA5B9CA095d11705284221b2878731ab4EF' as Address, + '11155111': '0x4DeF0AA5B9CA095d11705284221b2878731ab4EF' as Address, + '11155420': '0x4DeF0AA5B9CA095d11705284221b2878731ab4EF' as Address, +} + +const SUPPORTED_JB_CONTROLLER_ADDRESS = { + '84532': '0x219A5cE6d1c512D5b050ad2E3d380b8746BE0Cb8' as Address, + '421614': '0x219A5cE6d1c512D5b050ad2E3d380b8746BE0Cb8' as Address, + '11155111': '0x219A5cE6d1c512D5b050ad2E3d380b8746BE0Cb8' as Address, + '11155420': '0x219A5cE6d1c512D5b050ad2E3d380b8746BE0Cb8' as Address, +} + +/** + * Takes data in V2V3 format, converts it to v4 format and passes it to `writeLaunchProject` + * @returns A function that deploys a project. + */ +export function useLaunchProjectTx() { + const { writeContractAsync: writeLaunchProject } = + useWriteJbControllerLaunchProjectFor() + + const chainId = useCurrentRouteChainId() ?? sepolia.id // default to sepolia + const terminalAddress = chainId + ? SUPPORTED_JB_MULTITERMINAL_ADDRESS[chainId] + : undefined + + const controllerAddress = chainId + ? SUPPORTED_JB_CONTROLLER_ADDRESS[chainId] + : undefined + + const { addTransaction } = useContext(TxHistoryContext) + + const { userAddress } = useWallet() + + return useCallback( + async ( + { + owner, + projectMetadataCID, + fundingCycleData, + fundingCycleMetadata, + fundAccessConstraints, + groupedSplits = [], + mustStartAtOrAfter = DEFAULT_MUST_START_AT_OR_AFTER, + }: LaunchV2V3ProjectData, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: LaunchTxOpts, + ) => { + if (!controllerAddress || !terminalAddress || !userAddress || !chainId) { + return + } + + const _owner = owner && owner.length ? owner : userAddress + + const v2v3Args = [ + _owner, + [projectMetadataCID, JUICEBOX_MONEY_PROJECT_METADATA_DOMAIN], + fundingCycleData, + fundingCycleMetadata, + mustStartAtOrAfter, + groupedSplits, + fundAccessConstraints, + [terminalAddress], // _terminals, just supporting single for now + // Eventually should be something like: + // getTerminalsFromFundAccessConstraints( + // fundAccessConstraints, + // contracts.primaryNativeTerminal.data, + // ), + DEFAULT_MEMO, + ] as LaunchV2V3ProjectArgs + + const args = transformV2V3CreateArgsToV4({ + v2v3Args, + primaryNativeTerminal: terminalAddress, + currencyTokenAddress: NATIVE_TOKEN, + }) + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbControllerAbi, // ABI of the contract + // functionName: 'launchProjectFor', + // args, + // }) + + const hash = await writeLaunchProject({ + chainId, + address: controllerAddress, + args, + }) + + onTransactionPendingCallback(hash) + addTransaction?.('Launch Project', { hash }) + const transactionReceipt: WaitForTransactionReceiptReturnType = + await waitForTransactionReceipt(wagmiConfig, { + hash, + }) + + const newProjectId = getProjectIdFromLaunchReceipt(transactionReceipt) + + onTransactionConfirmedCallback(hash, newProjectId) + } catch (e) { + onTransactionErrorCallback( + (e as Error) ?? new Error('Transaction failed'), + ) + } + }, + [ + chainId, + controllerAddress, + userAddress, + writeLaunchProject, + terminalAddress, + addTransaction, + ], + ) +} diff --git a/src/packages/v4/hooks/useProjectHasErc20Token.ts b/src/packages/v4/hooks/useProjectHasErc20Token.ts new file mode 100644 index 0000000000..a38b254f4a --- /dev/null +++ b/src/packages/v4/hooks/useProjectHasErc20Token.ts @@ -0,0 +1,8 @@ +import { useReadJbTokensTokenOf } from 'juice-sdk-react' +import { isZeroAddress } from 'utils/address' + +export const useProjectHasErc20Token = () => { + const { data: tokenAddress } = useReadJbTokensTokenOf() + + return Boolean(tokenAddress && !isZeroAddress(tokenAddress)) +} diff --git a/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts b/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts new file mode 100644 index 0000000000..aff75e3842 --- /dev/null +++ b/src/packages/v4/hooks/useV4IssueErc20TokenTx.ts @@ -0,0 +1,71 @@ +import { useCallback, useContext } from 'react' + +import { TxHistoryContext } from 'contexts/Transaction/TxHistoryContext' +import { useJBContractContext, useWriteJbControllerDeployErc20For } from 'juice-sdk-react' +import { Address, zeroAddress } from 'viem' +import { BaseTxOpts } from '../models/transactions' + +export function useV4IssueErc20TokenTx() { + const { addTransaction } = useContext(TxHistoryContext) + const { projectId, contracts } = useJBContractContext() + + const { writeContractAsync: deployErc20Tx } = useWriteJbControllerDeployErc20For() + + return useCallback ( + async ({ name, symbol }: { + name: string + symbol: string + }, + { + onTransactionPending: onTransactionPendingCallback, + onTransactionConfirmed: onTransactionConfirmedCallback, + onTransactionError: onTransactionErrorCallback, + }: BaseTxOpts + ) => { + if ( + !projectId || !name || !symbol + ) { + return + } + + const args = [projectId, name, symbol, `${zeroAddress}000000000000000000000000`] as const + + try { + // SIMULATE TX: + // const encodedData = encodeFunctionData({ + // abi: jbControllerAbi, // ABI of the contract + // functionName: 'deployERC20For', + // args, + // }) + // console.log('contract', contracts.controller.data) + // console.log('encodedData', encodedData) + + const hash = await deployErc20Tx({ + args, + address: contracts.controller.data as Address + }) + + onTransactionPendingCallback(hash) + addTransaction?.('Launch ERC20 Token', { hash }) + // const transactionReceipt: WaitForTransactionReceiptReturnType = await waitForTransactionReceipt( + // wagmiConfig, + // { + // hash, + // }, + // ) + + onTransactionConfirmedCallback() + } catch (e) { + onTransactionErrorCallback( + (e as Error) ?? new Error('Transaction failed'), + ) + } + }, + [ + deployErc20Tx, + projectId, + addTransaction, + contracts.controller.data, + ], + ) +} diff --git a/src/packages/v4/hooks/useV4PayoutSplits.ts b/src/packages/v4/hooks/useV4PayoutSplits.ts index 41708a6b30..59650d9728 100644 --- a/src/packages/v4/hooks/useV4PayoutSplits.ts +++ b/src/packages/v4/hooks/useV4PayoutSplits.ts @@ -1,4 +1,8 @@ -import { JBSplit, SplitPortion } from 'juice-sdk-core' +import { + JBSplit, + NATIVE_TOKEN, + SplitPortion +} from 'juice-sdk-core' import { useJBContractContext, useJBRuleset, @@ -10,22 +14,20 @@ export const useV4CurrentPayoutSplits = () => { const { projectId } = useJBContractContext() const { data: tokenAddress } = useReadJbTokensTokenOf() const { data: ruleset } = useJBRuleset() + const rulesetId = BigInt(ruleset?.id ?? 0) + const groupId = BigInt(tokenAddress ?? NATIVE_TOKEN) // contracts say this is: `uint256(uint160(tokenAddress))` - const groupId = BigInt(tokenAddress ?? 0) // contracts say this is: `uint256(uint160(tokenAddress))` - const { data: _splits, isLoading: currentSplitsLoading } = - useReadJbSplitsSplitsOf({ - args: [projectId, BigInt(ruleset?.id ?? 0), groupId], - query: { - select(data) { - return data.map(d => ({ + return useReadJbSplitsSplitsOf({ + args: [projectId, rulesetId, groupId], + query: { + select(data) { + return data.map( + (d): JBSplit => ({ ...d, percent: new SplitPortion(d.percent), - })) - }, + }), + ) }, - }) - - const splits: JBSplit[] = _splits ? [..._splits] : [] - - return { splits, isLoading: currentSplitsLoading } + }, + }) } diff --git a/src/packages/v4/hooks/useV4ProjectRoute.ts b/src/packages/v4/hooks/useV4ProjectRoute.ts new file mode 100644 index 0000000000..1e032acb8b --- /dev/null +++ b/src/packages/v4/hooks/useV4ProjectRoute.ts @@ -0,0 +1,11 @@ +import { useChainId } from 'wagmi' +import { v4ProjectRoute } from '../utils/routes' + +export function useV4ProjectRoute(projectId: number) { + const chainId = useChainId() + + return v4ProjectRoute({ + chainId, + projectId, + }) +} diff --git a/src/packages/v4/models/transactions.ts b/src/packages/v4/models/transactions.ts new file mode 100644 index 0000000000..2aa9d8411e --- /dev/null +++ b/src/packages/v4/models/transactions.ts @@ -0,0 +1,5 @@ +export interface BaseTxOpts { + onTransactionPending: (hash?: `0x${string}`) => void + onTransactionConfirmed: (hash?: `0x${string}`) => void + onTransactionError: (error: Error) => void +} diff --git a/src/packages/v4/utils/distributions.ts b/src/packages/v4/utils/distributions.ts index dfaa0091e8..9924e7194c 100644 --- a/src/packages/v4/utils/distributions.ts +++ b/src/packages/v4/utils/distributions.ts @@ -116,7 +116,7 @@ export function ensureSplitsSumTo100Percent({ } // Calculate the ratio to adjust each split by - const ratio = max / currentTotal + const ratio = currentTotal ? max / currentTotal : 0 // Adjust each split const adjustedSplits = splits.map(split => { diff --git a/src/packages/v4/utils/editRuleset.ts b/src/packages/v4/utils/editRuleset.ts new file mode 100644 index 0000000000..ca46980f54 --- /dev/null +++ b/src/packages/v4/utils/editRuleset.ts @@ -0,0 +1,113 @@ +import { NATIVE_TOKEN } from "juice-sdk-core"; +import round from "lodash/round"; +import { issuanceRateFrom } from "packages/v2v3/utils/math"; +import { parseWad } from "utils/format/formatNumber"; +import { otherUnitToSeconds } from "utils/format/formatTime"; +import { EditCycleFormFields } from "../views/V4ProjectSettings/EditCyclePage/EditCycleFormFields"; + +export function transformEditCycleFormFieldsToTxArgs({ + formValues, + primaryNativeTerminal, + tokenAddress, + projectId, +}: { + formValues: EditCycleFormFields; + primaryNativeTerminal: `0x${string}`; + tokenAddress: `0x${string}`; + projectId: bigint; +}) { + const now = round(new Date().getTime() / 1000); + const mustStartAtOrAfter = now; + + const duration = otherUnitToSeconds({ + duration: formValues.duration, + unit: formValues.durationUnit.value, + }) + const weight = BigInt(issuanceRateFrom(formValues.issuanceRate.toString())); + const decayPercent = round(formValues.decayPercent * 10000000); + const approvalHook = formValues.approvalHook; + + const rulesetConfigurations = [ + { + mustStartAtOrAfter, + duration, + weight, + decayPercent, + approvalHook, + + metadata: { + reservedPercent: formValues.reservedPercent * 100, + redemptionRate: formValues.redemptionRate * 100, + baseCurrency: 1, // Assuming base currency is a constant value, typically USD + pausePay: formValues.pausePay, + pauseRedeem: false, // Defaulting this value since it's not in formValues + pauseCreditTransfers: !formValues.tokenTransfers, + allowOwnerMinting: formValues.allowOwnerMinting, + allowSetCustomToken: false, // Defaulting to false as it's not in formValues + allowTerminalMigration: formValues.allowTerminalMigration, + allowSetTerminals: formValues.allowSetTerminals, + allowSetController: formValues.allowSetController, + allowAddAccountingContext: false, // Defaulting to false as it's not in formValues + allowAddPriceFeed: false, // Defaulting to false as it's not in formValues + ownerMustSendPayouts: false, // Defaulting to false as it's not in formValues + holdFees: formValues.holdFees, + useTotalSurplusForRedemptions: false, // Defaulting to false as it's not in formValues + useDataHookForPay: false, // Defaulting to false as it's not in formValues + useDataHookForRedeem: false, // Defaulting to false as it's not in formValues + dataHook: "0x0000000000000000000000000000000000000000" as `0x${string}`, // Defaulting to a null address + metadata: 0, // Assuming no additional metadata is provided + allowCrosschainSuckerExtension: false + }, + + splitGroups: [ + { + groupId: BigInt(NATIVE_TOKEN), + splits: formValues.payoutSplits.map((split) => ({ + preferAddToBalance: Boolean(split.preferAddToBalance), + percent: Number(split.percent.value), + projectId: BigInt(split.projectId), + beneficiary: split.beneficiary as `0x${string}`, + lockedUntil: split.lockedUntil ?? 0, + hook: split.hook as `0x${string}`, + })), + }, + { + groupId: BigInt(1), + splits: formValues.reservedTokensSplits.map((split) => ({ + preferAddToBalance: Boolean(split.preferAddToBalance), + percent: Number(split.percent.value), + projectId: BigInt(split.projectId), + beneficiary: split.beneficiary as `0x${string}`, + lockedUntil: split.lockedUntil ?? 0, + hook: split.hook as `0x${string}`, + })), + }, + ], + + fundAccessLimitGroups: [ + { + terminal: primaryNativeTerminal, + token: tokenAddress, + payoutLimits: [ + { + amount: parseWad(formValues.payoutLimit).toBigInt(), + currency: 1, // Assuming currency is constant (e.g., USD) + }, + ], + surplusAllowances: [ + { + amount: BigInt(0), // Assuming no surplus allowances for now + currency: 1, // Assuming currency is constant (e.g., USD) + }, + ], + }, + ], + }, + ]; + + return [ + projectId, + rulesetConfigurations, + formValues.memo ?? "", + ] as const; +} diff --git a/src/packages/v4/utils/launchProject.ts b/src/packages/v4/utils/launchProject.ts new file mode 100644 index 0000000000..21292b1834 --- /dev/null +++ b/src/packages/v4/utils/launchProject.ts @@ -0,0 +1,133 @@ +import { NATIVE_TOKEN, NATIVE_TOKEN_DECIMALS, SplitGroup } from 'juice-sdk-core' +import round from 'lodash/round' +import { V2FundingCycleMetadata } from 'packages/v2/models/fundingCycle' +import { + V2V3FundAccessConstraint, + V2V3FundingCycleData, +} from 'packages/v2v3/models/fundingCycle' +import { GroupedSplits } from 'packages/v2v3/models/splits' +import { V3FundingCycleMetadata } from 'packages/v3/models/fundingCycle' +import { Address } from 'viem' + +export type LaunchV2V3ProjectArgs = [ + string, // _owner + [string, number], // _projectMetadata [projectMetadataCID, JUICEBOX_MONEY_PROJECT_METADATA_DOMAIN] + V2V3FundingCycleData, // _data + V2FundingCycleMetadata | V3FundingCycleMetadata, // _metadata + string, // _mustStartAtOrAfter + GroupedSplits[], // _groupedSplits + V2V3FundAccessConstraint[], // _fundAccessConstraints + string[], // _terminals + string, // _memo +] + +export function transformV2V3CreateArgsToV4({ + v2v3Args, + primaryNativeTerminal, + currencyTokenAddress, +}: { + v2v3Args: LaunchV2V3ProjectArgs + primaryNativeTerminal: Address + currencyTokenAddress: Address +}) { + const [ + _owner, + _projectMetadata, + _data, + _metadata, + _mustStartAtOrAfter, + _groupedSplits, + _fundAccessConstraints, + _terminals, + _memo, + ] = v2v3Args + + const mustStartAtOrAfterNum = parseInt(_mustStartAtOrAfter) + const now = round(new Date().getTime() / 1000) + + const ruleset = { + mustStartAtOrAfter: mustStartAtOrAfterNum > now ? mustStartAtOrAfterNum : now, + duration: _data.duration.toNumber(), + weight: _data.weight.toBigInt(), + decayPercent: _data.discountRate.toNumber(), + + approvalHook: _data.ballot as Address, + + metadata: { + reservedPercent: _metadata.reservedRate.toNumber(), + redemptionRate: _metadata.redemptionRate.toNumber(), + baseCurrency: 1, // Not present in v2v3, passing 1 by default + pausePay: _metadata.pausePay, + pauseRedeem: _metadata.pauseRedeem, + pauseCreditTransfers: Boolean(_metadata.global.pauseTransfers), + allowOwnerMinting: _metadata.allowMinting, + allowSetCustomToken: false, // Assuming false by default + allowTerminalMigration: _metadata.allowTerminalMigration, + allowSetTerminals: _metadata.global.allowSetTerminals, + allowSetController: _metadata.global.allowSetController, + allowAddAccountingContext: false, // Not present in v2v3, passing false by default + allowAddPriceFeed: false, // Not present in v2v3, passing false by default + ownerMustSendPayouts: false, // Not present in v2v3, passing false by default + holdFees: _metadata.holdFees, + useTotalSurplusForRedemptions: _metadata.useTotalOverflowForRedemptions, + useDataHookForPay: _metadata.useDataSourceForPay, + useDataHookForRedeem: _metadata.useDataSourceForRedeem, + dataHook: _metadata.dataSource as Address, + metadata: 0, + allowCrosschainSuckerExtension: false, + }, + + splitGroups: _groupedSplits.map(group => ({ + groupId: + group.group === SplitGroup.ETHPayout + ? BigInt(NATIVE_TOKEN) + : 1n, // TODO dont hardcode reserved token group as 1n + splits: group.splits.map(split => ({ + preferAddToBalance: Boolean(split.preferClaimed), + percent: split.percent, + projectId: BigInt(parseInt(split.projectId ?? '0x00', 16)), + beneficiary: split.beneficiary as Address, + lockedUntil: split.lockedUntil ?? 0, + hook: split.allocator as Address, + })), + })), + + fundAccessLimitGroups: _fundAccessConstraints.map(constraint => ({ + terminal: primaryNativeTerminal, + token: currencyTokenAddress, + payoutLimits: [ + { + amount: constraint.distributionLimit.toBigInt(), + currency: Number(BigInt(NATIVE_TOKEN)), // TODO support USD somehow + }, + ], + surplusAllowances: [ + { + amount: constraint.overflowAllowance.toBigInt(), + currency: Number(BigInt(NATIVE_TOKEN)), + }, + ], + })), + } + + const rulesetConfigurations = [ruleset] + + const terminalConfigurations = _terminals.map(terminal => ({ + terminal: terminal as Address, + accountingContextsToAccept: [ + { + token: currencyTokenAddress, + decimals: NATIVE_TOKEN_DECIMALS, + currency: Number(BigInt(currencyTokenAddress)), + }, + ], + })) + + return [ + _owner as Address, + _projectMetadata[0], + rulesetConfigurations, + terminalConfigurations, + _memo, + ] as const +} diff --git a/src/packages/v4/utils/math.ts b/src/packages/v4/utils/math.ts index d38d9fa04b..a5d55b13ce 100644 --- a/src/packages/v4/utils/math.ts +++ b/src/packages/v4/utils/math.ts @@ -1,7 +1,6 @@ -import * as constants from '@ethersproject/constants' import { feeForAmount } from 'utils/math' -export const MAX_PAYOUT_LIMIT = constants.MaxUint256.toBigInt() +export const MAX_PAYOUT_LIMIT = BigInt('26959946667150639794667015087019630673637144422540572481103610249215') // uint 224, probably a better way lol export const amountSubFee = ( amountWad: bigint | undefined, diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx index 329294c3b2..52880d268e 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectDashboard.tsx @@ -1,3 +1,4 @@ +import { Footer } from 'components/Footer/Footer' import { CoverPhoto } from 'components/Project/ProjectHeader/CoverPhoto' import { SuccessPayView } from 'packages/v4/components/ProjectDashboard/components/SuccessPayView/SuccessPayView' import { useProjectDispatch } from 'packages/v4/components/ProjectDashboard/redux/hooks' @@ -27,7 +28,7 @@ export function V4ProjectDashboard() {
-
+
+
) } diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx index 1171fdfa41..db5bcef5f3 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectHeader.tsx @@ -6,7 +6,6 @@ import EthereumAddress from 'components/EthereumAddress' import { GnosisSafeBadge } from 'components/Project/ProjectHeader/GnosisSafeBadge' import { useSocialLinks } from 'components/Project/ProjectHeader/hooks/useSocialLinks' import { ProjectHeaderLogo } from 'components/Project/ProjectHeader/ProjectHeaderLogo' -import { ProjectHeaderPopupMenu } from 'components/Project/ProjectHeader/ProjectHeaderPopupMenu' import { SocialLinkButton } from 'components/Project/ProjectHeader/SocialLinkButton' // import { Subtitle } from 'components/Project/ProjectHeader/Subtitle' import { TruncatedText } from 'components/TruncatedText' @@ -58,7 +57,8 @@ export const V4ProjectHeader = ({ className }: { className?: string }) => {
{projectId ? ( isMobile ? ( - + // + <> ) : ( <>
@@ -73,7 +73,7 @@ export const V4ProjectHeader = ({ className }: { className?: string }) => { /> ))}
- + {/* @v4todo: */} {canQueueRuleSets && (
@@ -69,7 +68,7 @@ export function V4ActivityList() { extra={ bought {event.beneficiaryTokenCount?.format(6)}{' '} - {token.data?.symbol} + {token.data?.symbol ?? 'tokens'} } /> diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx index b1428fbbee..cb8274affc 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4ActivityPanel/V4ActivityPanel.tsx @@ -1,7 +1,8 @@ import { Trans } from '@lingui/macro' import { ErrorBoundaryCallout } from 'components/Callout/ErrorBoundaryCallout' import Loading from 'components/Loading' -// import VolumeChart from 'components/VolumeChart' +import VolumeChart from 'components/VolumeChart' +import { PV_V4 } from 'constants/pv' import { useJBContractContext } from 'juice-sdk-react' import { ProjectsDocument } from 'packages/v4/graphql/client/graphql' import { useSubgraphQuery } from 'packages/v4/graphql/useSubgraphQuery' @@ -11,14 +12,14 @@ import { V4ActivityList } from './V4ActivityList' export function V4ActivityPanel() { const { projectId } = useJBContractContext() const { data } = useSubgraphQuery({ - document: ProjectsDocument, + document: ProjectsDocument, variables: { where: { projectId: Number(projectId), }, - }, + } }) - + const createdAt = data?.projects?.[0].createdAt return ( @@ -29,12 +30,12 @@ export function V4ActivityPanel() { Volume chart failed to load.} > - {/* */} + />
diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx index 012fd2e815..b559162cc0 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/V4DistributePayoutsModal.tsx @@ -32,7 +32,7 @@ export default function V4DistributePayoutsModal({ onCancel?: VoidFunction onConfirmed?: VoidFunction }) { - const { splits: payoutSplits } = useV4CurrentPayoutSplits() + const { data: payoutSplits } = useV4CurrentPayoutSplits() const { data: payoutLimit } = usePayoutLimit() const { distributableAmount: distributable } = useV4DistributableAmount() const { projectId } = useProjectMetadataContext() diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts index 0813a0d511..409405372a 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4CurrentUpcomingPayoutSplits.ts @@ -12,11 +12,11 @@ export const useV4CurrentUpcomingPayoutSplits = ( ) => { const { projectId } = useJBContractContext() const { data: tokenAddress } = useReadJbTokensTokenOf() - const { splits, isLoading: currentSplitsLoading } = useV4CurrentPayoutSplits() + const { data: currentSplits, isLoading: currentSplitsLoading } = + useV4CurrentPayoutSplits() const { ruleset: upcomingRuleset, isLoading: upcomingRulesetLoading } = useJBUpcomingRuleset() - const { data: _upcomingSplits, isLoading: upcomingSplitsLoading } = useReadJbSplitsSplitsOf({ args: [ @@ -37,8 +37,9 @@ export const useV4CurrentUpcomingPayoutSplits = ( const upcomingSplits: JBSplit[] = _upcomingSplits ? [..._upcomingSplits] : [] if (type === 'current') { - return { splits, isLoading: currentSplitsLoading } + return { splits: currentSplits, isLoading: currentSplitsLoading } } + return { splits: upcomingSplits, isLoading: upcomingSplitsLoading || upcomingRulesetLoading, diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.tsx similarity index 89% rename from src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts rename to src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.tsx index 5bbaf06828..21326e43a2 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.ts +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationCycleSection.tsx @@ -2,9 +2,9 @@ import { t } from '@lingui/macro' import { ConfigurationPanelDatum } from 'components/Project/ProjectTabs/CyclesPayoutsTab/ConfigurationPanel' import { pairToDatum } from 'components/Project/ProjectTabs/utils/pairToDatum' import { JBRulesetData } from 'juice-sdk-core' +import { NativeTokenValue } from 'juice-sdk-react' import { V4CurrencyOption } from 'packages/v4/models/v4CurrencyOption' import { getApprovalStrategyByAddress } from 'packages/v4/utils/approvalHooks' -import { formatCurrencyAmount } from 'packages/v4/utils/formatCurrencyAmount' import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' import { useMemo } from 'react' import { formatTime } from 'utils/format/formatTime' @@ -71,19 +71,15 @@ export const useV4FormatConfigurationCycleSection = ({ const formatPayoutAmount = ( amount: bigint | undefined, - currency: V4CurrencyOption | undefined, ) => { if (amount === undefined || amount === MAX_PAYOUT_LIMIT) return t`Unlimited` if (amount === 0n) return t`Zero (no payouts)` - return formatCurrencyAmount({ - amount: Number(amount) / 1e18, // Assuming fromWad - currency, - }) + return } const payoutsDatum: ConfigurationPanelDatum = useMemo(() => { const { amount, currency } = payoutLimitAmountCurrency ?? {} - const currentPayout = formatPayoutAmount(amount, currency) + const currentPayout = formatPayoutAmount(amount) if ( upcomingPayoutLimitAmountCurrency === null || @@ -96,13 +92,9 @@ export const useV4FormatConfigurationCycleSection = ({ upcomingPayoutLimitAmountCurrency?.amount !== undefined ? upcomingPayoutLimitAmountCurrency.amount : amount - const upcomingPayoutLimitCurrency = - upcomingPayoutLimitAmountCurrency?.currency !== undefined - ? upcomingPayoutLimitAmountCurrency.currency - : currency + const upcomingPayout = formatPayoutAmount( upcomingPayoutLimit, - upcomingPayoutLimitCurrency, ) return pairToDatum(t`Payouts`, currentPayout, upcomingPayout) diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts index 6f6cc9e49b..bca38922ca 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4FormatConfigurationTokenSection.ts @@ -24,58 +24,77 @@ export const useV4FormatConfigurationTokenSection = ({ upcomingRulesetLoading: boolean upcomingRulesetMetadata?: JBRulesetMetadata | undefined | null }): ConfigurationPanelTableData => { - const tokenSymbol = useMemo( - () => - tokenSymbolText({ - tokenSymbol: tokenSymbolRaw, - capitalize: false, - plural: true, - }), - [tokenSymbolRaw], - ) + const tokenSymbol = tokenSymbolText({ + tokenSymbol: tokenSymbolRaw, + capitalize: false, + plural: true, + }) + const decayPercentFloat = ruleset?.decayPercent.toFloat() const currentTotalIssuanceRate = ruleset?.weight.toFloat() - const queuedTotalIssuanceRate = upcomingRuleset ? - upcomingRuleset?.weight.toFloat() - : currentTotalIssuanceRate && decayPercentFloat ? - currentTotalIssuanceRate - (currentTotalIssuanceRate * decayPercentFloat) - : undefined + + const queuedTotalIssuanceRate = upcomingRuleset + ? upcomingRuleset?.weight.toFloat() + : typeof currentTotalIssuanceRate !== 'undefined' && + typeof decayPercentFloat !== 'undefined' + ? currentTotalIssuanceRate - currentTotalIssuanceRate * decayPercentFloat + : undefined const totalIssuanceRateDatum: ConfigurationPanelDatum = useMemo(() => { - const current = currentTotalIssuanceRate + const current = currentTotalIssuanceRate !== undefined ? `${currentTotalIssuanceRate} ${tokenSymbol}/ETH` : undefined if (upcomingRuleset === null || upcomingRulesetLoading) { return pairToDatum(t`Total issuance rate`, current, null) } - const queued = queuedTotalIssuanceRate + + const queued = queuedTotalIssuanceRate !== undefined ? `${queuedTotalIssuanceRate} ${tokenSymbol}/ETH` : undefined + return pairToDatum(t`Total issuance rate`, current, queued) - }, [upcomingRuleset, currentTotalIssuanceRate, tokenSymbol, queuedTotalIssuanceRate, upcomingRulesetLoading]) + }, [ + upcomingRuleset, + currentTotalIssuanceRate, + tokenSymbol, + queuedTotalIssuanceRate, + upcomingRulesetLoading, + ]) const reservedPercentFloat = rulesetMetadata?.reservedPercent.toFloat() - const queuedReservedPercentFloat = upcomingRulesetMetadata?.reservedPercent.toFloat() + const queuedReservedPercentFloat = + upcomingRulesetMetadata?.reservedPercent.toFloat() const payerIssuanceRateDatum: ConfigurationPanelDatum = useMemo(() => { - const currentPayerIssuanceRate = currentTotalIssuanceRate && reservedPercentFloat ? - currentTotalIssuanceRate - (currentTotalIssuanceRate * reservedPercentFloat) - : undefined + const currentPayerIssuanceRate = + typeof currentTotalIssuanceRate !== 'undefined' && + typeof reservedPercentFloat !== 'undefined' + ? currentTotalIssuanceRate - + currentTotalIssuanceRate * reservedPercentFloat + : undefined - const current = currentPayerIssuanceRate + const current = currentPayerIssuanceRate !== undefined ? `${currentPayerIssuanceRate} ${tokenSymbol}/ETH` : undefined - if (upcomingRuleset === null || upcomingRulesetMetadata === null || upcomingRulesetLoading) { + + if ( + upcomingRuleset === null || + upcomingRulesetMetadata === null || + upcomingRulesetLoading + ) { return pairToDatum(t`Payer issuance rate`, current, null) } + const _reservedPercent = queuedReservedPercentFloat ?? reservedPercentFloat - const queuedPayerIssuanceRate = queuedTotalIssuanceRate && _reservedPercent ? - queuedTotalIssuanceRate - (queuedTotalIssuanceRate * _reservedPercent) - : undefined - const queued = queuedPayerIssuanceRate + const queuedPayerIssuanceRate = + queuedTotalIssuanceRate && _reservedPercent + ? queuedTotalIssuanceRate - queuedTotalIssuanceRate * _reservedPercent + : undefined + const queued = queuedPayerIssuanceRate !== undefined ? `${queuedPayerIssuanceRate} ${tokenSymbol}/ETH` : undefined + return pairToDatum(t`Payer issuance rate`, current, queued) }, [ tokenSymbol, @@ -85,27 +104,28 @@ export const useV4FormatConfigurationTokenSection = ({ currentTotalIssuanceRate, queuedTotalIssuanceRate, reservedPercentFloat, - upcomingRulesetLoading + upcomingRulesetLoading, ]) const reservedPercentDatum: ConfigurationPanelDatum = useMemo(() => { - const current = rulesetMetadata?.reservedPercent ? - `${rulesetMetadata.reservedPercent.formatPercentage()}%` : undefined + const current = rulesetMetadata?.reservedPercent + ? `${rulesetMetadata.reservedPercent.formatPercentage()}%` + : undefined if (upcomingRulesetMetadata === null || upcomingRulesetLoading) { return pairToDatum(t`Reserved rate`, current, null) } const queued = upcomingRulesetMetadata?.reservedPercent ? `${upcomingRulesetMetadata.reservedPercent.formatPercentage()}%` - : rulesetMetadata?.reservedPercent ? - `${rulesetMetadata.reservedPercent.formatPercentage()}%` + : rulesetMetadata?.reservedPercent + ? `${rulesetMetadata.reservedPercent.formatPercentage()}%` : undefined return pairToDatum(t`Reserved rate`, current, queued) }, [upcomingRulesetMetadata, rulesetMetadata, upcomingRulesetLoading]) const decayPercentDatum: ConfigurationPanelDatum = useMemo(() => { - const current = ruleset ? - `${ruleset.decayPercent.formatPercentage()}%` + const current = ruleset + ? `${ruleset.decayPercent.formatPercentage()}%` : undefined if (upcomingRuleset === null || upcomingRulesetLoading) { @@ -113,15 +133,16 @@ export const useV4FormatConfigurationTokenSection = ({ } const queued = upcomingRuleset ? `${upcomingRuleset.decayPercent.formatPercentage()}%` - : ruleset ? - `${ruleset.decayPercent.formatPercentage()}%` + : ruleset + ? `${ruleset.decayPercent.formatPercentage()}%` : undefined return pairToDatum(t`Decay rate`, current, queued) }, [ruleset, upcomingRuleset, upcomingRulesetLoading]) const redemptionRateDatum: ConfigurationPanelDatum = useMemo(() => { - const currentRedemptionRate = rulesetMetadata?.redemptionRate.formatPercentage() + const currentRedemptionRate = + rulesetMetadata?.redemptionRate.formatPercentage() const current = currentRedemptionRate ? `${currentRedemptionRate}%` @@ -133,8 +154,8 @@ export const useV4FormatConfigurationTokenSection = ({ const queued = upcomingRulesetMetadata ? `${upcomingRulesetMetadata?.redemptionRate.formatPercentage()}%` - : rulesetMetadata ? - `${rulesetMetadata.redemptionRate.formatPercentage()}%` + : rulesetMetadata + ? `${rulesetMetadata.redemptionRate.formatPercentage()}%` : undefined return pairToDatum(t`Redemption rate`, current, queued) }, [upcomingRulesetMetadata, rulesetMetadata, upcomingRulesetLoading]) @@ -153,18 +174,22 @@ export const useV4FormatConfigurationTokenSection = ({ } const queuedOwnerTokenMinting = - upcomingRulesetMetadata?.allowOwnerMinting !== undefined ? - upcomingRulesetMetadata?.allowOwnerMinting - : rulesetMetadata?.allowOwnerMinting !== undefined ? - rulesetMetadata.allowOwnerMinting - : undefined + upcomingRulesetMetadata?.allowOwnerMinting !== undefined + ? upcomingRulesetMetadata?.allowOwnerMinting + : rulesetMetadata?.allowOwnerMinting !== undefined + ? rulesetMetadata.allowOwnerMinting + : undefined return flagPairToDatum( t`Owner token minting`, currentOwnerTokenMinting, queuedOwnerTokenMinting, ) - }, [rulesetMetadata?.allowOwnerMinting, upcomingRulesetMetadata, upcomingRulesetLoading]) + }, [ + rulesetMetadata?.allowOwnerMinting, + upcomingRulesetMetadata, + upcomingRulesetLoading, + ]) const tokenTransfersDatum: ConfigurationPanelDatum = useMemo(() => { const currentTokenTransfersDatum = @@ -181,8 +206,8 @@ export const useV4FormatConfigurationTokenSection = ({ const queuedTokenTransfersDatum = upcomingRulesetMetadata?.pauseCreditTransfers !== undefined ? !upcomingRulesetMetadata?.pauseCreditTransfers - : rulesetMetadata?.pauseCreditTransfers !== undefined ? - !rulesetMetadata.pauseCreditTransfers + : rulesetMetadata?.pauseCreditTransfers !== undefined + ? !rulesetMetadata.pauseCreditTransfers : null return flagPairToDatum( @@ -193,7 +218,7 @@ export const useV4FormatConfigurationTokenSection = ({ }, [ rulesetMetadata?.pauseCreditTransfers, upcomingRulesetMetadata, - upcomingRulesetLoading + upcomingRulesetLoading, ]) return useMemo(() => { diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx index 390ca45ed8..be32cd3419 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4CyclesPayoutsPanel/hooks/useV4TreasuryStats.tsx @@ -1,5 +1,9 @@ import { t } from '@lingui/macro' -import { NativeTokenValue, useJBRulesetMetadata, useNativeTokenSurplus } from 'juice-sdk-react' +import { + NativeTokenValue, + useJBRulesetMetadata, + useNativeTokenSurplus, +} from 'juice-sdk-react' import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' import { useV4BalanceOfNativeTerminal } from 'packages/v4/hooks/useV4BalanceOfNativeTerminal' import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' @@ -15,37 +19,16 @@ export const useV4TreasuryStats = () => { const { data: payoutLimit } = usePayoutLimit() - const treasuryBalance = useMemo(() => { - if (!_treasuryBalance) return undefined - - return ( - - ) - }, [_treasuryBalance]) - + const treasuryBalance = const surplus = useMemo(() => { - if (payoutLimit && payoutLimit.amount === MAX_PAYOUT_LIMIT) return t`No surplus` + if (payoutLimit && payoutLimit.amount === MAX_PAYOUT_LIMIT) + return t`No surplus` - return ( - - ) - }, [ - surplusInNativeToken, - payoutLimit, - ]) + return + }, [surplusInNativeToken, payoutLimit]) - const availableToPayout = useMemo(() => { - return ( - - ) - }, [distributableAmount]) + const availableToPayout = return { treasuryBalance, availableToPayout, diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx index fa13d73bc3..be5f9875a9 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4ReservedTokensSubPanel.tsx @@ -53,9 +53,9 @@ export const V4ReservedTokensSubPanel = ({ {pendingReservedTokensFormatted || reservedPercent || diff --git a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx index 0eff7023aa..a302700ae0 100644 --- a/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx +++ b/src/packages/v4/views/V4ProjectDashboard/V4ProjectTabs/V4TokensPanel/V4TokensPanel.tsx @@ -7,9 +7,15 @@ import { TitleDescriptionDisplayCard } from 'components/Project/ProjectTabs/Titl // import { ReservedTokensSubPanel } from './components/ReservedTokensSubPanel' // import { TokenRedemptionCallout } from './components/TokenRedemptionCallout' // import { TransferUnclaimedTokensModalWrapper } from './components/TransferUnclaimedTokensModalWrapper' -// import { IssueErc20TokenButton } from 'components/buttons/IssueErc20TokenButton' -import { V4TokenHoldersModal } from 'packages/v4/components/V4TokenHoldersModal' +import { SettingOutlined } from '@ant-design/icons' +import { Button, Tooltip } from 'antd' +import { AddTokenToMetamaskButton } from 'components/buttons/AddTokenToMetamaskButton' +import { ISSUE_ERC20_EXPLANATION } from 'components/strings' +import { useJBContractContext } from 'juice-sdk-react' +import { V4TokenHoldersModal } from 'packages/v4/components/modals/V4TokenHoldersModal/V4TokenHoldersModal' +import { v4ProjectRoute } from 'packages/v4/utils/routes' import { useCallback, useState } from 'react' +import { useChainId } from 'wagmi' import { useV4TokensPanel } from './hooks/useV4TokensPanel' import { useV4YourBalanceMenuItems } from './hooks/useV4YourBalanceMenuItems' import { V4ReservedTokensSubPanel } from './V4ReservedTokensSubPanel' @@ -164,6 +170,10 @@ export const V4TokensPanel = () => { } const ProjectTokenCard = () => { + const chainId = useChainId() + const { projectId: projectIdBig } = useJBContractContext() + const projectId = Number(projectIdBig) + const { projectToken, projectTokenAddress, @@ -185,15 +195,27 @@ const ProjectTokenCard = () => { )}
- {/* {projectTokenAddress && projectHasErc20Token && ( + {projectTokenAddress && projectHasErc20Token && ( - )} */} - {/* {canCreateErc20Token && ( - - )} */} + )} + {canCreateErc20Token ? ( + + + + + + ): null} } /> diff --git a/src/packages/v4/views/V4ProjectSettings/ArchiveProjectSettingsPage.tsx b/src/packages/v4/views/V4ProjectSettings/ArchiveProjectSettingsPage.tsx new file mode 100644 index 0000000000..4757ec3611 --- /dev/null +++ b/src/packages/v4/views/V4ProjectSettings/ArchiveProjectSettingsPage.tsx @@ -0,0 +1,167 @@ +import { Trans } from '@lingui/macro' +import { Button, Statistic } from 'antd' +import { Callout } from 'components/Callout/Callout' +import { useJBProjectMetadataContext } from 'juice-sdk-react' +import { uploadProjectMetadata } from 'lib/api/ipfs' +import { useEditProjectDetailsTx } from 'packages/v4/hooks/useEditProjectDetailsTx' +import { useCallback, useState } from 'react' +import { emitErrorNotification, emitInfoNotification } from 'utils/notifications' + +export function ArchiveProjectSettingsPage() { + const [loading, setLoading] = useState(false) + + const editV4ProjectDetailsTx = useEditProjectDetailsTx() + const { metadata } = useJBProjectMetadataContext() + + const projectMetadata = metadata.data + + const setArchived = useCallback(async (archived: boolean) => { + if (!projectMetadata) return + + setLoading(true) + + const uploadedMetadata = await uploadProjectMetadata({ + ...projectMetadata, + archived, + }) + + if (!uploadedMetadata.Hash) { + setLoading(false) + return + } + + editV4ProjectDetailsTx( + uploadedMetadata.Hash as `0x${string}`, { + onTransactionPending: () => null, + onTransactionConfirmed: () => { + setLoading(false) + emitInfoNotification('Project archived', { + description: 'Your project has been archived.', + }) + + // v4Todo: part of v2, not sure if necessary + // if (projectId) { + // await revalidateProject({ + // pv: PV_V4, + // projectId: String(projectId), + // }) + // } + }, + onTransactionError: (error: unknown) => { + console.error(error) + setLoading(false) + emitErrorNotification(`Error launching ruleset: ${error}`) + }, + } + ) + }, [ + editV4ProjectDetailsTx, + projectMetadata, + ]) + + if (projectMetadata?.archived) { + return ( +
+ Project state} + valueRender={() => Archived} + /> + +
+

+ Unarchiving your project has the following effects: +

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

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

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

+ Archiving your project has the following effects: +

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

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

+ + + You can unarchive your project at any time. + +
+ +
+ +
+
+ ) +} diff --git a/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx b/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx new file mode 100644 index 0000000000..c1dcb397af --- /dev/null +++ b/src/packages/v4/views/V4ProjectSettings/CreateErc20TokenSettingsPage.tsx @@ -0,0 +1,125 @@ +import { Trans, t } from '@lingui/macro' +import { Button, Form, Input } from 'antd' +import { IssueErc20TokenTxArgs } from 'components/buttons/IssueErc20TokenButton' +import TransactionModal from 'components/modals/TransactionModal' +import { ISSUE_ERC20_EXPLANATION } from 'components/strings' +import { useProjectHasErc20Token } from 'packages/v4/hooks/useProjectHasErc20Token' +import { useV4IssueErc20TokenTx } from 'packages/v4/hooks/useV4IssueErc20TokenTx' +import { useV4WalletHasPermission } from 'packages/v4/hooks/useV4WalletHasPermission' +import { V4OperatorPermission } from 'packages/v4/models/v4Permissions' +import { useState } from 'react' +import { emitErrorNotification } from 'utils/notifications' + +export function CreateErc20TokenSettingsPage() { + const [form] = Form.useForm() + const [loading, setLoading] = useState() + const [transactionModalOpen, setTransactionModalOpen] = + useState(false) + const [transactionPending, setTransactionPending] = useState(false) +const issueErc20TokenTx = useV4IssueErc20TokenTx() + const projectHasErc20Token = useProjectHasErc20Token() + const hasIssueTicketsPermission = useV4WalletHasPermission( + V4OperatorPermission.DEPLOY_ERC20, + ) + + const canCreateErc20Token = !projectHasErc20Token && hasIssueTicketsPermission + + async function onIssueErc20FormSaved(values: IssueErc20TokenTxArgs) { + await form.validateFields() + + if (!issueErc20TokenTx) { + emitErrorNotification(t`ERC20 transaction not ready. Try again.`) + return + } + + setLoading(true) + + issueErc20TokenTx( + { name: values.name, symbol: values.symbol }, + { + onTransactionPending: () => { + setTransactionPending(true) + setTransactionModalOpen(true) + }, + onTransactionConfirmed: () => { + setTransactionPending(false) + setTransactionModalOpen(false) + setLoading(false) + setTimeout(() => { + window.location.reload() + }, 1000) + }, + onTransactionError: (e: Error) => { + setTransactionPending(false) + setTransactionModalOpen(false) + setLoading(false) + emitErrorNotification(e.message) + emitErrorNotification( + t`Failed to create ERC20 token: ${e.message}`, + ) + }, + }, + ) + } + + if (!canCreateErc20Token) { + return ( +
+

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

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

{ISSUE_ERC20_EXPLANATION}

+
+ + + + + + form.setFieldsValue({ symbol: e.target.value.toUpperCase() }) + } + /> + + +
+ + setTransactionModalOpen(false)} + onOk={() => setTransactionModalOpen(false)} + confirmLoading={loading} + centered + /> + + ) +} diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx index 980c8c784b..c2d81a670d 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/ReviewConfirmModal.tsx @@ -1,13 +1,12 @@ import { Trans, t } from '@lingui/macro' import { Form } from 'antd' -import { useWatch } from 'antd/lib/form/Form' import { JuiceTextArea } from 'components/inputs/JuiceTextArea' import TransactionModal from 'components/modals/TransactionModal' -import { CreateCollapse } from 'packages/v2v3/components/Create/components/CreateCollapse/CreateCollapse' +import { CreateCollapse } from 'packages/v4/components/Create/components/CreateCollapse/CreateCollapse' +import { useEditRulesetTx } from 'packages/v4/hooks/useEditRulesetTx' import { useState } from 'react' -// import { useReconfigureFundingCycle } from '../../../hooks/useReconfigureFundingCycle' +import { emitErrorNotification } from 'utils/notifications' import { useEditCycleFormContext } from '../EditCycleFormContext' -// import { usePrepareSaveEditCycleData } from '../hooks/usePrepareSaveEditCycleData' import { TransactionSuccessModal } from '../TransactionSuccessModal' import { DetailsSectionDiff } from './DetailsSectionDiff' import { PayoutsSectionDiff } from './PayoutsSectionDiff' @@ -26,6 +25,7 @@ export function ReviewConfirmModal({ }) { const [editCycleSuccessModalOpen, setEditCycleSuccessModalOpen] = useState(false) + const [confirmLoading, setConfirmLoading] = useState(false) const { editCycleForm } = useEditCycleFormContext() @@ -36,19 +36,25 @@ export function ReviewConfirmModal({ const formHasChanges = detailsSectionHasDiff || payoutsSectionHasDiff || tokensSectionHasDiff - const memo = useWatch('memo', editCycleForm) - // const { editingFundingCycleConfig } = usePrepareSaveEditCycleData() + const editRulesetTx = useEditRulesetTx() - // const { reconfigureLoading, reconfigureFundingCycle } = - // useReconfigureFundingCycle({ - // editingFundingCycleConfig, - // memo: memo ?? '', - // onComplete: () => { - // editCycleForm?.resetFields() - // setEditCycleSuccessModalOpen(true) - // onClose() - // }, - // }) + const handleConfirm = () => { + setConfirmLoading(true) + editRulesetTx(editCycleForm?.getFieldsValue(true), { + onTransactionPending: () => null, + onTransactionConfirmed: () => { + editCycleForm?.resetFields() + setConfirmLoading(false) + setEditCycleSuccessModalOpen(true) + onClose() + }, + onTransactionError: (error: unknown) => { + console.error(error) + setConfirmLoading(false) + emitErrorNotification(`Error launching ruleset: ${error}`) + }, + }) + } const panelProps = { className: 'text-lg' } @@ -58,12 +64,12 @@ export function ReviewConfirmModal({ open={open} title={Review & confirm} destroyOnClose - onOk={() => null}//reconfigureFundingCycle()} + onOk={handleConfirm} okText={Deploy changes} okButtonProps={{ disabled: !formHasChanges }} cancelButtonProps={{ hidden: true }} onCancel={onClose} - confirmLoading={false}//reconfigureLoading} + confirmLoading={confirmLoading} >

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

Reserved recipients: @@ -136,7 +142,7 @@ export function TokensSectionDiff() { showDiffs />
- )} + ) : null}
} /> diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts index d3179df115..6cfc84c0e0 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/usePayoutsSectionValues.ts @@ -1,9 +1,9 @@ -import { WeiPerEther } from '@ethersproject/constants' import { CurrencyName } from 'constants/currency' import { JBSplit } from 'juice-sdk-core' import { distributionLimitsEqual } from 'packages/v4/utils/distributions' import { MAX_PAYOUT_LIMIT } from 'packages/v4/utils/math' import { splitsListsHaveDiff } from 'packages/v4/utils/v4Splits' +import { parseWad } from 'utils/format/formatNumber' import { useEditCycleFormContext } from '../../EditCycleFormContext' export const usePayoutsSectionValues = () => { @@ -27,10 +27,10 @@ export const usePayoutsSectionValues = () => { const newDistributionLimitNum: number = editCycleForm?.getFieldValue('payoutLimit') const newDistributionLimit = - newDistributionLimitNum ? BigInt(newDistributionLimitNum) * WeiPerEther.toBigInt() : MAX_PAYOUT_LIMIT + newDistributionLimitNum ? parseWad(newDistributionLimitNum).toBigInt() : MAX_PAYOUT_LIMIT const currentDistributionLimitNum = initialFormData?.payoutLimit - const currentDistributionLimit = currentDistributionLimitNum ? BigInt(currentDistributionLimitNum) * WeiPerEther.toBigInt() : MAX_PAYOUT_LIMIT + const currentDistributionLimit = currentDistributionLimitNum ? parseWad(currentDistributionLimitNum).toBigInt() : MAX_PAYOUT_LIMIT const distributionLimitHasDiff = !distributionLimitsEqual(currentDistributionLimit, newDistributionLimit) || diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts index 4f3161da98..934f2a791e 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/ReviewConfirmModal/hooks/useTokensSectionValues.ts @@ -1,4 +1,4 @@ -import { useJBTokenContext } from 'juice-sdk-react' +import { useJBRuleset, useJBTokenContext } from 'juice-sdk-react' import round from 'lodash/round' import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' import { splitsListsHaveDiff } from 'packages/v4/utils/v4Splits' @@ -11,6 +11,7 @@ export const useTokensSectionValues = () => { const formValues: EditCycleFormFields = editCycleForm?.getFieldsValue(true) + const { data: ruleset } = useJBRuleset() const { ruleset: upcomingRuleset, } = useJBUpcomingRuleset() @@ -43,11 +44,13 @@ export const useTokensSectionValues = () => { ) const onlyDiscountRateApplied = - upcomingRuleset && - newMintRate && - round(upcomingRuleset?.weight.toFloat(), 4) === round(newMintRate, 4) + ( + upcomingRuleset && + newMintRate && + round(upcomingRuleset?.weight.toFloat(), 4) === round(newMintRate, 4) + ) || ruleset?.duration === 0 - const mintRateHasDiff = !onlyDiscountRateApplied + const mintRateHasDiff = !onlyDiscountRateApplied const reservedRateHasDiff = Boolean( currentReservedRate && diff --git a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx index d33170692e..0d2e0f5ea9 100644 --- a/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx +++ b/src/packages/v4/views/V4ProjectSettings/EditCyclePage/hooks/useLoadEditCycleData.tsx @@ -6,6 +6,7 @@ import { secondsToOtherUnit, } from 'utils/format/formatTime' +import { Ether } from 'juice-sdk-core' import { useJBRuleset, useJBRulesetMetadata } from 'juice-sdk-react' import { useJBUpcomingRuleset } from 'packages/v4/hooks/useJBUpcomingRuleset' import { usePayoutLimit } from 'packages/v4/hooks/usePayoutLimit' @@ -22,38 +23,35 @@ export const useLoadEditCycleData = () => { const { data: ruleset } = useJBRuleset() const { data: rulesetMetadata } = useJBRulesetMetadata() - const { - ruleset: upcomingRuleset, - } = useJBUpcomingRuleset() + const { ruleset: upcomingRuleset } = useJBUpcomingRuleset() const { splits: reservedTokensSplits } = useV4ReservedSplits() - const { splits: payoutSplits } = useV4CurrentPayoutSplits() + const { data: payoutSplits } = useV4CurrentPayoutSplits() const { data: payoutLimit } = usePayoutLimit() const [editCycleForm] = Form.useForm() + + const payoutLimitAmount = new Ether(payoutLimit.amount).toFloat() useEffect(() => { - if ( - ruleset && - rulesetMetadata - ) { + if (ruleset && rulesetMetadata) { const duration = Number(ruleset.duration) const issuanceRate = upcomingRuleset?.weight.toFloat() ?? 0 const reservedPercent = rulesetMetadata.reservedPercent.formatPercentage() - // : DefaultTokenSettings.reservedTokensPercentage + // : DefaultTokenSettings.reservedTokensPercentage const decayPercent = ruleset.decayPercent.formatPercentage() - // : DefaultTokenSettings.discountRate + // : DefaultTokenSettings.discountRate const redemptionRate = rulesetMetadata.redemptionRate.formatPercentage() - // : DefaultTokenSettings.redemptionRate + // : DefaultTokenSettings.redemptionRate const allowOwnerMinting = rulesetMetadata.allowOwnerMinting - // : DefaultTokenSettings.tokenMinting + // : DefaultTokenSettings.tokenMinting const tokenTransfers = !rulesetMetadata.pauseCreditTransfers - // : DefaultTokenSettings.pauseTransfers + // : DefaultTokenSettings.pauseTransfers const formData: EditCycleFormFields = { duration: secondsToOtherUnit({ @@ -62,15 +60,12 @@ export const useLoadEditCycleData = () => { }), durationUnit: deriveDurationOption(duration), approvalHook: ruleset.approvalHook, - allowSetTerminals: - rulesetMetadata.allowSetTerminals, - allowSetController: - rulesetMetadata.allowSetController, - allowTerminalMigration: - rulesetMetadata.allowTerminalMigration, + allowSetTerminals: rulesetMetadata.allowSetTerminals, + allowSetController: rulesetMetadata.allowSetController, + allowTerminalMigration: rulesetMetadata.allowTerminalMigration, pausePay: rulesetMetadata.pausePay, - payoutSplits, - payoutLimit: payoutLimit ? Number(payoutLimit.amount) : undefined, // TODO: format + payoutSplits: payoutSplits ?? [], + payoutLimit: payoutLimitAmount, payoutLimitCurrency: V4CurrencyName(payoutLimit?.currency) ?? 'ETH', holdFees: rulesetMetadata?.holdFees, issuanceRate, @@ -88,8 +83,8 @@ export const useLoadEditCycleData = () => { setInitialFormData(formData) editCycleForm.setFieldsValue(formData) } - }, [ruleset, rulesetMetadata]) - + }, [ruleset, rulesetMetadata, payoutSplits]) + return { initialFormData, editCycleForm, diff --git a/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx b/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx index 7adeacfc05..cd23f05410 100644 --- a/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx +++ b/src/packages/v4/views/V4ProjectSettings/ProjectDetailsSettingsPage/ProjectDetailsSettingsPage.tsx @@ -1,26 +1,22 @@ import { useForm } from 'antd/lib/form/Form' import { ProjectDetailsForm, ProjectDetailsFormFields } from 'components/Project/ProjectSettings/ProjectDetailsForm' import { PROJECT_PAY_CHARACTER_LIMIT } from 'constants/numbers' -import { PV_V2 } from 'constants/pv' -import { ProjectMetadataContext } from 'contexts/ProjectMetadataContext' +import { useJBProjectMetadataContext } from 'juice-sdk-react' import { uploadProjectMetadata } from 'lib/api/ipfs' -import { revalidateProject } from 'lib/api/nextjs' +import { useEditProjectDetailsTx } from 'packages/v4/hooks/useEditProjectDetailsTx' -import { useEditProjectDetailsTx } from 'packages/v2v3/hooks/transactor/useEditProjectDetailsTx' -import { useCallback, useContext, useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { withoutHttps } from 'utils/http' -import { emitInfoNotification } from 'utils/notifications' +import { emitErrorNotification, emitInfoNotification } from 'utils/notifications' export function ProjectDetailsSettingsPage() { - const { projectId } = useContext(ProjectMetadataContext) - const { projectMetadata, refetchProjectMetadata } = useContext( - ProjectMetadataContext, - ) + const { metadata } = useJBProjectMetadataContext() + const projectMetadata = metadata.data const [loadingSaveChanges, setLoadingSaveChanges] = useState() const [projectForm] = useForm() - const editV2ProjectDetailsTx = useEditProjectDetailsTx() // v4Todo: V4 tx + const editProjectDetailsTx = useEditProjectDetailsTx() const onProjectFormSaved = useCallback(async () => { setLoadingSaveChanges(true) @@ -49,43 +45,34 @@ export function ProjectDetailsSettingsPage() { return } - const txSuccess = await editV2ProjectDetailsTx( - { - cid: uploadedMetadata.Hash, - }, - { - onConfirmed: async () => { + editProjectDetailsTx( + uploadedMetadata.Hash as `0x${string}`, { + onTransactionPending: () => null, + onTransactionConfirmed: () => { + projectForm?.resetFields() setLoadingSaveChanges(false) - emitInfoNotification('Project details saved', { description: 'Your project details have been saved.', }) - if (projectId) { - await revalidateProject({ - pv: PV_V2, - projectId: String(projectId), - }) - } - refetchProjectMetadata() - }, - onError: () => { - setLoadingSaveChanges(false) + // v4Todo: part of v2, not sure if necessary + // if (projectId) { + // await revalidateProject({ + // pv: PV_V4, + // projectId: String(projectId), + // }) + // } }, - onCancelled: () => { + onTransactionError: (error: unknown) => { + console.error(error) setLoadingSaveChanges(false) + emitErrorNotification(`Error launching ruleset: ${error}`) }, - }, + } ) - - if (!txSuccess) { - setLoadingSaveChanges(false) - } }, [ - editV2ProjectDetailsTx, + editProjectDetailsTx, projectForm, - projectId, - refetchProjectMetadata, projectMetadata, ]) @@ -100,8 +87,7 @@ export function ProjectDetailsSettingsPage() { coverImageUri: projectMetadata?.coverImageUri ?? '', description: projectMetadata?.description ?? '', projectTagline: projectMetadata?.projectTagline ?? '', - projectRequiredOFACCheck: - projectMetadata?.projectRequiredOFACCheck ?? false, + projectRequiredOFACCheck: false, // OFAC not supported in V4 yet twitter: projectMetadata?.twitter ?? '', discord, telegram, @@ -117,7 +103,6 @@ export function ProjectDetailsSettingsPage() { projectMetadata?.coverImageUri, projectMetadata?.description, projectMetadata?.projectTagline, - projectMetadata?.projectRequiredOFACCheck, projectMetadata?.twitter, projectMetadata?.discord, projectMetadata?.telegram, diff --git a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx index b15fb65127..c9ff01032d 100644 --- a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx +++ b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsContent.tsx @@ -5,6 +5,8 @@ import { Button, Layout } from 'antd' import Link from 'next/link' import { useMemo } from 'react' import { twJoin } from 'tailwind-merge' +import { ArchiveProjectSettingsPage } from './ArchiveProjectSettingsPage' +import { CreateErc20TokenSettingsPage } from './CreateErc20TokenSettingsPage' import { EditCyclePage } from './EditCyclePage/EditCyclePage' import { useSettingsPagePath } from './hooks/useSettingsPagePath' import { ProjectDetailsSettingsPage } from './ProjectDetailsSettingsPage/ProjectDetailsSettingsPage' @@ -15,15 +17,15 @@ const SettingsPageComponents: { [k in SettingsPageKey]: () => JSX.Element | null } = { general: ProjectDetailsSettingsPage, - handle: () => null, //ProjectHandleSettingsPage, + // handle: () => null, //ProjectHandleSettingsPage, cycle: EditCyclePage, // nfts: () => null, //EditNftsPage, payouts: () => null, //PayoutsSettingsPage, - reservedtokens: () => null, //ReservedTokensSettingsPage, - transferownership: () => null, //TransferOwnershipSettingsPage, - archiveproject: () => null, //ArchiveProjectSettingsPage, - heldfees: () => null, //ProcessHeldFeesPage, - createerc20: () => null, //CreateErc20TokenSettingsPage, + // reservedtokens: () => null, //ReservedTokensSettingsPage, + // transferownership: () => null, //TransferOwnershipSettingsPage, + archiveproject: ArchiveProjectSettingsPage, + // heldfees: () => null, //ProcessHeldFeesPage, + createerc20: CreateErc20TokenSettingsPage, } const V4SettingsPageKeyTitleMap = ( @@ -32,14 +34,14 @@ const V4SettingsPageKeyTitleMap = ( [k in SettingsPageKey]: string } => ({ general: t`General`, - handle: t`Project handle`, + // handle: t`Project handle`, cycle: t`Cycle configuration`, payouts: t`Payouts`, - reservedtokens: t`Reserved token recipients`, + // reservedtokens: t`Reserved token recipients`, // nfts: hasExistingNfts ? t`Edit NFT collection` : t`Launch New NFT Collection`, - transferownership: t`Transfer ownership`, + // transferownership: t`Transfer ownership`, archiveproject: t`Archive project`, - heldfees: t`Process held fees`, + // heldfees: t`Process held fees`, createerc20: t`Create ERC-20 token`, }) diff --git a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsDashboard.tsx b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsDashboard.tsx index da2c5c8d41..67382b8867 100644 --- a/src/packages/v4/views/V4ProjectSettings/ProjectSettingsDashboard.tsx +++ b/src/packages/v4/views/V4ProjectSettings/ProjectSettingsDashboard.tsx @@ -14,14 +14,14 @@ import { useSettingsPagePath } from './hooks/useSettingsPagePath' export type SettingsPageKey = | 'general' - | 'handle' + // | 'handle' -> commenting out not necessary for v4 | 'cycle' // | 'nfts' | 'payouts' - | 'reservedtokens' - | 'transferownership' + // | 'reservedtokens' + // | 'transferownership' | 'archiveproject' - | 'heldfees' + // | 'heldfees' | 'createerc20' function SettingsCard({ children }: { children: React.ReactNode }) { @@ -146,6 +146,11 @@ export function ProjectSettingsDashboard() { Basic details +
  • + + Archive + +
  • {/*
  • Project handle @@ -155,13 +160,13 @@ export function ProjectSettingsDashboard() { Cycle configuration} + title={Ruleset configuration} subtitle={ - Make changes to your cycle settings and rules + Make changes to your ruleset settings and rules } > - Edit next cycle + Edit next ruleset
  • )} -
  • + {/*
  • Process held fees -
  • + */} - Manage} subtitle={Manage your project's state and ownership} > @@ -193,13 +198,8 @@ export function ProjectSettingsDashboard() { Transfer ownership -
  • - - Archive - -
  • -
    + */}
    diff --git a/src/pages/api/auth/challenge-message.ts b/src/pages/api/auth/challenge-message.ts index 8fd2aa1a9d..7d576a954d 100644 --- a/src/pages/api/auth/challenge-message.ts +++ b/src/pages/api/auth/challenge-message.ts @@ -14,8 +14,10 @@ const WalletSigningRequestMessageTemplate = template( */ const handler = async (req: NextApiRequest, res: NextApiResponse) => { try { - if (req.method !== 'GET') + if (req.method !== 'GET') { return res.status(405).json({ message: 'Method not allowed.' }) + } + const { walletAddress } = req.query ?? {} if (!walletAddress || typeof walletAddress !== 'string') { return res.status(400).json({ message: 'Invalid request.' }) diff --git a/src/pages/api/ens/resolve/[address].ts b/src/pages/api/ens/resolve/[address].ts index 013d94db17..1c66a554d2 100644 --- a/src/pages/api/ens/resolve/[address].ts +++ b/src/pages/api/ens/resolve/[address].ts @@ -9,7 +9,7 @@ const logger = getLogger('api/ens/resolve/[address]') const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== 'GET') { - return res.status(404) + return res.status(405).end() } try { @@ -20,7 +20,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (readNetwork.name === NetworkName.sepolia) { // ethers v5 doesn't support ens on sepolia - return res.status(404).json({ error: 'ens not supported on sepolia' }) + return res.status(400).json({ error: 'ens not supported on sepolia' }) } let response diff --git a/src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts b/src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts index bd9267fdfe..7fb4ede6b1 100644 --- a/src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts +++ b/src/pages/api/juicebox/jb-721-delegate/[dataSourceAddress].ts @@ -180,7 +180,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { enableCors(res) if (req.method !== 'GET') { - return res.status(404) + return res.status(405).end() } try { diff --git a/src/pages/api/juicebox/prices/ethusd.ts b/src/pages/api/juicebox/prices/ethusd.ts index 9e2a3b53d7..49a843a86d 100644 --- a/src/pages/api/juicebox/prices/ethusd.ts +++ b/src/pages/api/juicebox/prices/ethusd.ts @@ -17,7 +17,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { enableCors(res) if (req.method !== 'GET') { - return res.status(404) + return res.status(405).end() } try { diff --git a/src/pages/api/juicebox/projectHandle/[projectId].ts b/src/pages/api/juicebox/projectHandle/[projectId].ts index 8690c5b4d1..25643c9e52 100644 --- a/src/pages/api/juicebox/projectHandle/[projectId].ts +++ b/src/pages/api/juicebox/projectHandle/[projectId].ts @@ -9,7 +9,7 @@ const logger = getLogger('api/juicebox/projectHandle/[projectId]') const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== 'GET') { - return res.status(404).json({ error: 'not found' }) + return res.status(405).json({ error: 'method not supported' }) } try { diff --git a/src/pages/api/projects/health.ts b/src/pages/api/projects/health.ts index b8e38f3cf6..a2895a6fa3 100644 --- a/src/pages/api/projects/health.ts +++ b/src/pages/api/projects/health.ts @@ -36,7 +36,9 @@ const handler: NextApiHandler = async (_, res) => { (await paginateDepleteQuery({ client: serverClient, document: DbProjectsDocument, - })) as unknown as Json>[] + })) as unknown as Json< + Pick + >[] ).map(formatSGProjectForDB) report += `\n\n${dbProjectsCount} projects in database` diff --git a/src/pages/api/projects/trending.ts b/src/pages/api/projects/trending.ts index 321fde416b..e4c122e1c0 100644 --- a/src/pages/api/projects/trending.ts +++ b/src/pages/api/projects/trending.ts @@ -1,4 +1,4 @@ -import { PV_V1, PV_V2 } from 'constants/pv' +import { PV_V1, PV_V2, PV_V4 } from 'constants/pv' import { RomanStormVariables } from 'constants/romanStorm' import { BigNumber } from 'ethers' import { @@ -8,11 +8,13 @@ import { TrendingProjectsDocument, TrendingProjectsQuery, } from 'generated/graphql' -import { serverClient } from 'lib/apollo/serverClient' +import { serverClient, v4SepoliaServerClient } from 'lib/apollo/serverClient' import { NextApiHandler } from 'next' import { V1ArchivedProjectIds } from 'packages/v1/constants/archivedProjects' import { V2ArchivedProjectIds } from 'packages/v2v3/constants/archivedProjects' +import { TrendingProjectsV4Document } from 'packages/v4/graphql/client/graphql' import { getSubgraphIdForProject } from 'utils/graph' +import { sepolia } from 'viem/chains' const CACHE_MAXAGE = 60 * 5 // 5 minutes @@ -31,25 +33,40 @@ const handler: NextApiHandler = async (req, res) => { const rawFirst = req.query.count // TODO probably can use Yup for this const first = typeof rawFirst === 'string' ? parseInt(rawFirst) : undefined try { - const projectsRes = await serverClient.query< - TrendingProjectsQuery, - QueryProjectsArgs - >({ - query: TrendingProjectsDocument, - variables: { - where: { - trendingScore_gt: '0' as any, // eslint-disable-line @typescript-eslint/no-explicit-any - ...(ARCHIVED_SUBGRAPH_IDS.length - ? { id_not_in: ARCHIVED_SUBGRAPH_IDS } - : {}), // `id_not_in: ` will return 0 results + const [projectsRes, v4SepoliaProjectsRes] = await Promise.all([ + serverClient.query({ + query: TrendingProjectsDocument, + variables: { + where: { + trendingScore_gt: '0' as any, // eslint-disable-line @typescript-eslint/no-explicit-any + ...(ARCHIVED_SUBGRAPH_IDS.length + ? { id_not_in: ARCHIVED_SUBGRAPH_IDS } + : {}), // `id_not_in: ` will return 0 results + }, + first, + orderBy: Project_OrderBy.trendingScore, + orderDirection: OrderDirection.desc, + }, + }), + v4SepoliaServerClient.query({ + query: TrendingProjectsV4Document, + variables: { + where: { + trendingScore_gt: '0' as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }, + first, + orderBy: Project_OrderBy.trendingScore, + orderDirection: OrderDirection.desc, }, - first, - orderBy: Project_OrderBy.trendingScore, - orderDirection: OrderDirection.desc, - }, - }) + }), + ]) - const projects = [...projectsRes.data.projects] + const projects = [ + ...projectsRes.data.projects, + ...v4SepoliaProjectsRes.data.projects.map(p => { + return { ...p, chainId: sepolia.id, pv: PV_V4 } + }), + ] try { const romanProjectIndex = projects.findIndex( diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 7a29a18eef..ebb6cdf2be 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -1,16 +1,38 @@ import { AppWrapper } from 'components/common/CoreAppWrapper/CoreAppWrapper' import { Head } from 'components/common/Head/Head' import { CV_V3 } from 'constants/cv' +import { FEATURE_FLAGS } from 'constants/featureFlags' import { SiteBaseUrl } from 'constants/url' import { TransactionProvider } from 'contexts/Transaction/TransactionProvider' -import { Create } from 'packages/v2v3/components/Create/Create' +import { Create as V2V3Create } from 'packages/v2v3/components/Create/Create' import { V2V3ContractsProvider } from 'packages/v2v3/contexts/Contracts/V2V3ContractsProvider' import { V2V3CurrencyProvider } from 'packages/v2v3/contexts/V2V3CurrencyProvider' +import { Create as V4Create } from 'packages/v4/components/Create/Create' +import { wagmiConfig } from 'packages/v4/wagmiConfig' import { Provider } from 'react-redux' import store from 'redux/store' +import { featureFlagEnabled } from 'utils/featureFlags' import globalGetServerSideProps from 'utils/next-server/globalGetServerSideProps' +import { WagmiProvider } from 'wagmi' -export default function V2CreatePage() { +export default function CreatePage() { + let contentByVersion = ( + + + + + + + + ) + + if (featureFlagEnabled(FEATURE_FLAGS.V4)) { + contentByVersion = ( + + + + ) + } return ( <> - - {/* New projects will be launched using V3 contracts. */} - - - - - - - - + {contentByVersion} ) diff --git a/src/redux/hooks/useEditingDistributionLimit.ts b/src/redux/hooks/useEditingDistributionLimit.ts index e5afbd2700..8d707517c9 100644 --- a/src/redux/hooks/useEditingDistributionLimit.ts +++ b/src/redux/hooks/useEditingDistributionLimit.ts @@ -1,5 +1,6 @@ +import { AddressZero } from '@ethersproject/constants' +import { ETH_TOKEN_ADDRESS } from 'constants/juiceboxTokens' import { BigNumber } from 'ethers' -import { ETH_TOKEN_ADDRESS } from 'packages/v2v3/constants/juiceboxTokens' import { useDefaultJBETHPaymentTerminal } from 'packages/v2v3/hooks/defaultContracts/useDefaultJBETHPaymentTerminal' import { V2V3CurrencyOption } from 'packages/v2v3/models/currencyOption' import { V2V3_CURRENCY_ETH } from 'packages/v2v3/utils/currency' @@ -51,7 +52,6 @@ export const useEditingDistributionLimit = (): [ const setDistributionLimit = useCallback( (input: ReduxDistributionLimit | undefined) => { - if (!defaultJBETHPaymentTerminal) return if (!input) { dispatch(editingV2ProjectActions.setFundAccessConstraints([])) return @@ -60,7 +60,7 @@ export const useEditingDistributionLimit = (): [ dispatch( editingV2ProjectActions.setFundAccessConstraints([ { - terminal: defaultJBETHPaymentTerminal?.address, + terminal: defaultJBETHPaymentTerminal?.address ?? AddressZero, token: ETH_TOKEN_ADDRESS, distributionLimit: fromWad(input.amount), distributionLimitCurrency, @@ -75,10 +75,8 @@ export const useEditingDistributionLimit = (): [ const setDistributionLimitAmount = useCallback( (input: BigNumber) => { - if (!defaultJBETHPaymentTerminal) return - const currentFundAccessConstraint = fundAccessConstraints?.[0] ?? { - terminal: defaultJBETHPaymentTerminal?.address, + terminal: defaultJBETHPaymentTerminal?.address ?? AddressZero, token: ETH_TOKEN_ADDRESS, distributionLimitCurrency: V2V3_CURRENCY_ETH.toString(), overflowAllowance: '0', @@ -100,12 +98,11 @@ export const useEditingDistributionLimit = (): [ const setDistributionLimitCurrency = useCallback( (input: V2V3CurrencyOption) => { - if (!defaultJBETHPaymentTerminal) return dispatch( editingV2ProjectActions.setDistributionLimitCurrency(input.toString()), ) }, - [defaultJBETHPaymentTerminal, dispatch], + [dispatch], ) return [ diff --git a/src/types/database.types.ts b/src/types/database.types.ts index 66018c63de..8bb0c1630a 100644 --- a/src/types/database.types.ts +++ b/src/types/database.types.ts @@ -191,6 +191,7 @@ export type Database = { payments_count: number project_id: number pv: string + chain_id: number redeem_count: number redeem_volume: string redeem_voume_usd: string @@ -224,6 +225,7 @@ export type Database = { payments_count: number project_id: number pv: string + chain_id: number redeem_count: number redeem_volume: string redeem_voume_usd: string @@ -257,6 +259,7 @@ export type Database = { payments_count?: number project_id?: number pv?: string + chain_id?: number redeem_count?: number redeem_volume?: string redeem_voume_usd?: string diff --git a/src/utils/sgDbProjects.ts b/src/utils/sgDbProjects.ts index d696e65d0f..7657765023 100644 --- a/src/utils/sgDbProjects.ts +++ b/src/utils/sgDbProjects.ts @@ -1,3 +1,4 @@ +import { Project } from 'generated/graphql' import { ipfsGatewayFetch } from 'lib/api/ipfs' import { DBProject, DBProjectRow, SGSBCompareKey } from 'models/dbProject' import { Json } from 'models/json' @@ -8,8 +9,6 @@ import { } from 'models/project-tags' import { ProjectMetadata, consolidateMetadata } from 'models/projectMetadata' import { PV } from 'models/pv' - -import { Project } from 'generated/graphql' import { formatError } from './format/formatError' import { parseBigNumberKeyVals } from './graph' import { isIpfsCID } from './ipfs' @@ -84,6 +83,7 @@ export function parseDBProjectsRow(p: DBProjectRow): Json { paymentsCount: p.payments_count, projectId: p.project_id, pv: p.pv as PV, + chainId: p.chain_id, redeemCount: p.redeem_count, redeemVolume: p.redeem_volume, redeemVolumeUSD: p.redeem_voume_usd, @@ -123,6 +123,7 @@ export function formatDBProjectRow( payments_count: p.paymentsCount, project_id: p.projectId, pv: p.pv, + chain_id: p.chainId, redeem_count: p.redeemCount, redeem_volume: p.redeemVolume, redeem_voume_usd: p.redeemVolumeUSD, @@ -152,7 +153,7 @@ export function formatSgProjectsForUpdate({ retryIpfs, returnAllProjects, }: { - sgProjects: Json>[] + sgProjects: Json>[] dbProjects: Record> retryIpfs?: boolean returnAllProjects?: boolean @@ -243,7 +244,7 @@ export async function formatWithMetadata({ sgProject, dbProject, }: { - sgProject: Json> + sgProject: Json> dbProject: | Pick< DBProject, @@ -351,8 +352,8 @@ function padBigNumForSort(bn: string) { } export function formatSGProjectForDB( - p: Json>, -): Json> { + p: Json>, +): Json> { return { ...p, // Adjust BigNumber values before we compare them to database values diff --git a/supabase/migrations/20240913212503_project_pv_v4.sql b/supabase/migrations/20240913212503_project_pv_v4.sql new file mode 100644 index 0000000000..78bac6e8ec --- /dev/null +++ b/supabase/migrations/20240913212503_project_pv_v4.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.projects +DROP CONSTRAINT projects_pv_check; + +ALTER TABLE public.projects +ADD CONSTRAINT projects_pv_check +CHECK (pv IN ('1', '2', '4')); + +ALTER TABLE public.projects +add COLUMN "chain_id" int \ No newline at end of file diff --git a/supabase/migrations/20240913212504_project_pv_v4_drop.sql b/supabase/migrations/20240913212504_project_pv_v4_drop.sql new file mode 100644 index 0000000000..6ef0acf4b1 --- /dev/null +++ b/supabase/migrations/20240913212504_project_pv_v4_drop.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.projects +DROP CONSTRAINT projects_pv_check; \ No newline at end of file diff --git a/supabase/migrations/20240913212505_project_pv_v4_drop.sql b/supabase/migrations/20240913212505_project_pv_v4_drop.sql new file mode 100644 index 0000000000..1c76770b44 --- /dev/null +++ b/supabase/migrations/20240913212505_project_pv_v4_drop.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.projects +add COLUMN "chain_id" int \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ed85eeb5e8..9aae589e59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12837,18 +12837,18 @@ jsx-ast-utils@^3.3.5: object.assign "^4.1.4" object.values "^1.1.6" -juice-sdk-core@^10.0.3-alpha: - version "10.0.3-alpha" - resolved "https://registry.yarnpkg.com/juice-sdk-core/-/juice-sdk-core-10.0.3-alpha.tgz#dcd1afa2faa13f42559ced3b308e5e886892c7bd" - integrity sha512-E+Wx7zv/PCOWrY9Co62ilyHa/6ge44xltTsNeaF96a3d3jWhWlkLlrDBrHDnT48VvClw0LbnRWOFvaB662OyFg== +juice-sdk-core@^11.0.0-alpha: + version "11.0.0-alpha" + resolved "https://registry.yarnpkg.com/juice-sdk-core/-/juice-sdk-core-11.0.0-alpha.tgz#dd0228158de3c3a2799ea6ba3b1f22c8c0635d60" + integrity sha512-wqKAb9f88579CiTZP6MNb08TOStQ4OqgLQYh7cwmONcpon09+A9tyHPCxhgKjGHGevzF80Wv2Z0dPjgujMRI2Q== dependencies: bs58 "^5.0.0" fpnum "^1.0.0" -juice-sdk-react@^10.0.1-alpha: - version "10.0.1-alpha" - resolved "https://registry.yarnpkg.com/juice-sdk-react/-/juice-sdk-react-10.0.1-alpha.tgz#a1a9273292ed7b6f2f97e87e60c131b8cd53ee03" - integrity sha512-Lpdymh4bt2YBba2htXn/RB+Kohs1iMhu2mqzadwAtrYpsFz+dKkL5GFA1YLLSC96d31KSVJX/bArX46Uc5hnoA== +juice-sdk-react@^11.0.0-alpha: + version "11.0.0-alpha" + resolved "https://registry.yarnpkg.com/juice-sdk-react/-/juice-sdk-react-11.0.0-alpha.tgz#15b93be75d80e9e83a0f45b4cc8c81d3f1828e49" + integrity sha512-G0zCPMCozfAK0Y9mxGMtMOYW8Bm3ml5bEEr/pKFbrrxIs+sEUnK4f5CZWiaA6fZlN8QnUbXbIRteFjv+RB2Yzg== juice@^10.0.0: version "10.0.0"