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 (
-
+
)
}
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 (
+
+ )
+}
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 (
+
+
+
+
+
+
+ {type === MULTISIG_FORM ? (
+
+
+ {formData?.memberAddresses?.length}
+
+
+
+ {formData?.councilYesVotePercentage}
+
+ %
+
+
+
+ ) : (
+ <>
+
+ {(formData.addCouncil || formData?.memberAddresses?.length > 0) && (
+
+ )}
+ >
+ )}
+ {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 (
+
+ )
+}
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
+
+
+
+
+
+
+
+
+ )
+ )
+}
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 ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ {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 (
+
+ )
+}
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 (
+
+ 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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+
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 (
+
+ )
+}
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 ? <>> : }
+ >
+ )
+}
export default PageBodyContainer
diff --git a/components/RealmWizard/RealmWizard.tsx b/components/RealmWizard/RealmWizard.tsx
index be0eca25d5..6e3240bc6c 100644
--- a/components/RealmWizard/RealmWizard.tsx
+++ b/components/RealmWizard/RealmWizard.tsx
@@ -36,13 +36,9 @@ import { useEffect } from 'react'
import { CreateFormSchema } from './validators/createRealmValidator'
import { formValidation, isFormValid } from '@utils/formValidation'
import { registerRealm } from 'actions/registerRealm'
-import {
- getGovernanceProgramVersion,
- MintMaxVoteWeightSource,
-} from '@solana/spl-governance'
+import { getGovernanceProgramVersion } from '@solana/spl-governance'
import Switch from '@components/Switch'
-import { BN } from '@project-serum/anchor'
-import BigNumber from 'bignumber.js'
+import { parseMintMaxVoteWeight } from '@tools/governance/units'
enum LoaderMessage {
CREATING_ARTIFACTS = 'Creating the DAO artifacts..',
@@ -152,23 +148,6 @@ const RealmWizard: React.FC = () => {
})
}
- /**
- * Get the mint max vote weight parsed to `MintMaxVoteWeightSource`
- */
- const getMintMaxVoteWeight = () => {
- let value = MintMaxVoteWeightSource.FULL_SUPPLY_FRACTION.value
- if (form.communityMintMaxVoteWeightSource) {
- const fraction = new BigNumber(form.communityMintMaxVoteWeightSource)
- .shiftedBy(MintMaxVoteWeightSource.SUPPLY_FRACTION_DECIMALS)
- .toString()
- value = new BN(fraction)
- }
-
- return new MintMaxVoteWeightSource({
- value,
- })
- }
-
/**
* Get the array of wallets parsed into public keys or undefined if not eligible
*/
@@ -209,7 +188,7 @@ const RealmWizard: React.FC = () => {
? new PublicKey(form.communityMintId)
: undefined,
form.councilMintId ? new PublicKey(form.councilMintId) : undefined,
- getMintMaxVoteWeight(),
+ parseMintMaxVoteWeight(form.communityMintMaxVoteWeightSource),
form.minCommunityTokensToCreateGovernance!,
form.yesThreshold,
form.communityMintId ? form.transferAuthority : true,
@@ -415,10 +394,10 @@ const RealmWizard: React.FC = () => {
>
@@ -438,7 +417,7 @@ const RealmWizard: React.FC = () => {
} pr-10 mr-3 mt-10`}
>
{ctl.getMode() === RealmWizardMode.BASIC && ctl.isLastStep() && (
-
+
{
}
}, [realm?.pubkey.toBase58(), wallet?.connected])
return (
-
+
Your NFTS
{
}`}
>
View
-
+
{!connected ? (
-
Please connect your wallet
+
Please connect your wallet
) : !isLoading ? (
{
diff --git a/package.json b/package.json
index 27056c6a37..14737dea91 100644
--- a/package.json
+++ b/package.json
@@ -34,8 +34,9 @@
"@emotion/styled": "^11.8.1",
"@foresight-tmp/foresight-sdk": "^0.1.46",
"@friktion-labs/friktion-sdk": "^1.1.118",
- "@headlessui/react": "^1.6.0",
+ "@headlessui/react": "^1.6.4",
"@heroicons/react": "^1.0.1",
+ "@hookform/resolvers": "^2.8.10",
"@marinade.finance/marinade-ts-sdk": "^2.0.9",
"@metaplex-foundation/mpl-token-metadata": "^1.2.5",
"@mithraic-labs/serum-remote": "^0.0.1-rc.16",
@@ -81,6 +82,7 @@
"react": "^18.1.0",
"react-dom": "^18.0.0",
"react-headless-pagination": "^0.1.0",
+ "react-hook-form": "^7.31.3",
"react-markdown": "^7.0.0",
"react-portal": "^4.2.2",
"remark-gfm": "^3.0.1",
diff --git a/pages/_app.tsx b/pages/_app.tsx
index 9d321cbff7..358bf64088 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -2,6 +2,7 @@ import { ThemeProvider } from 'next-themes'
import '@dialectlabs/react-ui/index.css'
// import '../styles/ambit-font.css'
import '../styles/index.css'
+import '../styles/typography.css'
import useWallet from '../hooks/useWallet'
import NavBar from '../components/NavBar'
import PageBodyContainer from '../components/PageBodyContainer'
@@ -9,7 +10,6 @@ import useHydrateStore from '../hooks/useHydrateStore'
import useRealm from '../hooks/useRealm'
import { getResourcePathPart } from '../tools/core/resources'
import handleRouterHistory from '@hooks/handleRouterHistory'
-import Footer from '@components/Footer'
import { useEffect } from 'react'
import useDepositStore from 'VoteStakeRegistry/stores/useDepositStore'
import useWalletStore from 'stores/useWalletStore'
@@ -28,6 +28,7 @@ import TransactionLoader from '@components/TransactionLoader'
import dynamic from 'next/dynamic'
import Head from 'next/head'
+
const Notifications = dynamic(() => import('../components/Notification'), {
ssr: false,
})
@@ -205,7 +206,6 @@ function App({ Component, pageProps }) {
-
)
}
diff --git a/pages/dao/[symbol]/params/GovernanceConfigModal.tsx b/pages/dao/[symbol]/params/GovernanceConfigModal.tsx
index cf99c60ebc..a36e9e38b1 100644
--- a/pages/dao/[symbol]/params/GovernanceConfigModal.tsx
+++ b/pages/dao/[symbol]/params/GovernanceConfigModal.tsx
@@ -29,6 +29,7 @@ import {
} from '@tools/sdk/units'
import { abbreviateAddress } from '@utils/formatting'
import * as yup from 'yup'
+import { MAX_TOKENS_TO_DISABLE } from '@tools/constants'
interface GovernanceConfigForm extends BaseGovernanceFormFields {
title: string
@@ -58,10 +59,12 @@ const GovernanceConfigModal = ({
title: '',
description: '',
minCommunityTokensToCreateProposal: mint
- ? getMintDecimalAmountFromNatural(
- mint,
- config?.minCommunityTokensToCreateProposal
- ).toNumber()
+ ? MAX_TOKENS_TO_DISABLE.eq(config?.minCommunityTokensToCreateProposal)
+ ? MAX_TOKENS_TO_DISABLE.toString()
+ : getMintDecimalAmountFromNatural(
+ mint,
+ config?.minCommunityTokensToCreateProposal
+ ).toNumber()
: 0,
minInstructionHoldUpTime: getDaysFromTimestamp(
config?.minInstructionHoldUpTime
@@ -128,8 +131,8 @@ const GovernanceConfigModal = ({
onClose={closeProposalModal}
isOpen={isProposalModalOpen}
>
-
-
+
+
Change Governance Config:{' '}
{governance && abbreviateAddress(governance.pubkey)}
@@ -172,7 +175,7 @@ const GovernanceConfigModal = ({
setFormErrors={setFormErrors}
>
-
+