diff --git a/actions/createMultisigRealm.ts b/actions/createMultisigRealm.ts index e972fc5704..7968b3b89b 100644 --- a/actions/createMultisigRealm.ts +++ b/actions/createMultisigRealm.ts @@ -5,6 +5,7 @@ import { SetRealmAuthorityAction, VoteThresholdPercentage, VoteTipping, + withCreateNativeTreasury, } from '@solana/spl-governance' import { withCreateMintGovernance } from '@solana/spl-governance' @@ -28,8 +29,9 @@ import { } from '@tools/sdk/units' import { getWalletPublicKey, - sendTransactions, + sendTransactionsV2, SequenceType, + transactionInstructionsToTypedInstructionsSets, WalletSigner, } from 'utils/sendTransactions' import { chunks } from '@utils/helpers' @@ -209,6 +211,15 @@ export const createMultisigRealm = async ( walletPk ) + await withCreateNativeTreasury( + realmInstructions, + programId, + communityMintGovPk, + walletPk + ) + + console.log('CREATE NFT REALM governance config created', config) + // Set the community governance as the realm authority withSetRealmAuthority( realmInstructions, @@ -227,13 +238,26 @@ export const createMultisigRealm = async ( [] ) - const tx = await sendTransactions( + const tx = await sendTransactionsV2({ connection, + showUiComponent: true, wallet, - [mintsSetupInstructions, ...councilMembersChunks, realmInstructions], - [mintsSetupSigners, ...councilMembersSignersChunks, realmSigners], - SequenceType.Sequential - ) + signersSet: [ + mintsSetupSigners, + ...councilMembersSignersChunks, + realmSigners, + ], + TransactionInstructions: [ + mintsSetupInstructions, + ...councilMembersChunks, + realmInstructions, + ].map((x) => + transactionInstructionsToTypedInstructionsSets( + x, + SequenceType.Sequential + ) + ), + }) return { tx, diff --git a/actions/createMultisigWallet.ts b/actions/createMultisigWallet.ts new file mode 100644 index 0000000000..d28010c8e1 --- /dev/null +++ b/actions/createMultisigWallet.ts @@ -0,0 +1,100 @@ +import { Connection, PublicKey } from '@solana/web3.js' + +import { + sendTransactionsV2, + SequenceType, + transactionInstructionsToTypedInstructionsSets, + WalletSigner, +} from 'utils/sendTransactions' +import { chunks } from '@utils/helpers' + +import { prepareRealmCreation } from '@tools/governance/prepareRealmCreation' + +/// Creates multisig realm with community mint with 0 supply +/// and council mint used as multisig token +interface MultisigWallet { + connection: Connection + wallet: WalletSigner + programIdAddress: string + + realmName: string + councilYesVotePercentage: number + councilWalletPks: PublicKey[] +} + +export default async function createMultisigWallet({ + connection, + wallet, + programIdAddress, + realmName, + + councilYesVotePercentage, + councilWalletPks, +}: MultisigWallet) { + const { + communityMintPk, + councilMintPk, + realmPk, + realmInstructions, + realmSigners, + mintsSetupInstructions, + mintsSetupSigners, + councilMembersInstructions, + } = await prepareRealmCreation({ + connection, + wallet, + programIdAddress, + + realmName, + tokensToGovernThreshold: undefined, + + existingCommunityMintPk: undefined, + communityMintSupplyFactor: undefined, + transferCommunityMintAuthority: true, + communityYesVotePercentage: councilYesVotePercentage, + + createCouncil: true, + existingCouncilMintPk: undefined, + transferCouncilMintAuthority: true, + councilWalletPks, + }) + + try { + const councilMembersChunks = chunks(councilMembersInstructions, 10) + // only walletPk needs to sign the minting instructions and it's a signer by default and we don't have to include any more signers + const councilMembersSignersChunks = Array(councilMembersChunks.length).fill( + [] + ) + console.log('CREATE MULTISIG WALLET: sending transactions') + const tx = await sendTransactionsV2({ + connection, + showUiComponent: true, + wallet, + signersSet: [ + mintsSetupSigners, + ...councilMembersSignersChunks, + realmSigners, + ], + TransactionInstructions: [ + mintsSetupInstructions, + ...councilMembersChunks, + realmInstructions, + ].map((x) => + transactionInstructionsToTypedInstructionsSets( + x, + SequenceType.Sequential + ) + ), + }) + + return { + tx, + realmPk, + communityMintPk, + councilMintPk, + } + } catch (ex) { + console.error(ex) + throw ex + } +} diff --git a/actions/createNFTRealm.ts b/actions/createNFTRealm.ts new file mode 100644 index 0000000000..904aae0b73 --- /dev/null +++ b/actions/createNFTRealm.ts @@ -0,0 +1,272 @@ +import { + SetRealmAuthorityAction, + SYSTEM_PROGRAM_ID, + withCreateTokenOwnerRecord, + withSetRealmAuthority, +} from '@solana/spl-governance' + +import { + Connection, + Keypair, + PublicKey, + TransactionInstruction, +} from '@solana/web3.js' +import { AnchorProvider, Wallet } from '@project-serum/anchor' + +import { + sendTransactionsV2, + SequenceType, + WalletSigner, + transactionInstructionsToTypedInstructionsSets, +} from 'utils/sendTransactions' +import { chunks } from '@utils/helpers' +import { nftPluginsPks } from '@hooks/useVotingPlugins' + +import { + getNftVoterWeightRecord, + getNftMaxVoterWeightRecord, + getNftRegistrarPDA, +} from 'NftVotePlugin/sdk/accounts' +import { NftVoterClient } from '@solana/governance-program-library' + +import { prepareRealmCreation } from '@tools/governance/prepareRealmCreation' +interface NFTRealm { + connection: Connection + wallet: WalletSigner + programIdAddress: string + + realmName: string + collectionAddress: string + collectionCount: number + tokensToGovernThreshold: number | undefined + + communityYesVotePercentage: number + existingCommunityMintPk: PublicKey | undefined + // communityMintSupplyFactor: number | undefined + + createCouncil: boolean + existingCouncilMintPk: PublicKey | undefined + transferCouncilMintAuthority: boolean | undefined + councilWalletPks: PublicKey[] +} + +export default async function createNFTRealm({ + connection, + wallet, + programIdAddress, + realmName, + tokensToGovernThreshold = 1, + + collectionAddress, + collectionCount, + + existingCommunityMintPk, + communityYesVotePercentage, + // communityMintSupplyFactor: rawCMSF, + + createCouncil = false, + existingCouncilMintPk, + transferCouncilMintAuthority = true, + // councilYesVotePercentage, + councilWalletPks, +}: NFTRealm) { + const options = AnchorProvider.defaultOptions() + const provider = new AnchorProvider(connection, wallet as Wallet, options) + const nftClient = await NftVoterClient.connect(provider) + + const { + communityMintGovPk, + communityMintPk, + councilMintPk, + realmPk, + walletPk, + programIdPk, + programVersion, + minCommunityTokensToCreateAsMintValue, + realmInstructions, + realmSigners, + mintsSetupInstructions, + mintsSetupSigners, + councilMembersInstructions, + } = await prepareRealmCreation({ + connection, + wallet, + programIdAddress, + + realmName, + tokensToGovernThreshold, + + existingCommunityMintPk, + nftCollectionCount: collectionCount, + communityMintSupplyFactor: undefined, + transferCommunityMintAuthority: false, // delay this until we have created NFT instructions + communityYesVotePercentage, + + createCouncil, + existingCouncilMintPk, + transferCouncilMintAuthority, + councilWalletPks, + + additionalRealmPlugins: [ + new PublicKey(nftPluginsPks[0]), + new PublicKey(nftPluginsPks[0]), + ], + }) + + console.log('NFT REALM realm public-key', realmPk.toBase58()) + const { registrar } = await getNftRegistrarPDA( + realmPk, + communityMintPk, + nftClient!.program.programId + ) + const instructionCR = await nftClient!.program.methods + .createRegistrar(10) // Max collections + .accounts({ + registrar, + realm: realmPk, + governanceProgramId: programIdPk, + // realmAuthority: communityMintGovPk, + realmAuthority: walletPk, + governingTokenMint: communityMintPk, + payer: walletPk, + systemProgram: SYSTEM_PROGRAM_ID, + }) + .instruction() + + console.log( + 'CREATE NFT REALM registrar PDA', + registrar.toBase58(), + instructionCR + ) + + const { maxVoterWeightRecord } = await getNftMaxVoterWeightRecord( + realmPk, + communityMintPk, + nftClient!.program.programId + ) + const instructionMVWR = await nftClient!.program.methods + .createMaxVoterWeightRecord() + .accounts({ + maxVoterWeightRecord, + governanceProgramId: programIdPk, + realm: realmPk, + realmGoverningTokenMint: communityMintPk, + payer: walletPk, + systemProgram: SYSTEM_PROGRAM_ID, + }) + .instruction() + console.log( + 'CREATE NFT REALM max voter weight record', + maxVoterWeightRecord.toBase58(), + instructionMVWR + ) + + const instructionCC = await nftClient!.program.methods + .configureCollection(minCommunityTokensToCreateAsMintValue, collectionCount) + .accounts({ + registrar, + realm: realmPk, + // realmAuthority: communityMintGovPk, + realmAuthority: walletPk, + collection: new PublicKey(collectionAddress), + maxVoterWeightRecord: maxVoterWeightRecord, + }) + .instruction() + + console.log( + 'CREATE NFT REALM configure collection', + minCommunityTokensToCreateAsMintValue, + instructionCC + ) + + const nftConfigurationInstructions: TransactionInstruction[] = [ + instructionCR, + instructionMVWR, + instructionCC, + ] + + // Set the community governance as the realm authority + withSetRealmAuthority( + nftConfigurationInstructions, + programIdPk, + programVersion, + realmPk, + walletPk, + communityMintGovPk, + SetRealmAuthorityAction.SetChecked + ) + + const { voterWeightPk } = await getNftVoterWeightRecord( + realmPk, + communityMintPk, + walletPk, + nftClient.program.programId + ) + console.log('NFT realm voter weight', voterWeightPk.toBase58()) + const createVoterWeightRecord = await nftClient.program.methods + .createVoterWeightRecord(walletPk) + .accounts({ + voterWeightRecord: voterWeightPk, + governanceProgramId: programIdPk, + realm: realmPk, + realmGoverningTokenMint: communityMintPk, + payer: walletPk, + systemProgram: SYSTEM_PROGRAM_ID, + }) + .instruction() + console.log( + 'NFT realm voter weight record instruction', + createVoterWeightRecord + ) + nftConfigurationInstructions.push(createVoterWeightRecord) + await withCreateTokenOwnerRecord( + nftConfigurationInstructions, + programIdPk, + realmPk, + walletPk, + communityMintPk, + walletPk + ) + + try { + const councilMembersChunks = chunks(councilMembersInstructions, 10) + // only walletPk needs to sign the minting instructions and it's a signer by default and we don't have to include any more signers + const councilMembersSignersChunks = Array(councilMembersChunks.length).fill( + [] + ) + const nftSigners: Keypair[] = [] + console.log('CREATE NFT REALM: sending transactions') + const tx = await sendTransactionsV2({ + connection, + showUiComponent: true, + wallet, + signersSet: [ + mintsSetupSigners, + ...councilMembersSignersChunks, + realmSigners, + nftSigners, + ], + TransactionInstructions: [ + mintsSetupInstructions, + ...councilMembersChunks, + realmInstructions, + nftConfigurationInstructions, + ].map((x) => + transactionInstructionsToTypedInstructionsSets( + x, + SequenceType.Sequential + ) + ), + }) + + return { + tx, + realmPk, + communityMintPk, + councilMintPk, + } + } catch (ex) { + console.error(ex) + throw ex + } +} diff --git a/actions/createTokenizedRealm.ts b/actions/createTokenizedRealm.ts new file mode 100644 index 0000000000..6c7b531f86 --- /dev/null +++ b/actions/createTokenizedRealm.ts @@ -0,0 +1,116 @@ +import { Connection, PublicKey } from '@solana/web3.js' + +import { + sendTransactionsV2, + transactionInstructionsToTypedInstructionsSets, + SequenceType, + WalletSigner, +} from 'utils/sendTransactions' +import { chunks } from '@utils/helpers' + +import { prepareRealmCreation } from '@tools/governance/prepareRealmCreation' + +interface TokenizedRealm { + connection: Connection + wallet: WalletSigner + programIdAddress: string + + realmName: string + tokensToGovernThreshold: number | undefined + + communityYesVotePercentage: number + existingCommunityMintPk: PublicKey | undefined + transferCommunityMintAuthority: boolean | undefined + communityMintSupplyFactor: number | undefined + + createCouncil: boolean + existingCouncilMintPk: PublicKey | undefined + transferCouncilMintAuthority: boolean | undefined + councilWalletPks: PublicKey[] +} + +export default async function createTokenizedRealm({ + connection, + wallet, + programIdAddress, + realmName, + tokensToGovernThreshold, + + existingCommunityMintPk, + transferCommunityMintAuthority = true, + communityYesVotePercentage, + communityMintSupplyFactor: rawCMSF, + + createCouncil = false, + existingCouncilMintPk, + transferCouncilMintAuthority = true, + // councilYesVotePercentage, + councilWalletPks, +}: TokenizedRealm) { + const { + communityMintPk, + councilMintPk, + realmPk, + realmInstructions, + realmSigners, + mintsSetupInstructions, + mintsSetupSigners, + councilMembersInstructions, + } = await prepareRealmCreation({ + connection, + wallet, + programIdAddress, + + realmName, + tokensToGovernThreshold, + + existingCommunityMintPk, + communityMintSupplyFactor: rawCMSF, + transferCommunityMintAuthority, + communityYesVotePercentage, + + createCouncil, + existingCouncilMintPk, + transferCouncilMintAuthority, + councilWalletPks, + }) + + try { + const councilMembersChunks = chunks(councilMembersInstructions, 10) + // only walletPk needs to sign the minting instructions and it's a signer by default and we don't have to include any more signers + const councilMembersSignersChunks = Array(councilMembersChunks.length).fill( + [] + ) + console.log('CREATE GOV TOKEN REALM: sending transactions') + const tx = await sendTransactionsV2({ + connection, + showUiComponent: true, + wallet, + signersSet: [ + mintsSetupSigners, + ...councilMembersSignersChunks, + realmSigners, + ], + TransactionInstructions: [ + mintsSetupInstructions, + ...councilMembersChunks, + realmInstructions, + ].map((x) => + transactionInstructionsToTypedInstructionsSets( + x, + SequenceType.Sequential + ) + ), + }) + + return { + tx, + realmPk, + communityMintPk, + councilMintPk, + } + } catch (ex) { + console.error(ex) + throw ex + } +} diff --git a/components/AssetsList/BaseGovernanceForm.tsx b/components/AssetsList/BaseGovernanceForm.tsx index 0304190793..b12b13901b 100644 --- a/components/AssetsList/BaseGovernanceForm.tsx +++ b/components/AssetsList/BaseGovernanceForm.tsx @@ -15,7 +15,7 @@ import BigNumber from 'bignumber.js' import React, { useEffect, useState } from 'react' export interface BaseGovernanceFormFields { - minCommunityTokensToCreateProposal: number + minCommunityTokensToCreateProposal: number | string minInstructionHoldUpTime: number maxVotingTime: number voteThreshold: number @@ -157,7 +157,7 @@ const BaseGovernanceForm = ({ formErrors, form, setForm, setFormErrors }) => { } error={formErrors['voteThreshold']} /> -
+
= ({ disabled={disabled} className={`${className} border border-primary-light font-bold default-transition rounded-full px-4 ${ small ? 'py-1' : 'py-2.5' - } text-primary-light text-sm hover:border-primary-dark hover:text-primary-dark focus:outline-none disabled:border-fgd-3 disabled:text-fgd-3 disabled:cursor-not-allowed`} + } text-primary-light text-sm hover:border-fgd-1 hover:text-fgd-1 focus:outline-none disabled:border-fgd-4 disabled:text-fgd-3 disabled:cursor-not-allowed`} {...props} > @@ -88,3 +89,79 @@ export const LinkButton: FunctionComponent = ({ ) } + +interface NewButtonProps extends React.ButtonHTMLAttributes { + loading?: boolean + secondary?: boolean + radio?: boolean + selected?: boolean + className?: string +} + +export const NewButton: FunctionComponent = ({ + className = '', + loading = false, + secondary = false, + children, + ...props +}) => { + let classNames = `heading-cta default-transition rounded-full focus-visible:outline-none disabled:cursor-not-allowed ` + + if (loading) { + classNames += + ' h-[64px] min-w-[208px] border border-fgd-3 disabled:border-fgd-3' + } else if (secondary) { + classNames += + 'py-3 px-2 h-[64px] min-w-[208px] text-fgd-1 border border-fgd-3 focus:border-fgd-1 hover:bg-fgd-1 hover:text-bkg-1 active:bg-fgd-2 active:text-bkg-1 active:border-none disabled:bg-fgd-4 disabled:text-bkg-1 disabled:border-none ' + } else { + // this is a primary button + // TODO: make sure this using the typogrpahic class for CTAs + classNames += + 'py-4 px-2 h-[64px] min-w-[208px] text-bkg-1 bg-fgd-1 hover:bg-fgd-2 active:bg-fgd-3 active:border-none focus:border-2 focus:border-[#00E4FF] disabled:bg-fgd-4' + } + + classNames += ` ${className}` + + return ( + + ) +} + +export const RadioButton: FunctionComponent = ({ + className = '', + selected = false, + disabled = false, + children, + ...props +}) => { + let classNames = + 'group default-transition py-3 px-2 h-[72px] min-w-[208px] text-fgd-1 rounded border disabled:cursor-not-allowed' + + if (selected) { + classNames += ' bg-bkg-4 border-fgd-1 focus:border-blue' + } else { + classNames += ' focus:bg-fgd-3 focus:border-none' + } + + if (!disabled) { + classNames += 'hover:bg-bkg-4 hover:border-fgd-1 border-fgd-3' + } else { + classNames += ' bg-none text-fgd-4 border-bkg-4' + } + + classNames += ` ${className}` + return ( + + ) +} diff --git a/components/ConnectWalletButton.tsx b/components/ConnectWalletButton.tsx index 657acf77c2..3060645dd8 100644 --- a/components/ConnectWalletButton.tsx +++ b/components/ConnectWalletButton.tsx @@ -1,3 +1,4 @@ +import { useRouter } from 'next/router' import { AddressImage, DisplayAddress, @@ -29,6 +30,11 @@ const StyledWalletProviderLabel = styled.p` ` const ConnectWalletButton = (props) => { + const { pathname, query, replace } = useRouter() + const [currentCluster, setCurrentCluster] = useLocalStorageState( + 'cluster', + 'mainnet' + ) const { connected, current, @@ -36,24 +42,33 @@ const ConnectWalletButton = (props) => { connection, set: setWalletStore, } = useWalletStore((s) => s) - const provider = useMemo(() => getWalletProviderByUrl(providerUrl), [ providerUrl, ]) - const [useDevnet, setUseDevnet] = useLocalStorageState('false') - const handleToggleDevnet = () => { - setUseDevnet(!useDevnet) - if (useDevnet) { - window.location.href = `${window.location.pathname}` - } else { - window.location.href = `${window.location.href}?cluster=devnet` - } - } useEffect(() => { - setUseDevnet(connection.cluster === 'devnet') + setCurrentCluster(connection.cluster) }, [connection.cluster]) + function updateClusterParam(cluster) { + const newQuery = { + ...query, + cluster, + } + if (!cluster) { + delete newQuery.cluster + } + replace({ pathname, query: newQuery }, undefined, { + shallow: true, + }) + } + + function handleToggleDevnet() { + const isDevnet = !(currentCluster === 'devnet') + setCurrentCluster(isDevnet ? 'devnet' : 'mainnet') + updateClusterParam(isDevnet ? 'devnet' : null) + } + const handleConnectDisconnect = async () => { try { if (connected) { @@ -90,7 +105,7 @@ const ConnectWalletButton = (props) => { const displayAddressImage = useMemo(() => { return connected && current?.publicKey ? ( -
+
{ />{' '}
) : ( -
+
) @@ -183,7 +198,7 @@ const ConnectWalletButton = (props) => {
Devnet { handleToggleDevnet() }} diff --git a/components/Footer.tsx b/components/Footer.tsx index f1a99b34e8..b5e0a5f258 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -8,7 +8,7 @@ const RelevantLinks = { const Socials = { Twitter: { - url: 'https://twitter.com/solana', + url: 'https://twitter.com/Realms_DAOs', imgSrc: '/icons/twitter.svg', }, Github: { @@ -27,7 +27,7 @@ const Footer = () => { if (REALM) return null else return ( -
+
{Object.keys(RelevantLinks).map((linkTitle) => { const href = RelevantLinks[linkTitle] @@ -36,7 +36,7 @@ const Footer = () => { key={linkTitle} href={href} target="_blank" - className="text-base font-bold default-transition hover:text-primary-dark" + className="text-base font-bold default-transition hover:text-fgd-2" rel="noreferrer" > {linkTitle} @@ -63,15 +63,13 @@ const Footer = () => {
-

- Powered by -

+

Powered by

Solana diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000000..c7b70cab7a --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,32 @@ +import { createElement } from 'react' + +export default function Header({ + as = 'h2', + withGradient = false, + className = '', + children, +}) { + let classNames = '' + if (as === 'h1') { + classNames += ` heading-xl` + } else if (as === 'h2') { + classNames += ` heading-lg` + } else if (as === 'h3') { + classNames += ` heading-base` + } else if (as === 'h4') { + classNames += ` heading-sm` + } else if (as === 'h5') { + classNames += ` heading-xs` + } else if (as === 'h6' || as === 'cta') { + as = 'div' + classNames += ` heading-cta` + } + + classNames += ` ${className}` + + if (withGradient) { + classNames += ` bg-gradient-to-r from-[#00C2FF] via-[#00E4FF] to-[#87F2FF] bg-clip-text text-transparent` + } + + return createElement(as, { className: classNames }, children) +} diff --git a/components/Loading.tsx b/components/Loading.tsx index c9fe6011e1..c333b8d00b 100644 --- a/components/Loading.tsx +++ b/components/Loading.tsx @@ -1,5 +1,21 @@ import { FunctionComponent } from 'react' +export const LoadingDots = ({ className = '' }) => { + return ( +
+ + + +
+ ) +} + interface LoadingProps { className?: string w?: string @@ -12,7 +28,7 @@ const Loading: FunctionComponent = ({ h = 5, }) => { return ( -
+
{isOpen ? ( diff --git a/components/NavBar.tsx b/components/NavBar.tsx index 6b07731754..173126aa62 100644 --- a/components/NavBar.tsx +++ b/components/NavBar.tsx @@ -22,18 +22,12 @@ const NavBar = () => {
-
-
- - -
+
+ +
-
- - -
) } diff --git a/components/NewRealmWizard/CreateDAOWizard.tsx b/components/NewRealmWizard/CreateDAOWizard.tsx new file mode 100644 index 0000000000..2639e1cbd7 --- /dev/null +++ b/components/NewRealmWizard/CreateDAOWizard.tsx @@ -0,0 +1,47 @@ +import FormSummary from '@components/NewRealmWizard/components/FormSummary' + +export default function CreateDAOWizard({ + type, + steps, + currentStep, + formData, + handlePreviousButton, + handleNextButtonClick, + handleSubmit, + submissionPending, +}) { + return ( + <> + {steps.map(({ Form, ...props }, index) => { + delete props.schema + delete props.required + const visible = index == currentStep + return ( +
+
+
+ ) + })} + + {currentStep == steps.length + 1 && ( + + )} + + ) +} diff --git a/components/NewRealmWizard/PageTemplate.tsx b/components/NewRealmWizard/PageTemplate.tsx new file mode 100644 index 0000000000..7a5ae790c5 --- /dev/null +++ b/components/NewRealmWizard/PageTemplate.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import Head from 'next/head' + +import { isWizardValid } from '@utils/formValidation' + +import CreateDAOWizard from '@components/NewRealmWizard/CreateDAOWizard' +import useWalletStore from 'stores/useWalletStore' + +// import { FORM_NAME as NFT_FORM } from 'pages/realms/new/nft' +import { FORM_NAME as MULTISIG_WALLET_FORM } from 'pages/realms/new/multisig' +import { FORM_NAME as COMMUNITY_TOKEN_FORM } from 'pages/realms/new/community-token' + +export const Section = ({ children }) => { + return ( +
+ {children} +
+ ) +} + +export default function FormPage({ + autoInviteWallet = false, + type, + steps, + handleSubmit, + submissionPending, +}) { + const { connected, current: wallet } = useWalletStore((s) => s) + const userAddress = wallet?.publicKey?.toBase58() + const [formData, setFormData] = useState({ + memberAddresses: + autoInviteWallet && userAddress ? [userAddress] : undefined, + }) + const { query, push } = useRouter() + const currentStep = formData?.currentStep || 0 + const title = `Create ${ + type === MULTISIG_WALLET_FORM + ? 'multi-signature wallet' + : type === COMMUNITY_TOKEN_FORM + ? 'community token DAO' + : 'NFT community DAO' + } | Realms` + + useEffect(() => { + async function tryToConnect() { + try { + if (!connected) { + if (wallet) await wallet.connect() + } + if (!wallet?.publicKey) { + throw new Error('No valid wallet connected') + } + } catch (err) { + if (currentStep > 0) handlePreviousButton(1) + } + } + + tryToConnect() + }, [connected]) + + useEffect(() => { + if (currentStep > 0 && !isWizardValid({ currentStep, steps, formData })) { + handlePreviousButton(currentStep) + } + }, [currentStep]) + + function handleNextButtonClick({ step: fromStep, data }) { + const updatedFormState = { + ...formData, + ...data, + } + const nextStep = steps + .map( + ({ required }) => + required === 'true' || + !!eval(required.replace('form', 'updatedFormState')) + ) + .indexOf(true, fromStep + 1) + + updatedFormState.currentStep = nextStep > -1 ? nextStep : steps.length + 1 + + console.log('next button clicked', fromStep, nextStep) + + for (const key in updatedFormState) { + if (updatedFormState[key] == null) { + delete updatedFormState[key] + } + } + setFormData(updatedFormState) + } + + function handlePreviousButton(fromStep) { + console.log( + 'previous button clicked from step:', + fromStep, + currentStep, + query + ) + + if (fromStep === 0) { + push( + { + pathname: '/realms/new/', + query: query?.cluster ? { cluster: query.cluster } : {}, + }, + undefined, + { shallow: true } + ) + } else { + const previousStep = steps + .map( + ({ required }) => + required === 'true' || !!eval(required.replace('form', 'formData')) + ) + .lastIndexOf(true, fromStep - 1) + + const updatedFormState = { + ...formData, + currentStep: previousStep, + } + + setFormData(updatedFormState) + } + } + + return ( + <> + + {title} + +
+ +
+ + ) +} diff --git a/components/NewRealmWizard/components/AdvancedOptionsDropdown.tsx b/components/NewRealmWizard/components/AdvancedOptionsDropdown.tsx new file mode 100644 index 0000000000..7b7bb97b71 --- /dev/null +++ b/components/NewRealmWizard/components/AdvancedOptionsDropdown.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react' +import { Transition } from '@headlessui/react' +import Text from '@components/Text' + +export default function AdvancedOptionsDropdown({ + className = 'mt-10 md:mt-16 w-fit', + children, +}) { + const [open, setOpen] = useState(false) + return ( +
+ + +
{children}
+
+
+ ) +} diff --git a/components/NewRealmWizard/components/AdviceBox.tsx b/components/NewRealmWizard/components/AdviceBox.tsx new file mode 100644 index 0000000000..a2f97a1904 --- /dev/null +++ b/components/NewRealmWizard/components/AdviceBox.tsx @@ -0,0 +1,15 @@ +import Text from '@components/Text' + +export default function AdviceBox({ icon, title, children }) { + return ( +
+
{icon}
+
+ + {title} + + {children} +
+
+ ) +} diff --git a/components/NewRealmWizard/components/FormField.tsx b/components/NewRealmWizard/components/FormField.tsx new file mode 100644 index 0000000000..db40264e57 --- /dev/null +++ b/components/NewRealmWizard/components/FormField.tsx @@ -0,0 +1,73 @@ +import Text from '@components/Text' +interface Props { + advancedOption?: boolean + children: React.ReactNode + className?: string + description: string | React.ReactNode + disabled?: boolean + optional?: boolean + title: string + titleExtra?: React.ReactNode +} + +export default function FormField({ + title, + description, + optional = false, + advancedOption = false, + disabled = false, + className = '', + titleExtra, + children, +}: Props) { + const splitTitle = title.split(' ') + return ( +
+
+ + + {splitTitle + .slice( + 0, + splitTitle.length - (optional || advancedOption ? 1 : 0) + ) + .join(' ')} + + {(optional || advancedOption) && ( + + {` ${splitTitle[splitTitle.length - 1]} `} + {optional && ( + + (optional) + + )} + {advancedOption && ( + + Advanced Option + + )} + + )} + + {titleExtra} +
+ + + {description} + +
{children}
+
+ ) +} diff --git a/components/NewRealmWizard/components/FormFooter.tsx b/components/NewRealmWizard/components/FormFooter.tsx new file mode 100644 index 0000000000..976bddfb7c --- /dev/null +++ b/components/NewRealmWizard/components/FormFooter.tsx @@ -0,0 +1,72 @@ +import Header from '@components/Header' +import { LoadingDots } from '@components/Loading' +import React from 'react' +interface FormFooterProps { + isValid?: boolean + ctaText?: string + loading?: boolean + prevClickHandler: React.MouseEventHandler + submitClickHandler?: React.MouseEventHandler +} + +function ArrowRight({ className }) { + return ( +
+ + + +
+ ) +} + +const FormFooter: React.FC = ({ + isValid, + ctaText = '', + loading = false, + prevClickHandler, + submitClickHandler, +}) => { + return ( +
+
+ + +
+
+ ) +} + +export default FormFooter diff --git a/components/NewRealmWizard/components/FormHeader.tsx b/components/NewRealmWizard/components/FormHeader.tsx new file mode 100644 index 0000000000..004dacc4fa --- /dev/null +++ b/components/NewRealmWizard/components/FormHeader.tsx @@ -0,0 +1,50 @@ +import Header from '@components/Header' +import Text from '@components/Text' + +import { FORM_NAME as COMMUNITY_TOKEN_FORM } from 'pages/realms/new/community-token' +import { FORM_NAME as MULTISIG_FORM } from 'pages/realms/new/multisig' +import { FORM_NAME as NFT_FORM } from 'pages/realms/new/nft' + +function StepProgressIndicator({ formType, currentStep, totalSteps }) { + let stepTitle = '' + + if (formType == COMMUNITY_TOKEN_FORM) { + stepTitle = 'Community Token DAO: ' + } else if (formType == MULTISIG_FORM) { + stepTitle = 'Multi-Signature Wallet: ' + } else if (formType == NFT_FORM) { + stepTitle = 'NFT Community DAO: ' + } + + return ( +
+
+ + + {stepTitle} + + {currentStep === totalSteps + ? 'Summary' + : `Step ${currentStep + 1} of ${totalSteps}`} + +
+
+ ) +} + +export default function FormHeader({ type, currentStep, totalSteps, title }) { + return ( +
+ +
+
+ {title} +
+
+
+ ) +} diff --git a/components/NewRealmWizard/components/FormSummary.tsx b/components/NewRealmWizard/components/FormSummary.tsx new file mode 100644 index 0000000000..e99d980d78 --- /dev/null +++ b/components/NewRealmWizard/components/FormSummary.tsx @@ -0,0 +1,301 @@ +import FormHeader from '@components/NewRealmWizard/components/FormHeader' +import FormFooter from '@components/NewRealmWizard/components/FormFooter' + +import Header from '@components/Header' +import Text from '@components/Text' + +import { FORM_NAME as MULTISIG_FORM } from 'pages/realms/new/multisig' +import { FORM_NAME as COMMUNITY_TOKEN_FORM } from 'pages/realms/new/community-token' +import { GenericTokenIcon } from './TokenInfoTable' + +const TO_BE_GENERATED = '(To be generated)' + +function SummaryModule({ + className = '', + title, + advancedOption = false, + rightSide = <>, + children, +}) { + return ( +
+
+ + {title} + {advancedOption && ( + + Advanced Option + + )} + + {children} +
+
{rightSide ? rightSide : <>}
+
+ ) +} + +function TokenInfoSummary({ title, name, symbol, logoURI }) { + return ( + + #{symbol} + + ) + } + > +
+ {logoURI ? ( + + ) : ( +
+ +
+ )} + + {name || '(Unnamed)'} + +
+
+ ) +} + +function CommunityInfo({ + tokenInfo, + mintAddress, + transferMintAuthority, + mintSupplyFactor, + yesVotePercentage, + minimumNumberOfTokensToGovern, + nftInfo, +}) { + const nftIsCommunityToken = !!nftInfo?.name + const updatedTokenInfo = { + ...tokenInfo, + name: tokenInfo?.name || mintAddress || TO_BE_GENERATED, + } + + return ( + <> +
+ + Community info + +
+ {nftIsCommunityToken ? ( + + {nftInfo?.nftCollectionCount?.toLocaleString()} + {nftInfo?.nftCollectionCount !== 1 ? ' NFTs' : ' NFT'} + + } + > +
+ + + {nftInfo?.name || '(Collection has no name)'} + +
+
+ ) : ( + + )} +
+ + + {yesVotePercentage}% + + + {updatedTokenInfo.name !== TO_BE_GENERATED && !nftIsCommunityToken && ( + + + {transferMintAuthority === true ? 'Yes' : 'No'} + + + )} + {minimumNumberOfTokensToGovern && ( + + + {minimumNumberOfTokensToGovern.toLocaleString()} + + + )} + {mintSupplyFactor && ( + + + {mintSupplyFactor} + + + )} +
+ + ) +} + +function CouncilInfo({ + tokenInfo, + mintAddress, + transferMintAuthority, + // yesVotePercentage, + numberOfMembers, +}) { + const updatedTokenInfo = { + ...tokenInfo, + name: tokenInfo?.name || mintAddress || TO_BE_GENERATED, + } + + return ( + <> +
+ + Council info + +
+ +
+ + + {numberOfMembers} + + + {/* + + {yesVotePercentage}% + + */} + {updatedTokenInfo.name !== TO_BE_GENERATED && ( + + + {transferMintAuthority === true ? 'Yes' : 'No'} + + + )} +
+ + ) +} + +export default function WizardSummary({ + type, + currentStep, + formData, + onSubmit, + submissionPending = false, + onPrevClick, +}) { + const nftCollectionMetadata = + (formData?.collectionKey && formData?.collectionMetadata) || {} + const nftCollectionCount = formData?.numberOfNFTs || 0 + const nftCollectionInfo = { + ...nftCollectionMetadata, + nftCollectionCount, + } + + const programId = formData?.programId || '' + + return ( +
+ +
+ +
{formData?.name}
+
+ {type === MULTISIG_FORM ? ( +
+ +
{formData?.memberAddresses?.length}
+
+ +
+ {formData?.councilYesVotePercentage} + +
%
+
+
+
+ ) : ( + <> + + {(formData.addCouncil || formData?.memberAddresses?.length > 0) && ( + + )} + + )} + {programId && ( + +
+
{programId}
+
+
+ )} +
+ onPrevClick(currentStep)} + submitClickHandler={() => onSubmit(formData)} + /> +
+ ) +} diff --git a/components/NewRealmWizard/components/GradientCheckmarkCircle.tsx b/components/NewRealmWizard/components/GradientCheckmarkCircle.tsx new file mode 100644 index 0000000000..3ace75a0f6 --- /dev/null +++ b/components/NewRealmWizard/components/GradientCheckmarkCircle.tsx @@ -0,0 +1,23 @@ +export default function ({ selected = false }) { + return ( +
+ {selected && ( + + + + )} +
+ ) +} diff --git a/components/NewRealmWizard/components/Input.tsx b/components/NewRealmWizard/components/Input.tsx new file mode 100644 index 0000000000..f5d34de250 --- /dev/null +++ b/components/NewRealmWizard/components/Input.tsx @@ -0,0 +1,293 @@ +import React from 'react' +import { RadioGroup as HRG } from '@headlessui/react' +import { preventNegativeNumberInput } from '@utils/helpers' + +import { RadioButton } from '@components/Button' +import Text from '@components/Text' +interface InputProps extends React.InputHTMLAttributes { + invalid?: string + error?: string + success?: string + Icon?: any + suffix?: any + className?: string + warning?: string +} + +const Input = React.forwardRef( + ( + { + error = '', + success = '', + value, + Icon, + suffix, + className = '', + autoComplete = 'off', + warning = '', + ...props + }, + ref + ) => { + const hasContent = typeof value !== 'undefined' && value !== '' + let classNames = `input-base form-control block w-full ${ + Icon ? 'pl-8' : 'pl-2' + } ${ + suffix ? 'pr-8' : 'pr-2' + } pt-[15px] pb-[21px] default-transition rounded-t rounded-b-none outline-none border-b border-b-bkg-4 bg-bkg-2` + + if (hasContent) { + classNames += ` cursor-text` + } else { + classNames += ` cursor-pointer` + } + + classNames += ` + placeholder:text-bkg-4 + active:bg-bkg-3 + focus:bg-bkg-3 + hover:bg-bkg-3 + hover:border-fgd-2 + + focus:outline-none + focus:border-primary-light + + active:border-primary-light + + disabled:cursor-not-allowed + disabled:opacity-30 + disabled:hover:bg-bkg-2 + disabled:hover:border-b-bkg-4 + ` + + if (error) { + classNames += ` border-b-error-red/50 focus:border-b-error-red active:border-b-error-red` + } else if (success) { + classNames += ` border-b-green focus:border-b-green active:border-b-green` + } + + classNames += ` ${className}` + return ( +
+
+ {Icon ? Icon : ''} +
+ +
+ {suffix ? suffix : ''} +
+ + + +
+ ) + } +) + +export default Input + +export function FieldMessage({ + error = '', + warning = '', + success = '', + className = '', +}) { + return ( +
+ + + {error ? ( + + + + + ) : warning ? ( + + + + + ) : success ? ( + + + + ) : ( + <> + )} + + {error || warning || success} + +
+ ) +} + +interface RadioGroupOption { + label: string + value: string | boolean | number +} +interface RadioGroupProps { + options: RadioGroupOption[] + onChange: any + onBlur: any + value: any + disabled?: boolean + disabledValues?: any[] + error?: string + warning?: string + success?: string +} + +export const RadioGroup = ({ + options, + onChange, + onBlur, + value, + disabled, + disabledValues = [], + error, + warning, + success, +}: RadioGroupProps) => { + return ( + <> + +
+ {options.map(({ label, value }) => { + return ( + -1} + > + {({ checked }) => ( + -1} + className="w-full" + > + {label} + + )} + + ) + })} +
+
+ + + ) +} + +export function InputRangeSlider({ + field, + error = '', + placeholder = '50', + disabled = false, +}) { + return ( +
+
+ + % + + } + disabled={disabled} + data-testid="dao-approval-threshold-input" + error={error} + className="text-center" + {...field} + onChange={(ev) => { + preventNegativeNumberInput(ev) + field.onChange(ev) + }} + /> +
{' '} +
+ + 0% + + + + 100% + +
+
+ ) +} diff --git a/components/NewRealmWizard/components/NFTCollectionModal.tsx b/components/NewRealmWizard/components/NFTCollectionModal.tsx new file mode 100644 index 0000000000..4b064b7f04 --- /dev/null +++ b/components/NewRealmWizard/components/NFTCollectionModal.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import { abbreviateAddress } from '@utils/formatting' + +import Header from '@components/Header' +import Button from '@components/Button' +import NFTCollectionSelector from '@components/NewRealmWizard/components/NFTCollectionSelector' +import { WalletIcon } from './steps/AddNFTCollectionForm' +import Modal from '@components/Modal' + +export default function NFTCollectionModal({ + show, + walletPk, + collections, + onClose, + onSelect, +}) { + const [selected, setSelected] = useState('') + + function close() { + onClose() + setSelected('') + } + + function handleChoose() { + onSelect({ key: selected, collection: collections[selected] }) + close() + } + + return ( + show && ( + +
+
+ Choose a collection from your wallet +
+
+ +
{walletPk && abbreviateAddress(walletPk)}
+
+
+ +
+ +
+
+ ) + ) +} diff --git a/components/NewRealmWizard/components/NFTCollectionSelector.tsx b/components/NewRealmWizard/components/NFTCollectionSelector.tsx new file mode 100644 index 0000000000..097becaaa9 --- /dev/null +++ b/components/NewRealmWizard/components/NFTCollectionSelector.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { RadioGroup } from '@headlessui/react' +import Header from '@components/Header' +import Text from '@components/Text' +import GradientCheckmarkCircle from './GradientCheckmarkCircle' +import { LoadingDots } from '@components/Loading' + +function ImageWithLoader({ className, ...props }) { + const [loading, setLoading] = useState(true) + const loadingClassName = `${loading ? '' : 'hidden'} ${className}` + const imageClassName = `${loading ? 'hidden' : ''} ${className}` + return ( + <> +
+ +
+ setLoading(false)} + /> + + ) +} + +const NFTCollectionSelector = ({ collections = {}, onChange, value }) => { + const optionClass = + 'z-0 group flex flex-wrap md:items-center md:space-x-8 flex-wrap py-4 px-2 md:px-8 relative w-full default-transition rounded-md hover:cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 hover:opacity-100 hover:bg-bkg-3' + + if (Object.keys(collections).length === 0) { + return ( +
+ Your wallet does not contain any verified NFT collections to choose from +
+ ) + } + + return ( + +
+ {Object.keys(collections).map((key) => { + const collection = collections[key] + const totalNfts = collection.nfts.length + const images = collection.nfts.slice(0, 2).map((nft) => nft.image) + + for (let i = images.length; i < 3; i++) { + images.unshift('') + } + + return ( + + {({ active, checked }) => ( +
+
+ +
+
+ {collection?.name} + + {totalNfts} {`NFT${totalNfts === 1 ? '' : 's'}`} + +
+
+ {images.map((src, index) => { + return ( +
+ {src && ( + + )} +
+ ) + })} +
+ +
+ )} +
+ ) + })} +
+
+ ) +} + +export default NFTCollectionSelector diff --git a/components/NewRealmWizard/components/TokenInfoTable.tsx b/components/NewRealmWizard/components/TokenInfoTable.tsx new file mode 100644 index 0000000000..329f2f288e --- /dev/null +++ b/components/NewRealmWizard/components/TokenInfoTable.tsx @@ -0,0 +1,101 @@ +import Text from '@components/Text' + +export function GenericTokenIcon() { + return ( + + + + + + ) +} + +export function TokenInfoCell({ title = '', children }) { + return ( +
+ + {title} + +
{children}
+
+ ) +} + +export default function TokenInfoTable({ tokenInfo, loading }) { + return ( +
+ +
+ {tokenInfo?.logoURI ? ( +
+ token +
+ ) : ( +
+ +
+ )} + {tokenInfo ? ( + + {tokenInfo.name || '(No name)'} + + ) : ( + +
+
+ )} +
+
+ + + {tokenInfo ? ( + +
#
+
{tokenInfo.symbol || '(No symbol)'}
+
+ ) : ( +
+ )} +
+
+
+ ) +} diff --git a/components/NewRealmWizard/components/TokenInput.tsx b/components/NewRealmWizard/components/TokenInput.tsx new file mode 100644 index 0000000000..44ec62c8dc --- /dev/null +++ b/components/NewRealmWizard/components/TokenInput.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState } from 'react' +import { TokenListProvider, TokenInfo } from '@solana/spl-token-registry' +import { MintInfo, u64 } from '@solana/spl-token' +import { PublicKey } from '@solana/web3.js' + +import { getMintSupplyAsDecimal } from '@tools/sdk/units' +import useWalletStore from 'stores/useWalletStore' +import { tryGetMint } from '@utils/tokens' +import { validateSolAddress } from '@utils/formValidation' +import { preventNegativeNumberInput } from '@utils/helpers' + +import { Controller } from 'react-hook-form' + +import FormField from '@components/NewRealmWizard/components/FormField' +import Input, { RadioGroup } from '@components/NewRealmWizard/components/Input' +import TokenInfoTable, { + GenericTokenIcon, +} from '@components/NewRealmWizard/components/TokenInfoTable' + +interface MintInfoWithDecimalSupply extends MintInfo { + supplyAsDecimal: number +} +export interface TokenWithMintInfo extends TokenInfo { + mint: MintInfoWithDecimalSupply | undefined +} + +const PENDING_COIN: TokenWithMintInfo = { + chainId: 0, + address: '', + symbol: 'finding symbol...', + name: 'finding name...', + decimals: 9, + logoURI: '', + tags: [''], + extensions: {}, + mint: { + mintAuthority: null, + supply: new u64(0), + freezeAuthority: null, + decimals: 3, + isInitialized: false, + supplyAsDecimal: 0, + }, +} + +const NOTFOUND_COIN: TokenWithMintInfo = { + ...PENDING_COIN, + name: '', + symbol: '', +} + +export const COMMUNITY_TOKEN = 'community token' +export const COUNCIL_TOKEN = 'council token' + +export default function TokenInput({ + type, + control, + onValidation, + disableMinTokenInput = false, +}) { + const { connected, connection, current: wallet } = useWalletStore((s) => s) + const [tokenList, setTokenList] = useState() + const [tokenMintAddress, setTokenMintAddress] = useState('') + const [tokenInfo, setTokenInfo] = useState() + const validMintAddress = tokenInfo && tokenInfo !== PENDING_COIN + const walletIsMintAuthority = + wallet?.publicKey && + tokenInfo?.mint?.mintAuthority && + wallet.publicKey.toBase58() === tokenInfo.mint.mintAuthority.toBase58() + const invalidAddress = + !validMintAddress && !/finding/.test(tokenInfo?.name ? tokenInfo.name : '') + + useEffect(() => { + if (!connected) { + wallet?.connect() + } + }, [wallet]) + + useEffect(() => { + async function getTokenList() { + const tokenList = await new TokenListProvider().resolve() + const filteredTokenList = tokenList + .filterByClusterSlug( + connection.cluster === 'mainnet' ? 'mainnet-beta' : connection.cluster + ) + .getList() + setTokenList(filteredTokenList) + } + + getTokenList() + }, [connection.cluster]) + + useEffect(() => { + async function getTokenInfo(tokenMintAddress) { + setTokenInfo(PENDING_COIN) + const mintInfo = await tryGetMint( + connection.current, + new PublicKey(tokenMintAddress) + ) + if (mintInfo) { + const tokenInfo = + tokenList?.find((token) => token.address === tokenMintAddress) || + NOTFOUND_COIN + + setTokenInfo({ + ...tokenInfo, + mint: { + ...mintInfo.account, + supplyAsDecimal: getMintSupplyAsDecimal(mintInfo.account), + }, + }) + } else { + setTokenInfo(undefined) + } + } + + if (tokenMintAddress && validateSolAddress(tokenMintAddress)) { + getTokenInfo(tokenMintAddress) + } else { + setTokenInfo(undefined) + } + }, [tokenList, tokenMintAddress]) + + useEffect(() => { + let suggestedMinTokenAmount = 0 + if (typeof tokenInfo?.mint?.supplyAsDecimal === 'number') { + suggestedMinTokenAmount = Math.ceil(tokenInfo.mint.supplyAsDecimal * 0.01) + } + + onValidation({ + // validMintAddress: tokenMintAddress !== '' ? validMintAddress : true, + validMintAddress, + tokenInfo, + suggestedMinTokenAmount, + walletIsMintAuthority, + }) + }, [validMintAddress, walletIsMintAuthority, tokenInfo]) + + return ( + <> + ( + + { + field.onChange(ev) + setTokenMintAddress(ev.target.value) + }} + /> + {tokenInfo?.name && tokenInfo.name !== PENDING_COIN.name && ( + + )} + + )} + /> + {validMintAddress && ( + <> + ( + + + + )} + /> + + {!!tokenInfo?.mint?.supplyAsDecimal && !disableMinTokenInput && ( + ( + + } + error={error?.message || ''} + {...field} + disabled={!validMintAddress} + onChange={(ev) => { + preventNegativeNumberInput(ev) + field.onChange(ev) + }} + /> + + )} + /> + )} + + )} + + ) +} diff --git a/components/NewRealmWizard/components/steps/AddCouncilForm.tsx b/components/NewRealmWizard/components/steps/AddCouncilForm.tsx new file mode 100644 index 0000000000..2b47acc878 --- /dev/null +++ b/components/NewRealmWizard/components/steps/AddCouncilForm.tsx @@ -0,0 +1,221 @@ +import { useState, useEffect } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import * as yup from 'yup' + +import FormHeader from '@components/NewRealmWizard/components/FormHeader' +import FormField from '@components/NewRealmWizard/components/FormField' +import FormFooter from '@components/NewRealmWizard/components/FormFooter' +import { RadioGroup } from '@components/NewRealmWizard/components/Input' +import AdviceBox from '@components/NewRealmWizard/components/AdviceBox' +import Text from '@components/Text' + +import { updateUserInput, validateSolAddress } from '@utils/formValidation' +import TokenInput, { TokenWithMintInfo, COUNCIL_TOKEN } from '../TokenInput' + +export const AddCouncilSchema = { + addCouncil: yup + .boolean() + .oneOf( + [true, false], + 'You must specify whether you would like to add a council or not' + ) + .required('Required'), + useExistingCouncilToken: yup + .boolean() + .oneOf([true, false], 'You must specify whether you have a token already') + .when('addCouncil', { + is: true, + then: yup.boolean().required('Required'), + otherwise: yup.boolean().optional(), + }), + councilTokenMintAddress: yup + .string() + .when('useExistingCouncilToken', { + is: (val) => val == true, + then: yup.string().required('Required'), + otherwise: yup.string().optional(), + }) + .test('is-valid-address', 'Please enter a valid Solana address', (value) => + value ? validateSolAddress(value) : true + ), + transferCouncilMintAuthority: yup + .boolean() + .oneOf( + [true, false], + 'You must specify whether you which to transfer mint authority' + ) + .when('useExistingCouncilToken', { + is: (val) => val == true, + then: yup.boolean().required('Required'), + otherwise: yup.boolean().optional(), + }), +} + +export interface AddCouncil { + addCouncil: boolean + useExistingCouncilToken?: boolean + councilTokenMintAddress?: string + transferCouncilMintAuthority?: boolean +} + +export default function AddCouncilForm({ + type, + formData, + currentStep, + totalSteps, + onSubmit, + onPrevClick, +}) { + const schema = yup.object(AddCouncilSchema).required() + const { + control, + setValue, + handleSubmit, + watch, + formState: { isValid }, + } = useForm({ + mode: 'all', + resolver: yupResolver(schema), + }) + const addCouncil = watch('addCouncil') + const useExistingCouncilToken = watch('useExistingCouncilToken') + const [councilTokenInfo, setCouncilTokenInfo] = useState< + TokenWithMintInfo | undefined + >() + const forceCouncil = + formData.useExistingCommunityToken === false || + (formData?.communityTokenInfo?.mint?.supplyAsDecimal === 0 && + formData.transferCommunityMintAuthority) + + useEffect(() => { + updateUserInput(formData, AddCouncilSchema, setValue) + }, []) + + useEffect(() => { + setValue('addCouncil', forceCouncil || undefined) + }, [forceCouncil]) + + useEffect(() => { + if (!useExistingCouncilToken) { + setValue('councilTokenMintAddress', '') + setValue('transferCouncilMintAuthority', undefined) + setCouncilTokenInfo(undefined) + } + }, [useExistingCouncilToken]) + + useEffect(() => { + if (!addCouncil) { + setValue('useExistingCouncilToken', undefined, { shouldValidate: true }) + } + }, [addCouncil]) + + function handleTokenInput({ tokenInfo }) { + setCouncilTokenInfo(tokenInfo) + setValue('transferCouncilMintAuthority', undefined, { + shouldValidate: true, + }) + } + + function serializeValues(values) { + let data + if (!values.addCouncil) { + data = { + ...values, + memberAddresses: null, + quorumThreshold: null, + } + } else { + data = values + data.councilTokenInfo = councilTokenInfo ? councilTokenInfo : null + } + onSubmit({ step: currentStep, data }) + } + + return ( + + +
+
+ } + > + Council members can supervise and moderate DAO activities. It’s + recommended to always create the council for DAOs in their + incubation stage to prevent governance attacks or accidental losses + of assets managed by the DAO. + +
+ + + ( + + )} + /> + {forceCouncil && ( + + A council is required to govern the DAO until the community token + is distributed to members. + + )} + + + {addCouncil && ( + ( + + + + )} + /> + )} + {addCouncil && useExistingCouncilToken && ( + + )} +
+ onPrevClick(currentStep)} + /> + + ) +} diff --git a/components/NewRealmWizard/components/steps/AddNFTCollectionForm.tsx b/components/NewRealmWizard/components/steps/AddNFTCollectionForm.tsx new file mode 100644 index 0000000000..739abf117c --- /dev/null +++ b/components/NewRealmWizard/components/steps/AddNFTCollectionForm.tsx @@ -0,0 +1,656 @@ +import React, { useEffect, useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import * as yup from 'yup' +import { Metadata } from '@metaplex-foundation/mpl-token-metadata' +import axios from 'axios' + +import { updateUserInput, validateSolAddress } from '@utils/formValidation' +import { notify } from '@utils/notifications' +import { abbreviateAddress } from '@utils/formatting' + +import useWalletStore from 'stores/useWalletStore' + +import { NewButton as Button } from '@components/Button' +import Text from '@components/Text' +import FormHeader from '@components/NewRealmWizard/components/FormHeader' +import FormField from '@components/NewRealmWizard/components/FormField' +import FormFooter from '@components/NewRealmWizard/components/FormFooter' +import Input, { + InputRangeSlider, +} from '@components/NewRealmWizard/components/Input' +import AdviceBox from '@components/NewRealmWizard/components/AdviceBox' +import NFTCollectionModal from '@components/NewRealmWizard/components/NFTCollectionModal' + +function filterAndMapVerifiedCollections(nfts) { + return nfts + .filter((nft) => { + if (nft.data?.collection) { + return nft.data?.collection?.verified + } else { + return nft.collection?.verified + } + }) + .map((nft) => { + if (nft.data.collection) { + return nft.data + } else { + return nft + } + }) + .reduce((prev, curr) => { + const collectionKey = curr.collection?.key + if (typeof collectionKey === 'undefined') return prev + + if (prev[collectionKey]) { + prev[collectionKey].push(curr) + } else { + prev[collectionKey] = [curr] + } + return prev + }, {}) +} + +async function enrichItemInfo(item, uri) { + const { data: response } = await axios.get(uri) + return { + ...item, + ...response, + } +} + +async function enrichCollectionInfo(connection, collectionKey) { + const { + data: { data: collectionData }, + } = await Metadata.findByMint(connection, collectionKey) + + return enrichItemInfo( + { + ...collectionData, + collectionMintAddress: collectionKey, + }, + collectionData.uri + ) +} + +async function getNFTCollectionInfo(connection, collectionKey) { + const { data: result } = await Metadata.findByMint(connection, collectionKey) + console.log('NFT findByMint result', result) + if (result?.collection?.verified && result.collection?.key) { + // here we were given a child of the collection (hence the "collection" property is present) + const collectionInfo = await enrichCollectionInfo( + connection, + result.collection.key + ) + const nft = await enrichItemInfo(result.data, result.data.uri) + collectionInfo.nfts = [nft] + return collectionInfo + } else { + // assume we've been given the collection address already, so we need to go find it's children + const children = await Metadata.findMany(connection, { + updateAuthority: result.updateAuthority, + }) + + const verifiedCollections = filterAndMapVerifiedCollections(children) + if (verifiedCollections[collectionKey]) { + const collectionInfo = await enrichCollectionInfo( + connection, + collectionKey + ) + const nfts = await Promise.all( + verifiedCollections[collectionKey].map((item) => { + return enrichItemInfo(item.data, item.data.uri) + }) + ) + collectionInfo.nfts = nfts + return collectionInfo + } else { + throw new Error( + 'Address did not return collection with children whose "collection.key" matched' + ) + } + } + + // 3iBYdnzA418tD2o7vm85jBeXgxbdUyXzX9Qfm2XJuKME +} + +export const AddNFTCollectionSchema = { + collectionKey: yup.string().required(), + numberOfNFTs: yup + .number() + .min(1, 'Must be at least 1') + .transform((value) => (isNaN(value) ? undefined : value)) + .required('Required'), + communityYesVotePercentage: yup + .number() + .typeError('Required') + .max(100, 'Approval cannot require more than 100% of votes') + .min(1, 'Approval must be at least 1% of votes') + .required('Required'), +} + +export interface AddNFTCollection { + collectionKey: string + collectionMetadata: NFTCollection + communityYesVotePercentage: number + numberOfNFTs: number +} + +export interface NFT_Creator { + address: string + verified: number + share: number +} + +interface NFT_Attributes { + display_type: string + trait_type: string + value: number +} +export interface NFT { + name: string + symbol: string + uri: string + sellerFeeBasisPoints: number + creators: NFT_Creator[] + description: string + seller_fee_basis_points: number + image: string + animation_url: string + external_url: string + attributes: NFT_Attributes[] + collection: any + properties: any +} + +interface NFTCollection extends NFT { + nfts: NFT[] +} + +export function WalletIcon() { + return ( + + + + ) +} + +function SkeletonNFTCollectionInfo() { + return ( + <> +
+ + + +
+
+
+ Collection name... +
+
+ Loading-long-url-to-some-obscure-wallet-address +
+
+ xx 1234...6789 +
+
+ + ) +} + +export default function AddNFTCollectionForm({ + type, + formData, + currentStep, + totalSteps, + onSubmit, + onPrevClick, +}) { + const { connected, connection, current: wallet } = useWalletStore((s) => s) + const [walletConnecting, setWalletConnecting] = useState(false) + const [requestPending, setRequestPending] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + + const [collectionsInWallet, setCollectionsInWallet] = useState({}) + + const [ + selectedNFTCollection, + setSelectedNFTCollection, + ] = useState() + + const schema = yup.object(AddNFTCollectionSchema).required() + const { + control, + register, + watch, + setValue, + setError, + setFocus, + clearErrors, + handleSubmit, + formState: { isValid }, + } = useForm({ + mode: 'all', + resolver: yupResolver(schema), + }) + const [unverifiedCollection, setUnverifiedCollection] = useState(false) + const collectionKey = watch('collectionKey') + const numberOfNFTs = watch('numberOfNFTs') || 10000 + const approvalPercent = watch('communityYesVotePercentage', 60) || 60 + const approvalSize = approvalPercent + ? Math.ceil((Number(numberOfNFTs) * approvalPercent) / 100) + : undefined + + useEffect(() => { + updateUserInput(formData, AddNFTCollectionSchema, setValue) + setSelectedNFTCollection(formData?.collectionMetadata) + }, []) + + useEffect(() => { + if (unverifiedCollection || selectedNFTCollection) { + setFocus('numberOfNFTs') + } else { + // setFocus('collectionInput') + } + }, [unverifiedCollection, selectedNFTCollection]) + + function serializeValues(values) { + const data = { + numberOfNFTs: null, + ...values, + collectionInput: null, + collectionMetadata: selectedNFTCollection, + } + onSubmit({ step: currentStep, data }) + } + + async function handleAdd(collectionInput) { + clearErrors() + + if (validateSolAddress(collectionInput)) { + handleClearSelectedNFT(false) + setRequestPending(true) + try { + const collectionInfo = await getNFTCollectionInfo( + connection.current, + collectionInput + ) + console.log('NFT collection info from user input:', collectionInfo) + setValue('collectionKey', collectionInfo.collectionMintAddress) + setSelectedNFTCollection(collectionInfo) + setRequestPending(false) + } catch (err) { + setRequestPending(false) + setValue('collectionKey', collectionInput) + setUnverifiedCollection(true) + } + } else { + setError('collectionInput', { + type: 'error', + message: 'Address is invalid', + }) + } + } + + async function handleClearSelectedNFT(clearInput = true) { + if (clearInput) { + setValue('collectionInput', '') + } + clearErrors('collectionInput') + setValue('collectionKey', '') + setUnverifiedCollection(false) + setSelectedNFTCollection(undefined) + } + + async function handlePaste(ev) { + const value = ev.clipboardData.getData('text') + ev.currentTarget.value += value + setValue('collectionInput', ev.currentTarget.value) + handleAdd(ev.currentTarget.value) + ev.preventDefault() + } + + async function handleSelectFromWallet() { + try { + setWalletConnecting(true) + + if (!connected) { + if (wallet) await wallet.connect() + } + if (!wallet?.publicKey) { + throw new Error('No valid wallet connected') + } + + const ownedNfts = await Metadata.findDataByOwner( + connection.current, + wallet.publicKey + ) + console.log('NFT wallet contents', ownedNfts) + const verfiedNfts = filterAndMapVerifiedCollections(ownedNfts) + console.log('NFT verified nft by collection', verfiedNfts) + + const verifiedCollections = {} + for (const collectionKey in verfiedNfts) { + const collectionInfo = await enrichCollectionInfo( + connection.current, + collectionKey + ) + const nftsWithInfo = await Promise.all( + verfiedNfts[collectionKey].slice(0, 2).map((nft) => { + return enrichItemInfo(nft.data, nft.data.uri) + }) + ) + + verifiedCollections[collectionKey] = { + ...collectionInfo, + nfts: nftsWithInfo, + } + } + + console.log( + 'NFT verified collection metadata with nfts', + verifiedCollections + ) + if (Object.keys(verifiedCollections).length === 0) { + setError( + 'collectionInput', + { + type: 'error', + message: 'Current wallet has no verified collection', + }, + { shouldFocus: true } + ) + } else { + setCollectionsInWallet(verifiedCollections) + setIsModalOpen(true) + } + setWalletConnecting(false) + } catch (error) { + setWalletConnecting(false) + const err = error as Error + console.log(error) + return notify({ + type: 'error', + message: err.message, + }) + } + } + + return ( +
+ setIsModalOpen(false)} + walletPk={wallet?.publicKey} + collections={collectionsInWallet} + onSelect={({ key, collection }) => { + if (key && collection) { + handleClearSelectedNFT(true) + setValue('collectionKey', key) + setSelectedNFTCollection(collection) + } + }} + /> + +
+ ( + + Only{' '} + + Metaplex standard certified collections + {' '} + may be used. +
+ } + > + { + if (ev.key === 'Enter') { + handleAdd(ev.currentTarget.value) + } + }} + onPaste={handlePaste} + onBlur={(ev) => { + // field.onBlur() + handleAdd(ev.currentTarget.value) + }} + /> +
+ or + +
+ + )} + /> + + + {!unverifiedCollection && ( +
+ {requestPending ? ( + Getting collection data + ) : ( + + {!selectedNFTCollection?.name ? ( + 'Select a collection to preview...' + ) : ( + <> +
Verified collection
+
handleClearSelectedNFT(true)} + > + Clear +
+ + )} +
+ )} + +
+ {!selectedNFTCollection?.name ? ( + + ) : ( +
+
+ {selectedNFTCollection?.nfts + ?.slice(0, 3) + .map((nft, index) => { + return ( + collection item + ) + })} + +
+
+ + {selectedNFTCollection?.name || + '(Collection has no name)'} + + + {selectedNFTCollection?.external_url ? ( + + {selectedNFTCollection.external_url} + + ) : ( + '(No external address)' + )} + + + + + {collectionKey && abbreviateAddress(collectionKey)} + + +
+
+ )} +
+
+ )} + + {collectionKey && ( + <> + ( + + + + + } + type="tel" + placeholder="e.g. 10,000" + data-testid="nft-collection-count" + error={error?.message || ''} + {...field} + /> + + )} + /> + ( + + + + )} + /> + + } + > + +
+ With{' '} + {numberOfNFTs && !isNaN(numberOfNFTs) + ? Number(numberOfNFTs).toLocaleString() + : '???'}{' '} + NFT holders, +
+
+ {approvalSize?.toLocaleString() || '???'} members would need + to approve a proposal for it to pass. +
+
+
+ + )} +
+ onPrevClick(currentStep)} + /> + + ) +} diff --git a/components/NewRealmWizard/components/steps/BasicDetailsForm.tsx b/components/NewRealmWizard/components/steps/BasicDetailsForm.tsx new file mode 100644 index 0000000000..43ff4c4a34 --- /dev/null +++ b/components/NewRealmWizard/components/steps/BasicDetailsForm.tsx @@ -0,0 +1,132 @@ +import { useEffect } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import * as yup from 'yup' + +import FormHeader from '@components/NewRealmWizard/components/FormHeader' +import FormField from '@components/NewRealmWizard/components/FormField' +import FormFooter from '@components/NewRealmWizard/components/FormFooter' +import AdvancedOptionsDropdown from '@components/NewRealmWizard/components/AdvancedOptionsDropdown' +import Input from '@components/NewRealmWizard/components/Input' + +import { DEFAULT_GOVERNANCE_PROGRAM_ID } from '@components/instructions/tools' + +import { updateUserInput, validateSolAddress } from '@utils/formValidation' + +import { FORM_NAME as MUTISIG_FORM } from 'pages/realms/new/multisig' + +export const BasicDetailsSchema = { + avatar: yup.string(), + name: yup + .string() + .typeError('Required') + .required('Required') + .max(32, 'Name must not be longer than 32 characters'), + // description: yup.string(), + programId: yup + .string() + .test('is-valid-address', 'Please enter a valid Solana address', (value) => + value ? validateSolAddress(value) : true + ), +} + +export interface BasicDetails { + name: string + programId?: string +} + +export default function BasicDetailsForm({ + type, + formData, + currentStep, + totalSteps, + onSubmit, + onPrevClick, +}) { + const schema = yup.object(BasicDetailsSchema).required() + const { + setValue, + control, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + mode: 'all', + resolver: yupResolver(schema), + }) + + useEffect(() => { + updateUserInput(formData, BasicDetailsSchema, setValue) + }, []) + + function serializeValues(values) { + onSubmit({ step: currentStep, data: values }) + } + + return ( +
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + +
+ onPrevClick(currentStep)} + /> + + ) +} diff --git a/components/NewRealmWizard/components/steps/CommunityTokenDetailsForm.tsx b/components/NewRealmWizard/components/steps/CommunityTokenDetailsForm.tsx new file mode 100644 index 0000000000..082b08dc8a --- /dev/null +++ b/components/NewRealmWizard/components/steps/CommunityTokenDetailsForm.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import * as yup from 'yup' + +import { preventNegativeNumberInput } from '@utils/helpers' +import { updateUserInput, validateSolAddress } from '@utils/formValidation' + +import FormHeader from '@components/NewRealmWizard/components/FormHeader' +import FormField from '@components/NewRealmWizard/components/FormField' +import FormFooter from '@components/NewRealmWizard/components/FormFooter' +import AdvancedOptionsDropdown from '@components/NewRealmWizard/components/AdvancedOptionsDropdown' +import Input, { RadioGroup } from '@components/NewRealmWizard/components/Input' +import { GenericTokenIcon } from '@components/NewRealmWizard/components/TokenInfoTable' +import TokenInput, { TokenWithMintInfo, COMMUNITY_TOKEN } from '../TokenInput' + +export const CommunityTokenSchema = { + useExistingCommunityToken: yup + .boolean() + .oneOf([true, false], 'You must specify whether you have a token already') + .required('Required'), + communityTokenMintAddress: yup + .string() + .when('useExistingCommunityToken', { + is: true, + then: yup.string().required('Required'), + otherwise: yup.string().optional(), + }) + .test('is-valid-address', 'Please enter a valid Solana address', (value) => + value ? validateSolAddress(value) : true + ), + transferCommunityMintAuthority: yup + .boolean() + .oneOf( + [true, false], + 'You must specify whether you which to transfer mint authority' + ) + .when('useExistingCommunityToken', { + is: true, + then: yup.boolean().required('Required'), + otherwise: yup.boolean().optional(), + }), + minimumNumberOfCommunityTokensToGovern: yup + .number() + .positive('Must be greater than 0') + .transform((value) => (isNaN(value) ? undefined : value)) + .when(['suggestedMinTokenAmount', 'useExistingCommunityToken'], { + is: (suggestedMinTokenAmount, useExistingCommunityToken) => { + if (useExistingCommunityToken === false) return false + return suggestedMinTokenAmount > 0 + }, + then: (schema) => schema.required('Required'), + otherwise: (schema) => schema.optional(), + }), + communityMintSupplyFactor: yup + .number() + .positive('Must be greater than 0') + .max(1, 'Must not be greater than 1') + .transform((value) => (isNaN(value) ? undefined : value)), +} + +export interface CommunityToken { + useExistingToken: boolean + communityTokenMintAddress?: string + transferCommunityMintAuthority?: boolean + minimumNumberOfCommunityTokensToGovern?: number + communityMintSupplyFactor?: number +} + +export default function CommunityTokenForm({ + type, + formData, + currentStep, + totalSteps, + onSubmit, + onPrevClick, +}) { + const schema = yup.object(CommunityTokenSchema).required() + const { + watch, + control, + setValue, + handleSubmit, + formState: { isValid }, + } = useForm({ + mode: 'all', + resolver: yupResolver(schema), + }) + const useExistingCommunityToken = watch('useExistingCommunityToken') + const [communityTokenInfo, setCommunityTokenInfo] = useState< + TokenWithMintInfo | undefined + >() + + useEffect(() => { + updateUserInput(formData, CommunityTokenSchema, setValue) + }, []) + + useEffect(() => { + if (!useExistingCommunityToken) { + setValue('communityTokenMintAddress', undefined) + setValue('suggestedMinTokenAmount', undefined) + setValue('minimumNumberOfCommunityTokensToGovern', undefined) + setValue('transferCommunityMintAuthority', undefined, { + shouldValidate: true, + }) + } + }, [useExistingCommunityToken]) + + function handleTokenInput({ suggestedMinTokenAmount, tokenInfo }) { + setCommunityTokenInfo(tokenInfo) + setValue('transferCommunityMintAuthority', undefined, { + shouldValidate: true, + }) + setValue('suggestedMinTokenAmount', suggestedMinTokenAmount) + if (suggestedMinTokenAmount > 0) { + setValue( + 'minimumNumberOfCommunityTokensToGovern', + suggestedMinTokenAmount + ) + } else { + setValue('minimumNumberOfCommunityTokensToGovern', undefined) + } + } + + function serializeValues(values) { + const data = { + transferCommunityMintAuthority: null, + minimumNumberOfCommunityTokensToGovern: null, + communityMintSupplyFactor: null, + ...values, + } + if (values.useExistingCommunityToken) { + data.communityTokenInfo = communityTokenInfo + } else { + data.communityTokenMintAddress = null + data.transferCommunityMintAuthority = null + data.communityTokenInfo = null + } + + onSubmit({ step: currentStep, data }) + } + + return ( +
+ +
+ ( +
+ + + +
+ )} + /> + {useExistingCommunityToken && ( + + )} +
+ {useExistingCommunityToken === false && ( + + ( + + } + error={error?.message || ''} + {...field} + onChange={(ev) => { + preventNegativeNumberInput(ev) + field.onChange(ev) + }} + /> + + )} + /> + + )} + {useExistingCommunityToken && ( + + ( + + } + data-testid="programId-input" + error={error?.message || ''} + {...field} + onChange={(ev) => { + preventNegativeNumberInput(ev) + field.onChange(ev) + }} + /> + + )} + /> + + )} + + onPrevClick(currentStep)} + /> + + ) +} diff --git a/components/NewRealmWizard/components/steps/InviteMembersForm.tsx b/components/NewRealmWizard/components/steps/InviteMembersForm.tsx new file mode 100644 index 0000000000..8d29a1ac99 --- /dev/null +++ b/components/NewRealmWizard/components/steps/InviteMembersForm.tsx @@ -0,0 +1,345 @@ +import React, { useEffect, useState, useRef } from 'react' +import { useForm } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import * as yup from 'yup' +import type { ConditionBuilder } from 'yup/lib/Condition' +import clsx from 'clsx' + +import useWalletStore from 'stores/useWalletStore' +import FormHeader from '@components/NewRealmWizard/components/FormHeader' +import FormField from '@components/NewRealmWizard/components/FormField' +import FormFooter from '@components/NewRealmWizard/components/FormFooter' +import Input from '@components/NewRealmWizard/components/Input' + +import { updateUserInput, validateSolAddress } from '@utils/formValidation' +import { FORM_NAME as MULTISIG_FORM } from 'pages/realms/new/multisig' +import { textToAddressList } from '@utils/textToAddressList' + +/** + * Convert a list of addresses into a list of uniques and duplicates + */ +const splitUniques = (addresses: string[]) => { + const unique = new Set() + const duplicate: string[] = [] + + addresses.forEach((address) => { + if (unique.has(address)) { + duplicate.push(address) + } else { + unique.add(address) + } + }) + + return { duplicate, unique: Array.from(unique.values()) } +} + +function InviteAddress({ + address = '', + currentUser = false, + index, + invalid = false, + onRemoveClick, +}) { + return ( +
+
+ {invalid ? ( + + + + + ) : ( +
+
+ {currentUser ? 'Me' : index} +
+
+ )} +
+ {address} +
+
+ +
+ ) +} + +export const InviteMembersSchema = { + memberAddresses: yup + .array() + .of(yup.string()) + .when(['$addCouncil', '$useExistingCouncilToken'], (( + addCouncil, + useExistingCouncilToken, + schema + ) => { + if (useExistingCouncilToken) { + return schema.min(0).required('Required') + } else if (typeof addCouncil === 'undefined') { + return schema + .min(1, 'A DAO needs at least one member') + .required('Required') + } else { + return addCouncil + ? schema + .min(1, 'A DAO needs at least one member') + .required('Required') + : schema + } + }) as ConditionBuilder), +} + +export interface InviteMembers { + memberAddresses: string[] +} + +export default function InviteMembersForm({ + visible, + type, + formData, + onSubmit, + onPrevClick, + currentStep, + totalSteps, +}) { + const { current } = useWalletStore((s) => s) + const userAddress = current?.publicKey?.toBase58() + const inputElement = useRef(null) + const [inviteList, setInviteList] = useState([]) + const [invalidAddresses, setInvalidAddresses] = useState([]) + const [lacksMintAuthority, setLackMintAuthority] = useState(false) + + const schema = yup.object(InviteMembersSchema) + const { + setValue, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + mode: 'all', + resolver: yupResolver(schema), + context: formData, + }) + + useEffect(() => { + if (typeof formData.addCouncil === 'undefined' || formData?.addCouncil) { + updateUserInput(formData, InviteMembersSchema, setValue) + if ( + formData.useExistingCouncilToken && + formData.councilTokenInfo?.mint?.mintAuthority?.toBase58() !== + userAddress + ) { + setLackMintAuthority(true) + setInviteList([]) + setInvalidAddresses([]) + } else { + setLackMintAuthority(false) + setInviteList( + (current) => + formData.memberAddresses?.filter((wallet) => { + return validateSolAddress(wallet) + }) || current + ) + } + } else if (visible) { + // go to next step: + serializeValues({ memberAddresses: null }) + } + }, [formData]) + + useEffect(() => { + setValue('memberAddresses', splitUniques(inviteList).unique, { + shouldValidate: true, + shouldDirty: true, + }) + }, [inviteList]) + + // The user can get to this screen without connecting their wallet. If they + // connect their wallet after being in a disconnected state, we want to + // populate the invite list with their wallet address. + useEffect(() => { + if ( + userAddress && + !inviteList.includes(userAddress) && + !lacksMintAuthority + ) { + setInviteList((currentList) => currentList.concat(userAddress)) + } + }, [userAddress]) + + function serializeValues(values) { + onSubmit({ step: currentStep, data: values }) + } + + function addToAddressList(textBlock: string) { + if (lacksMintAuthority) { + return + } + + const { valid, invalid } = textToAddressList(textBlock) + const { unique, duplicate } = splitUniques(inviteList.concat(valid)) + setInviteList(unique) + setInvalidAddresses((currentList) => + currentList.concat(invalid).concat(duplicate) + ) + } + + function handleBlur(ev) { + addToAddressList(ev.currentTarget.value) + ev.currentTarget.value = '' + } + + function handlePaste(ev: React.ClipboardEvent) { + addToAddressList(ev.clipboardData.getData('text')) + ev.clipboardData.clearData() + // Don't allow the paste event to populate the input field + ev.preventDefault() + } + + function handleKeyDown(ev) { + if (ev.defaultPrevented) { + return // Do nothing if the event was already processed + } + + if (ev.key === 'Enter') { + addToAddressList(ev.currentTarget.value) + ev.currentTarget.value = '' + ev.preventDefault() + } + } + + function removeAddressFromInviteList(address) { + const newList = inviteList.slice() + const index = inviteList.indexOf(address) + if (index > -1) { + newList.splice(index, 1) + setInviteList(newList) + } + } + + function removeAddressFromInvalidList(address) { + const newList = invalidAddresses.slice() + const index = invalidAddresses.indexOf(address) + if (index > -1) { + newList.splice(index, 1) + setInvalidAddresses(newList) + } + } + + const error = + errors.daoName?.message || + (invalidAddresses.length > 0 + ? 'Invalid and duplicate addresses will not be included' + : '') + + return ( +
+ +
+ + {inviteList.length} +
+ ) + } + > + {(inviteList.length > 0 || invalidAddresses.length > 0) && ( +
+ {inviteList.map((address, index) => ( + removeAddressFromInviteList(address)} + /> + ))} + {invalidAddresses.map((address, index) => ( + removeAddressFromInvalidList(address)} + /> + ))} +
+ )} + + +
+ onPrevClick(currentStep)} + /> + + ) +} diff --git a/components/NewRealmWizard/components/steps/YesVotePercentageThresholdForm.tsx b/components/NewRealmWizard/components/steps/YesVotePercentageThresholdForm.tsx new file mode 100644 index 0000000000..8cc37e0293 --- /dev/null +++ b/components/NewRealmWizard/components/steps/YesVotePercentageThresholdForm.tsx @@ -0,0 +1,175 @@ +import { useEffect } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import * as yup from 'yup' + +import FormHeader from '@components/NewRealmWizard/components/FormHeader' +import FormField from '@components/NewRealmWizard/components/FormField' +import FormFooter from '@components/NewRealmWizard/components/FormFooter' +import { InputRangeSlider } from '@components/NewRealmWizard/components/Input' +import Text from '@components/Text' +import AdviceBox from '@components/NewRealmWizard/components/AdviceBox' + +import { updateUserInput } from '@utils/formValidation' +import { FORM_NAME as MUTISIG_FORM } from 'pages/realms/new/multisig' + +export const CommunityYesVotePercentageSchema = { + communityYesVotePercentage: yup + .number() + .transform((value) => (isNaN(value) ? 0 : value)) + .max(100, 'Approval cannot require more than 100% of votes') + .min(1, 'Approval must be at least 1% of votes') + .required('Required'), +} + +export interface CommunityYesVotePercentage { + communityYesVotePercentage: number +} + +export const CouncilYesVotePercentageSchema = { + councilYesVotePercentage: yup + .number() + .transform((value) => (isNaN(value) ? 0 : value)) + .max(100, 'Approval cannot require more than 100% of votes') + .when('$memberAddresses', (memberAddresses, schema) => { + if (memberAddresses) { + return schema + .min(1, 'Quorum must be at least 1% of member') + .required('Required') + } else { + return schema.min(1, 'Quorum must be at least 1% of member') + } + }), +} + +export interface CouncilYesVotePercentage { + councilYesVotePercentage: number +} + +export default function YesVotePercentageForm({ + type, + formData, + currentStep, + totalSteps, + forCouncil = false, + forCommunity = false, + onSubmit, + onPrevClick, +}) { + const schema = yup + .object( + forCommunity + ? CommunityYesVotePercentageSchema + : CouncilYesVotePercentageSchema + ) + .required() + const { + control, + setValue, + handleSubmit, + watch, + formState: { isValid }, + } = useForm({ + mode: 'all', + resolver: yupResolver(schema), + context: formData, + }) + const fieldName = forCommunity + ? 'communityYesVotePercentage' + : forCouncil + ? 'councilYesVotePercentage' + : 'yesVotePercentage' + const yesVotePercentage = watch(fieldName) || 0 + + useEffect(() => { + updateUserInput( + formData, + forCommunity + ? CommunityYesVotePercentageSchema + : CouncilYesVotePercentageSchema, + setValue + ) + }, []) + + function serializeValues(values) { + onSubmit({ step: currentStep, data: values }) + } + + return ( +
+ +
+ ( + + + + )} + /> +
+ } + > + {forCommunity ? ( + + Typically, newer DAOs start their community approval quorums around + 60% of total token supply. + + ) : forCouncil && formData?.memberAddresses?.length >= 0 ? ( + <> + + With {formData.memberAddresses.length} members added to your{' '} + {type === MUTISIG_FORM ? 'wallet' : 'DAO'}, + + + {Math.ceil( + (yesVotePercentage * formData.memberAddresses.length) / 100 + )}{' '} + members would need to approve a proposal for it to pass. + + + ) : ( + + Typically, newer DAOs start their approval percentage around 60%. + + )} + + + onPrevClick(currentStep)} + /> + + ) +} diff --git a/components/PageBodyContainer.tsx b/components/PageBodyContainer.tsx index 5ee9368ea7..62296b9f20 100644 --- a/components/PageBodyContainer.tsx +++ b/components/PageBodyContainer.tsx @@ -1,15 +1,30 @@ -const PageBodyContainer = ({ children }) => ( -
-
- - - - -
-
- {children} -
-
-) +import { useRouter } from 'next/router' +import Footer from '@components/Footer' + +const PageBodyContainer = ({ children }) => { + const { pathname } = useRouter() + const isNewRealmsWizard = /\/realms\/new\/\w+/.test(pathname) + + return ( + <> +
+
+ + + + +
+
+ {children} +
+
+ {isNewRealmsWizard ? <> :