diff --git a/apps/web/src/components/LinksProvider.tsx b/apps/web/src/components/LinksProvider.tsx index 47a75e8c2..6ea1e04ed 100644 --- a/apps/web/src/components/LinksProvider.tsx +++ b/apps/web/src/components/LinksProvider.tsx @@ -1,4 +1,10 @@ -import { AddressType, CHAIN_ID } from '@buildeross/types' +import { + AddressType, + CHAIN_ID, + DaoTab, + ProposalCreateStage, + ProposalTab, +} from '@buildeross/types' import { LinksProvider as BaseLinksProvider } from '@buildeross/ui/LinksProvider' import { chainIdToSlug } from '@buildeross/utils/chains' import React from 'react' @@ -26,7 +32,7 @@ export const LinksProvider: React.FC = ({ children }) => { ) const getDaoLink = React.useCallback( - (chainId: CHAIN_ID, tokenAddress: AddressType, tab?: string) => { + (chainId: CHAIN_ID, tokenAddress: AddressType, tab?: DaoTab) => { const baseHref = `/dao/${chainIdToSlug(chainId)}/${tokenAddress}` return { href: tab ? `${baseHref}?tab=${tab}` : baseHref, @@ -40,7 +46,7 @@ export const LinksProvider: React.FC = ({ children }) => { chainId: CHAIN_ID, tokenAddress: AddressType, proposalId: string | number | bigint, - tab?: string + tab?: ProposalTab ) => { const baseHref = `/dao/${chainIdToSlug(chainId)}/${tokenAddress}/vote/${proposalId}` return { @@ -51,9 +57,10 @@ export const LinksProvider: React.FC = ({ children }) => { ) const getProposalCreateLink = React.useCallback( - (chainId: CHAIN_ID, tokenAddress: AddressType) => { + (chainId: CHAIN_ID, tokenAddress: AddressType, stage?: ProposalCreateStage) => { + const baseHref = `/dao/${chainIdToSlug(chainId)}/${tokenAddress}/proposal/create` return { - href: `/dao/${chainIdToSlug(chainId)}/${tokenAddress}/proposal/create`, + href: stage ? `${baseHref}?stage=${stage}` : baseHref, } }, [] diff --git a/apps/web/src/layouts/BaseLayout/BaseLayout.tsx b/apps/web/src/layouts/BaseLayout/BaseLayout.tsx index 6bb7a9be7..1c3f14ebf 100644 --- a/apps/web/src/layouts/BaseLayout/BaseLayout.tsx +++ b/apps/web/src/layouts/BaseLayout/BaseLayout.tsx @@ -30,6 +30,7 @@ export function BaseLayout({ nav, ...props }: BaseLayoutProps) { + const { style, ...rest } = props const chainStore = useMemo(() => createChainStore(chain), [chain]) const daoStore = useMemo(() => createDaoStore(addresses), [addresses]) const { openConnectModal } = useConnectModal() @@ -38,9 +39,11 @@ export function BaseLayout({ - + {nav || } - {children} + + {children} + {footer} diff --git a/apps/web/src/layouts/DaoLayout/DaoLayout.tsx b/apps/web/src/layouts/DaoLayout/DaoLayout.tsx index 45203863d..787997e0c 100644 --- a/apps/web/src/layouts/DaoLayout/DaoLayout.tsx +++ b/apps/web/src/layouts/DaoLayout/DaoLayout.tsx @@ -9,14 +9,25 @@ type DaoPageProps = { chainId?: number } -export function getDaoLayout(page: ReactElement) { +type DaoLayoutOptions = { + hideFooterOnMobile?: boolean +} + +export function getDaoLayout( + page: ReactElement, + options?: DaoLayoutOptions +) { const addresses = page.props?.addresses ?? {} const chainId = page.props?.chainId ?? 1 const chain = PUBLIC_DEFAULT_CHAINS.find((c) => c.id === chainId) ?? PUBLIC_DEFAULT_CHAINS[0] return ( - + {page} ) diff --git a/apps/web/src/modules/coin/CreateContentCoinForm/NoCreatorCoinWarning.tsx b/apps/web/src/modules/coin/CreateContentCoinForm/NoCreatorCoinWarning.tsx index c2398b56d..90e6521ea 100644 --- a/apps/web/src/modules/coin/CreateContentCoinForm/NoCreatorCoinWarning.tsx +++ b/apps/web/src/modules/coin/CreateContentCoinForm/NoCreatorCoinWarning.tsx @@ -10,7 +10,7 @@ import { useAccount } from 'wagmi' export const NoCreatorCoinWarning: React.FC = () => { const router = useRouter() const { address: userAddress } = useAccount() - const { createProposal, setTransactionType } = useProposalStore() + const { startProposalDraft } = useProposalStore() // Get addresses and chain from stores const addresses = useDaoStore((x) => x.addresses) @@ -36,17 +36,17 @@ export const NoCreatorCoinWarning: React.FC = () => { // Handle creating a Creator Coin proposal const handleCreateCreatorCoinProposal = () => { - setTransactionType(TransactionType.CREATOR_COIN) - createProposal({ - title: undefined, - summary: undefined, - disabled: false, - transactions: [], + startProposalDraft({ + transactionType: TransactionType.CREATOR_COIN, }) // Navigate to proposal create page router.push({ pathname: '/dao/[network]/[token]/proposal/create', - query: router.query, + query: { + network: router.query.network, + token: router.query.token, + stage: 'transactions', + }, }) } diff --git a/apps/web/src/pages/dao/[network]/[token]/[tokenId].tsx b/apps/web/src/pages/dao/[network]/[token]/[tokenId].tsx index 92614d648..0cc212cb0 100644 --- a/apps/web/src/pages/dao/[network]/[token]/[tokenId].tsx +++ b/apps/web/src/pages/dao/[network]/[token]/[tokenId].tsx @@ -15,7 +15,7 @@ import { useGalleryItems } from '@buildeross/hooks/useGalleryItems' import { useVotes } from '@buildeross/hooks/useVotes' import { OrderDirection, SubgraphSDK, Token_OrderBy } from '@buildeross/sdk/subgraph' import { DaoContractAddresses } from '@buildeross/stores' -import { AddressType, Chain, CHAIN_ID } from '@buildeross/types' +import { AddressType, Chain, CHAIN_ID, ProposalCreateStage } from '@buildeross/types' import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' import { isChainIdSupportedByDroposal } from '@buildeross/utils/droposal' import { isPossibleMarkdown } from '@buildeross/utils/helpers' @@ -126,15 +126,19 @@ const TokenPage: NextPageWithLayout = ({ }) }, [push, chain.slug, addresses.token]) - const openProposalCreatePage = React.useCallback(async () => { - await push({ - pathname: `/dao/[network]/[token]/proposal/create`, - query: { - network: chain.slug, - token: addresses.token, - }, - }) - }, [push, chain.slug, addresses.token]) + const openProposalCreatePage = React.useCallback( + async (stage?: ProposalCreateStage) => { + await push({ + pathname: `/dao/[network]/[token]/proposal/create`, + query: { + network: chain.slug, + token: addresses.token, + ...(stage ? { stage } : {}), + }, + }) + }, + [push, chain.slug, addresses.token] + ) const openProposalReviewPage = React.useCallback(async () => { await push({ diff --git a/apps/web/src/pages/dao/[network]/[token]/index.tsx b/apps/web/src/pages/dao/[network]/[token]/index.tsx index 7a8210847..9ee149ea7 100644 --- a/apps/web/src/pages/dao/[network]/[token]/index.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/index.tsx @@ -16,7 +16,7 @@ import { import { auctionAbi, getDAOAddresses, tokenAbi } from '@buildeross/sdk/contract' import { OrderDirection, SubgraphSDK, Token_OrderBy } from '@buildeross/sdk/subgraph' import { DaoContractAddresses, useChainStore, useDaoStore } from '@buildeross/stores' -import { AddressType, CHAIN_ID } from '@buildeross/types' +import { AddressType, CHAIN_ID, ProposalCreateStage } from '@buildeross/types' import { unpackOptionalArray } from '@buildeross/utils/helpers' import { serverConfig } from '@buildeross/utils/wagmi/serverConfig' import { atoms, Flex, Text, theme } from '@buildeross/zord' @@ -135,15 +135,19 @@ const DaoPage: NextPageWithLayout = ({ chainId, collectionAddress [push, chain.slug, addresses.token] ) - const openProposalCreatePage = React.useCallback(async () => { - await push({ - pathname: `/dao/[network]/[token]/proposal/create`, - query: { - network: chain.slug, - token: addresses.token, - }, - }) - }, [push, chain.slug, addresses.token]) + const openProposalCreatePage = React.useCallback( + async (stage?: ProposalCreateStage) => { + await push({ + pathname: `/dao/[network]/[token]/proposal/create`, + query: { + network: chain.slug, + token: addresses.token, + ...(stage ? { stage } : {}), + }, + }) + }, + [push, chain.slug, addresses.token] + ) const openProposalReviewPage = React.useCallback(async () => { await push({ @@ -205,7 +209,7 @@ const DaoPage: NextPageWithLayout = ({ chainId, collectionAddress ...baseSections, ...minterSections, { - title: 'Smart Contracts', + title: 'Contracts', component: [], }, ] diff --git a/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx b/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx index 04d31fca0..da47a4a44 100644 --- a/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx @@ -3,8 +3,10 @@ import { CACHE_TIMES } from '@buildeross/constants/cacheTimes' import { L1_CHAINS, PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' import { CreateProposalHeading, + MobileProposalActionBar, + ProposalDraftForm, + ProposalStageIndicator, Queue, - SelectTransactionType, TRANSACTION_FORM_OPTIONS, TransactionForm, TransactionTypeIcon, @@ -23,7 +25,7 @@ import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' import { isChainIdSupportedByDroposal } from '@buildeross/utils/droposal' import { isChainIdSupportedByEAS } from '@buildeross/utils/eas' import { isChainIdSupportedBySablier } from '@buildeross/utils/sablier/constants' -import { Box, Flex, Stack } from '@buildeross/zord' +import { Box, Button, Flex, Icon, Stack, Text } from '@buildeross/zord' import { GetServerSideProps } from 'next' import { useRouter } from 'next/router' import React, { useCallback, useEffect, useMemo } from 'react' @@ -37,6 +39,7 @@ import { useAccount, useReadContract } from 'wagmi' const createSelectOption = (type: TransactionType) => ({ value: type, label: TRANSACTION_TYPES[type].title, + description: TRANSACTION_TYPES[type].subTitle, icon: , }) @@ -49,6 +52,23 @@ const CreateProposalPage: NextPageWithLayout = () => { const setTransactionType = useProposalStore((x) => x.setTransactionType) const resetTransactionType = useProposalStore((x) => x.resetTransactionType) const transactions = useProposalStore((x) => x.transactions) + const title = useProposalStore((x) => x.title) + const summary = useProposalStore((x) => x.summary) + const setTitle = useProposalStore((x) => x.setTitle) + const setSummary = useProposalStore((x) => x.setSummary) + const clearProposal = useProposalStore((x) => x.clearProposal) + + const initialStageFromQuery = query?.stage === 'transactions' ? 'transactions' : 'draft' + const [createStage, setCreateStage] = React.useState<'draft' | 'transactions'>(() => + initialStageFromQuery === 'transactions' || transactionType || transactions.length > 0 + ? 'transactions' + : 'draft' + ) + const [furthestStage, setFurthestStage] = React.useState<'draft' | 'transactions'>( + initialStageFromQuery === 'transactions' || transactionType || transactions.length > 0 + ? 'transactions' + : 'draft' + ) const { data: paused } = useReadContract({ abi: auctionAbi, @@ -157,6 +177,74 @@ const CreateProposalPage: NextPageWithLayout = () => { return TRANSACTION_FORM_OPTIONS_FILTERED.map(createSelectOption) }, [TRANSACTION_FORM_OPTIONS_FILTERED]) + const missingDraftRequirements = useMemo(() => { + const requirements: string[] = [] + + if (!title?.trim()) { + requirements.push('add a proposal title') + } + + if (!summary?.trim()) { + requirements.push('add a proposal summary') + } + + return requirements + }, [title, summary]) + + const missingReviewRequirements = useMemo(() => { + const requirements = [...missingDraftRequirements] + + if (!transactions.length) { + requirements.push('queue at least one transaction') + } + + return requirements + }, [missingDraftRequirements, transactions.length]) + + const canStartTransactions = missingDraftRequirements.length === 0 + const canContinueToReview = missingReviewRequirements.length === 0 + const isMissingTitle = !title?.trim() + const isMissingDescription = !summary?.trim() + const isMissingTransactions = transactions.length === 0 + + const joinRequirements = (requirements: string[]) => { + if (requirements.length === 1) return requirements[0] + if (requirements.length === 2) return `${requirements[0]} and ${requirements[1]}` + return `${requirements.slice(0, -1).join(', ')}, and ${requirements.at(-1)}` + } + + const continueHelperText = useMemo(() => { + const requiredMissing: string[] = [] + + if (isMissingTitle) requiredMissing.push('a title') + if (isMissingDescription) requiredMissing.push('a description') + if (createStage === 'transactions' && isMissingTransactions) { + requiredMissing.push('at least one transaction') + } + + if (!requiredMissing.length) return null + + return `To continue, add ${joinRequirements(requiredMissing)}.` + }, [createStage, isMissingDescription, isMissingTitle, isMissingTransactions]) + + React.useEffect(() => { + if (transactionType) { + setCreateStage('transactions') + setFurthestStage('transactions') + } + }, [transactionType]) + + React.useEffect(() => { + if (query?.stage === 'transactions' && furthestStage === 'transactions') { + setCreateStage('transactions') + return + } + + if (query?.stage === 'draft') { + setCreateStage('draft') + } + }, [query?.stage, furthestStage]) + const openDaoActivityPage = useCallback(async () => { await push({ pathname: `/dao/[network]/[token]`, @@ -189,6 +277,39 @@ const CreateProposalPage: NextPageWithLayout = () => { }) }, [push, chain.slug, addresses.token]) + const onContinueStep = useCallback(async () => { + if (createStage === 'draft') { + if (!canStartTransactions) return + setCreateStage('transactions') + setFurthestStage('transactions') + return + } + + if (!canContinueToReview) return + await openProposalReviewPage() + }, [createStage, canStartTransactions, canContinueToReview, openProposalReviewPage]) + + const onBackStep = useCallback(() => { + if (createStage === 'transactions') { + setCreateStage('draft') + } + }, [createStage]) + + const onResetProposal = useCallback(() => { + clearProposal() + setCreateStage('draft') + setFurthestStage('draft') + }, [clearProposal]) + + const onStageSelect = useCallback( + (stage: 'draft' | 'transactions' | 'review') => { + if (stage === 'review') return + if (stage === 'transactions' && furthestStage !== 'transactions') return + setCreateStage(stage) + }, + [furthestStage] + ) + if (isLoading) return null if (!address) @@ -208,43 +329,170 @@ const CreateProposalPage: NextPageWithLayout = () => { 0)} - onOpenProposalReview={openProposalReviewPage} + showHelpLinks + showQueue={ + createStage === 'transactions' && + (!!transactionType || (!transactionType && transactions.length > 0)) + } + showContinue + showStepBack + onStepBack={onBackStep} + backDisabled={createStage === 'draft'} + showReset + onReset={onResetProposal} + continueDisabled={ + createStage === 'draft' ? !canStartTransactions : !canContinueToReview + } + onContinue={onContinueStep} + hideActionsOnMobile queueButtonClassName={!transactionType ? styles.showOnMobile : undefined} /> - {transactionType ? ( - - setTransactionType(value)} - /> - - + + { + if (stage === 'draft') { + return furthestStage === 'transactions' && createStage !== 'draft' } - /> + if (stage === 'transactions') { + return furthestStage === 'transactions' && createStage !== 'transactions' + } + return false + }} + /> + + {continueHelperText && ( + + + {continueHelperText} + + {createStage === 'transactions' && (isMissingTitle || isMissingDescription) && ( + setCreateStage('draft')} + > + Go to Write Proposal + + )} + + )} + + {createStage === 'draft' ? ( + + + ) : ( + + + + Select Transaction Type + + {transactionType ? ( + + + setTransactionType(value)} + /> + + + + ) : ( + setTransactionType(value)} + /> + )} + + + + + {!transactionType && ( + + void openDaoAdminPage()} + > + + + Configure DAO Settings + + + Change all the main DAO settings in the Admin Tab + + + + + + )} + + {transactionType && } + } rightColumn={ - transactions.length > 0 ? ( + transactions.length > 0 && !transactionType ? ( @@ -252,11 +500,28 @@ const CreateProposalPage: NextPageWithLayout = () => { } /> )} + + { + void onContinueStep() + }} + continueDisabled={ + createStage === 'draft' ? !canStartTransactions : !canContinueToReview + } + continueLabel={'Continue'} + /> ) } -CreateProposalPage.getLayout = getDaoLayout +CreateProposalPage.getLayout = (page) => getDaoLayout(page, { hideFooterOnMobile: true }) export default CreateProposalPage diff --git a/apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx b/apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx index 5736b964c..cc819ca3c 100644 --- a/apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx @@ -1,13 +1,17 @@ import { CACHE_TIMES } from '@buildeross/constants/cacheTimes' import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' -import { CreateProposalHeading, ReviewProposalForm } from '@buildeross/create-proposal-ui' +import { + CreateProposalHeading, + ProposalStageIndicator, + ReviewProposalForm, +} from '@buildeross/create-proposal-ui' import { useDelayedGovernance } from '@buildeross/hooks/useDelayedGovernance' import { useVotes } from '@buildeross/hooks/useVotes' import { getDAOAddresses } from '@buildeross/sdk/contract' import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' -import { AddressType } from '@buildeross/types' +import { AddressType, ProposalCreateStage } from '@buildeross/types' import { AnimatedModal, SuccessModalContent } from '@buildeross/ui/Modal' -import { atoms, Box, Flex, Icon, Stack, Text } from '@buildeross/zord' +import { Flex, Stack } from '@buildeross/zord' import { GetServerSideProps } from 'next' import { useRouter } from 'next/router' import React, { useCallback, useEffect, useRef, useState } from 'react' @@ -36,7 +40,7 @@ const ReviewProposalPage: NextPageWithLayout = () => { governorAddress: addresses.governor, }) - const { transactions, disabled, title, summary } = useProposalStore() + const { transactions, disabled, title, summary, clearProposal } = useProposalStore() const onOpenCreatePage = useCallback(async () => { await push({ @@ -48,6 +52,33 @@ const ReviewProposalPage: NextPageWithLayout = () => { }) }, [push, chain.slug, addresses.token]) + const onOpenCreateStage = useCallback( + async (stage: ProposalCreateStage) => { + await push({ + pathname: `/dao/[network]/[token]/proposal/create`, + query: { + network: chain.slug, + token: addresses.token, + stage, + }, + }) + }, + [push, chain.slug, addresses.token] + ) + + const onResetProposal = useCallback(async () => { + clearProposal() + + await push({ + pathname: `/dao/[network]/[token]/proposal/create`, + query: { + network: chain.slug, + token: addresses.token, + stage: 'draft', + }, + }) + }, [clearProposal, push, chain.slug, addresses.token]) + const successTimerRef = useRef | null>(null) const isNavigatingRef = useRef(false) const [proposalIdCreated, setProposalIdCreated] = useState( @@ -113,6 +144,29 @@ const ReviewProposalPage: NextPageWithLayout = () => { } }, [handleCloseSuccessModal, proposalIdCreated]) + useEffect(() => { + if (proposalIdCreated !== undefined) return + if (transactions.length > 0) return + if (title?.trim() || summary?.trim()) return + + void push({ + pathname: `/dao/[network]/[token]/proposal/create`, + query: { + network: chain.slug, + token: addresses.token, + stage: 'draft', + }, + }) + }, [ + proposalIdCreated, + transactions.length, + title, + summary, + push, + chain.slug, + addresses.token, + ]) + if (isLoading) return null if (!address) @@ -129,33 +183,47 @@ const ReviewProposalPage: NextPageWithLayout = () => { } return ( - + void onOpenCreateStage('transactions')} + showContinue={false} + showQueue={false} + showReset + hideActionsOnMobile + onReset={() => void onResetProposal()} /> - - - - - Tips on how to write great proposals - - - - - - + + { + if (stage === 'draft' || stage === 'transactions') { + void onOpenCreateStage(stage) + } + }} + isStageClickable={(stage) => stage === 'draft' || stage === 'transactions'} + /> + + void onOpenCreateStage('transactions')} + onResetMobile={() => void onResetProposal()} /> @@ -173,7 +241,7 @@ const ReviewProposalPage: NextPageWithLayout = () => { ) } -ReviewProposalPage.getLayout = getDaoLayout +ReviewProposalPage.getLayout = (page) => getDaoLayout(page, { hideFooterOnMobile: true }) export default ReviewProposalPage diff --git a/packages/create-proposal-ui/src/components/CreateProposalHeading/CreateProposalHeading.tsx b/packages/create-proposal-ui/src/components/CreateProposalHeading/CreateProposalHeading.tsx index cbb34e2aa..8c0df5480 100644 --- a/packages/create-proposal-ui/src/components/CreateProposalHeading/CreateProposalHeading.tsx +++ b/packages/create-proposal-ui/src/components/CreateProposalHeading/CreateProposalHeading.tsx @@ -1,10 +1,12 @@ import { ProposalNavigation } from '@buildeross/proposal-ui' import { useProposalStore } from '@buildeross/stores' import { AnimatedModal } from '@buildeross/ui/Modal' -import { atoms, Button, Flex, Icon, Stack, Text } from '@buildeross/zord' +import { Button, Flex, Icon, Stack, Text } from '@buildeross/zord' import React, { useState } from 'react' +import { ProposalHelpLinks } from '../ProposalHelpLinks' import { Queue } from '../Queue' +import { ResetConfirmationModal } from '../ResetConfirmationModal' interface CreateProposalHeadingProps { title: string @@ -12,7 +14,19 @@ interface CreateProposalHeadingProps { showQueue?: boolean showContinue?: boolean onOpenProposalReview?: () => void + onContinue?: () => void + showStepBack?: boolean + onStepBack?: () => void + backDisabled?: boolean + showReset?: boolean + onReset?: () => void + resetLabel?: string + continueDisabled?: boolean + backLabel?: string + continueLabel?: string showDocsLink?: boolean + showHelpLinks?: boolean + hideActionsOnMobile?: boolean handleBack: () => void queueButtonClassName?: string } @@ -21,19 +35,41 @@ export const CreateProposalHeading: React.FC = ({ title, align = 'left', showDocsLink = false, + showHelpLinks = false, + hideActionsOnMobile = false, showQueue = false, showContinue = true, handleBack, onOpenProposalReview, + onContinue, + showStepBack = false, + onStepBack, + backDisabled = false, + showReset = false, + onReset, + resetLabel = 'Reset proposal', + continueDisabled, + backLabel = 'Back', + continueLabel = 'Continue', queueButtonClassName, }) => { const [queueModalOpen, setQueueModalOpen] = useState(false) + const [resetModalOpen, setResetModalOpen] = useState(false) const transactions = useProposalStore((state) => state.transactions) + const continueHandler = onContinue || onOpenProposalReview return ( - {(showQueue || showContinue) && ( - + {(showQueue || showContinue || showStepBack || showReset) && ( + {showQueue && ( )} - {showContinue && onOpenProposalReview && ( - + )} + {showReset && onReset && ( + + )} + {showContinue && continueHandler && ( + )} @@ -58,6 +118,17 @@ export const CreateProposalHeading: React.FC = ({ setQueueModalOpen(false)} open={queueModalOpen}> + + {showReset && onReset && ( + { + onReset() + setResetModalOpen(false) + }} + onCancel={() => setResetModalOpen(false)} + /> + )} = ({ > {title} - {showDocsLink && ( - - - - How to create a proposal? - - - - - )} + {(showHelpLinks || showDocsLink) && } ) diff --git a/packages/create-proposal-ui/src/components/MobileProposalActionBar/MobileProposalActionBar.css.ts b/packages/create-proposal-ui/src/components/MobileProposalActionBar/MobileProposalActionBar.css.ts new file mode 100644 index 000000000..82a51af52 --- /dev/null +++ b/packages/create-proposal-ui/src/components/MobileProposalActionBar/MobileProposalActionBar.css.ts @@ -0,0 +1,28 @@ +import { atoms, vars } from '@buildeross/zord' +import { style } from '@vanilla-extract/css' + +export const mobileProposalActionBar = style([ + atoms({ backgroundColor: 'background1', p: 'x4' }), + { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + zIndex: 25, + borderTop: '1px solid', + borderColor: vars.color.border, + display: 'flex', + alignItems: 'center', + gap: 12, + paddingBottom: 'calc(16px + env(safe-area-inset-bottom))', + '@media': { + '(min-width: 768px)': { + display: 'none', + }, + }, + }, +]) + +export const mobileActionPrimary = style({ + flex: 1, +}) diff --git a/packages/create-proposal-ui/src/components/MobileProposalActionBar/MobileProposalActionBar.tsx b/packages/create-proposal-ui/src/components/MobileProposalActionBar/MobileProposalActionBar.tsx new file mode 100644 index 000000000..89669e306 --- /dev/null +++ b/packages/create-proposal-ui/src/components/MobileProposalActionBar/MobileProposalActionBar.tsx @@ -0,0 +1,107 @@ +import { useProposalStore } from '@buildeross/stores' +import { AnimatedModal } from '@buildeross/ui/Modal' +import { Button, Icon, Text } from '@buildeross/zord' +import React, { useState } from 'react' + +import { Queue } from '../Queue' +import { ResetConfirmationModal } from '../ResetConfirmationModal' +import { + mobileActionPrimary, + mobileProposalActionBar, +} from './MobileProposalActionBar.css' + +type MobileProposalActionBarProps = { + showBack?: boolean + onBack?: () => void + backDisabled?: boolean + showQueue?: boolean + showReset?: boolean + onReset?: () => void + showContinue?: boolean + onContinue?: () => void + continueDisabled?: boolean + continueLoading?: boolean + continueLabel?: string +} + +export const MobileProposalActionBar: React.FC = ({ + showBack = true, + onBack, + backDisabled = false, + showQueue = false, + showReset = false, + onReset, + showContinue = true, + onContinue, + continueDisabled = false, + continueLoading = false, + continueLabel = 'Continue', +}) => { + const transactions = useProposalStore((state) => state.transactions) + const [queueModalOpen, setQueueModalOpen] = useState(false) + const [resetModalOpen, setResetModalOpen] = useState(false) + + return ( + <> +
+ {showBack && onBack && ( + + )} + + {showQueue && transactions.length > 0 && ( + + )} + + {showReset && onReset && ( + + )} + + {showContinue && onContinue && ( + + )} +
+ + setQueueModalOpen(false)} open={queueModalOpen}> + + + + {showReset && onReset && ( + { + onReset() + setResetModalOpen(false) + }} + onCancel={() => setResetModalOpen(false)} + /> + )} + + ) +} diff --git a/packages/create-proposal-ui/src/components/MobileProposalActionBar/index.ts b/packages/create-proposal-ui/src/components/MobileProposalActionBar/index.ts new file mode 100644 index 000000000..250f1c1ab --- /dev/null +++ b/packages/create-proposal-ui/src/components/MobileProposalActionBar/index.ts @@ -0,0 +1 @@ +export * from './MobileProposalActionBar' diff --git a/packages/create-proposal-ui/src/components/ProposalDraftForm/ProposalDraftForm.tsx b/packages/create-proposal-ui/src/components/ProposalDraftForm/ProposalDraftForm.tsx new file mode 100644 index 000000000..e4b5cea95 --- /dev/null +++ b/packages/create-proposal-ui/src/components/ProposalDraftForm/ProposalDraftForm.tsx @@ -0,0 +1,45 @@ +import { TextInput } from '@buildeross/ui/Fields' +import { MarkdownEditor } from '@buildeross/ui/MarkdownEditor' +import { Stack } from '@buildeross/zord' +import React from 'react' + +type ProposalDraftFormProps = { + title: string + summary: string + onTitleChange: (value: string) => void + onSummaryChange: (value: string) => void + titleError?: string + summaryError?: string + disabled?: boolean +} + +export const ProposalDraftForm: React.FC = ({ + title, + summary, + onTitleChange, + onSummaryChange, + titleError, + summaryError, + disabled, +}) => { + return ( + + onTitleChange(e.target.value)} + errorMessage={titleError} + /> + + + + ) +} diff --git a/packages/create-proposal-ui/src/components/ProposalDraftForm/index.ts b/packages/create-proposal-ui/src/components/ProposalDraftForm/index.ts new file mode 100644 index 000000000..101184905 --- /dev/null +++ b/packages/create-proposal-ui/src/components/ProposalDraftForm/index.ts @@ -0,0 +1 @@ +export * from './ProposalDraftForm' diff --git a/packages/create-proposal-ui/src/components/ProposalHelpLinks/ProposalHelpLinks.tsx b/packages/create-proposal-ui/src/components/ProposalHelpLinks/ProposalHelpLinks.tsx new file mode 100644 index 000000000..2e639a492 --- /dev/null +++ b/packages/create-proposal-ui/src/components/ProposalHelpLinks/ProposalHelpLinks.tsx @@ -0,0 +1,46 @@ +import { atoms, Flex, Icon, Stack, Text } from '@buildeross/zord' +import React from 'react' + +type ProposalHelpLinksProps = { + align?: 'left' | 'center' + howToCreateHref?: string + proposalTipsHref?: string +} + +export const ProposalHelpLinks: React.FC = ({ + align = 'left', + howToCreateHref = 'https://docs.nouns.build/onboarding/builder-proposal/', + proposalTipsHref = '/guidelines', +}) => { + const alignment = align === 'center' ? 'center' : 'flex-start' + + return ( + + + + + How to create a proposal? + + + + + + + + + Tips for writing great proposals + + + + + + ) +} diff --git a/packages/create-proposal-ui/src/components/ProposalHelpLinks/index.ts b/packages/create-proposal-ui/src/components/ProposalHelpLinks/index.ts new file mode 100644 index 000000000..d91956699 --- /dev/null +++ b/packages/create-proposal-ui/src/components/ProposalHelpLinks/index.ts @@ -0,0 +1 @@ +export * from './ProposalHelpLinks' diff --git a/packages/create-proposal-ui/src/components/ProposalStageIndicator/ProposalStageIndicator.tsx b/packages/create-proposal-ui/src/components/ProposalStageIndicator/ProposalStageIndicator.tsx new file mode 100644 index 000000000..1776e205a --- /dev/null +++ b/packages/create-proposal-ui/src/components/ProposalStageIndicator/ProposalStageIndicator.tsx @@ -0,0 +1,157 @@ +import { Box, Button, Flex, Stack, Text } from '@buildeross/zord' +import React from 'react' +import { useAccount } from 'wagmi' + +type ProposalStage = 'draft' | 'transactions' | 'review' + +type ProposalStageIndicatorProps = { + currentStage: ProposalStage + showOnboardingCallout?: boolean + onStageSelect?: (stage: ProposalStage) => void + isStageClickable?: (stage: ProposalStage) => boolean +} + +const STAGES = [ + { + id: 'draft' as const, + title: 'Write proposal', + description: 'Draft the core idea with a clear title and markdown summary.', + }, + { + id: 'transactions' as const, + title: 'Add transactions', + description: 'Define what the DAO will execute if the vote passes.', + }, + { + id: 'review' as const, + title: 'Review and submit', + description: 'Final checks, simulation results, timeline, and on-chain submission.', + }, +] + +const ONBOARDING_VERSION = 'v1' +const NETWORK_TYPE = process.env.NEXT_PUBLIC_NETWORK_TYPE || 'testnet' + +const getOnboardingStorageKey = (address?: string) => { + const account = address?.toLowerCase() || 'disconnected' + return `nouns-builder-proposal-onboarding-${ONBOARDING_VERSION}-${NETWORK_TYPE}-${account}` +} + +export const ProposalStageIndicator: React.FC = ({ + currentStage, + showOnboardingCallout = false, + onStageSelect, + isStageClickable, +}) => { + const { address } = useAccount() + const [showCallout, setShowCallout] = React.useState(false) + const currentStepIndex = STAGES.findIndex((stage) => stage.id === currentStage) + + React.useEffect(() => { + if (!showOnboardingCallout || typeof window === 'undefined') return + + const key = getOnboardingStorageKey(address) + const hasDismissed = window.localStorage.getItem(key) + setShowCallout(!hasDismissed) + }, [address, showOnboardingCallout]) + + const dismissCallout = React.useCallback(() => { + if (typeof window === 'undefined') return + const key = getOnboardingStorageKey(address) + window.localStorage.setItem(key, JSON.stringify({ dismissedAt: Date.now() })) + setShowCallout(false) + }, [address]) + + return ( + + {showCallout && ( + + + First time creating a proposal? + + Start with the written proposal, then add transactions, then do a final + preflight before submitting. + + + + + )} + + + {STAGES.map((stage, index) => { + const isCompleted = index < currentStepIndex + const isActive = index === currentStepIndex + const clickable = + !!onStageSelect && + (isStageClickable ? isStageClickable(stage.id) : isCompleted) + const isLocked = !clickable && !isActive + const onStageActivate = () => { + if (!clickable || isLocked) return + onStageSelect(stage.id) + } + + return ( + ) => { + if (!clickable || isLocked) return + if (event.key === 'Enter' || event.key === ' ') { + if (event.key === ' ') { + event.preventDefault() + } + onStageActivate() + } + }} + > + + + + {index + 1} + + {stage.title} + + + {stage.description} + + ) + })} + + + ) +} diff --git a/packages/create-proposal-ui/src/components/ProposalStageIndicator/index.ts b/packages/create-proposal-ui/src/components/ProposalStageIndicator/index.ts new file mode 100644 index 000000000..ac3aaa67a --- /dev/null +++ b/packages/create-proposal-ui/src/components/ProposalStageIndicator/index.ts @@ -0,0 +1 @@ +export * from './ProposalStageIndicator' diff --git a/packages/create-proposal-ui/src/components/Queue/Queue.tsx b/packages/create-proposal-ui/src/components/Queue/Queue.tsx index 06466cce9..b3fc3c20e 100644 --- a/packages/create-proposal-ui/src/components/Queue/Queue.tsx +++ b/packages/create-proposal-ui/src/components/Queue/Queue.tsx @@ -55,7 +55,7 @@ export const Queue: React.FC = ({ setQueueModalOpen, embedded = fals } return ( - + void + onCancel: () => void + title?: string + description?: string + confirmLabel?: string + cancelLabel?: string +} + +export const ResetConfirmationModal: React.FC = ({ + open, + onConfirm, + onCancel, + title = 'Reset proposal?', + description = 'This will clear your title, summary, and queued transactions.', + confirmLabel = 'Reset', + cancelLabel = 'Cancel', +}) => { + return ( + + + + {title} + + + {description} + + + + + + + + ) +} diff --git a/packages/create-proposal-ui/src/components/ResetConfirmationModal/index.ts b/packages/create-proposal-ui/src/components/ResetConfirmationModal/index.ts new file mode 100644 index 000000000..3d484b8cd --- /dev/null +++ b/packages/create-proposal-ui/src/components/ResetConfirmationModal/index.ts @@ -0,0 +1 @@ +export * from './ResetConfirmationModal' diff --git a/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx b/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx index e2e9b5e7c..661373fba 100644 --- a/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx +++ b/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx @@ -1,5 +1,7 @@ import { useVotes } from '@buildeross/hooks/useVotes' -import { governorAbi } from '@buildeross/sdk/contract' +import { ProposalDescription } from '@buildeross/proposal-ui' +import { governorAbi, treasuryAbi } from '@buildeross/sdk/contract' +import { type Proposal } from '@buildeross/sdk/subgraph' import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' import { BuilderTransaction, @@ -9,14 +11,15 @@ import { } from '@buildeross/stores' import { type SimulationOutput } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' -import { TextInput } from '@buildeross/ui/Fields' -import { MarkdownEditor } from '@buildeross/ui/MarkdownEditor' import { AnimatedModal, SuccessModalContent } from '@buildeross/ui/Modal' -import { Box, Flex, Icon } from '@buildeross/zord' -import { Field, FieldProps, Formik } from 'formik' +import { defaultInputLabelStyle } from '@buildeross/ui/styles' +import { handleGMTOffset, unpackOptionalArray } from '@buildeross/utils/helpers' +import { Box, Button, Flex, Icon, Stack, Text } from '@buildeross/zord' +import dayjs from 'dayjs' +import { Formik } from 'formik' import React, { useState } from 'react' import { decodeEventLog, type Hex } from 'viem' -import { useAccount, useConfig } from 'wagmi' +import { useAccount, useConfig, useReadContracts } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { prepareProposalTransactions } from '../../utils/prepareTransactions' @@ -24,6 +27,8 @@ import { isSimulationSupported, simulateTransactions, } from '../../utils/tenderlySimulation' +import { MobileProposalActionBar } from '../MobileProposalActionBar' +import { ProposalDraftForm } from '../ProposalDraftForm' import { ERROR_CODE, FormValues, validationSchema } from './fields' import { checkboxHelperText, checkboxStyleVariants } from './ReviewProposalForm.css' import { Transactions } from './Transactions' @@ -34,6 +39,8 @@ interface ReviewProposalProps { summary?: string transactions: BuilderTransaction[] onProposalCreated: (proposalId: string | null) => void + onBackMobile?: () => void + onResetMobile?: () => void } const SKIP_SIMULATION = process.env.NEXT_PUBLIC_DISABLE_TENDERLY_SIMULATION === 'true' @@ -49,18 +56,25 @@ const logError = async (e: unknown) => { } catch (_) {} } +const formatTimestamp = (timestamp?: number) => { + if (timestamp === undefined || timestamp === null) return 'Unavailable' + return `${dayjs.unix(timestamp).format('MMM D, YYYY h:mm A')} ${handleGMTOffset()}` +} + export const ReviewProposalForm = ({ disabled: disabledForm, title, summary, transactions, onProposalCreated, + onBackMobile, + onResetMobile, }: ReviewProposalProps) => { const addresses = useDaoStore((state) => state.addresses) const chain = useChainStore((x) => x.chain) const config = useConfig() const { address } = useAccount() - const { clearProposal } = useProposalStore() + const { clearProposal, setTitle, setSummary } = useProposalStore() const [error, setError] = useState() const [simulationError, setSimulationError] = useState() @@ -68,6 +82,7 @@ export const ReviewProposalForm = ({ const [failedSimulations, setFailedSimulations] = useState>([]) const [proposing, setProposing] = useState(false) const [skipSimulation, setSkipSimulation] = useState(SKIP_SIMULATION) + const [isEditingMetadata, setIsEditingMetadata] = useState(false) const { votes, hasThreshold, proposalVotesRequired, isLoading } = useVotes({ chainId: chain.id, @@ -76,6 +91,38 @@ export const ReviewProposalForm = ({ signerAddress: address, }) + const { data: governanceConfigData } = useReadContracts({ + allowFailure: false, + query: { + enabled: !!addresses.governor && !!addresses.treasury, + }, + contracts: [ + { + abi: governorAbi, + address: addresses.governor, + chainId: chain.id, + functionName: 'votingDelay', + }, + { + abi: governorAbi, + address: addresses.governor, + chainId: chain.id, + functionName: 'votingPeriod', + }, + { + abi: treasuryAbi, + address: addresses.treasury, + chainId: chain.id, + functionName: 'delay', + }, + ] as const, + }) + + const [votingDelay, votingPeriod, timelockDelay] = unpackOptionalArray( + governanceConfigData, + 3 + ) + const onSubmit = React.useCallback( async (values: FormValues) => { if (!addresses.governor || !addresses.treasury) { @@ -222,13 +269,29 @@ export const ReviewProposalForm = ({ if (isLoading) return null const tokensNeeded = Number(proposalVotesRequired ?? 0n) + const nowTimestamp = Math.floor(Date.now() / 1000) + const hasVotingDelay = votingDelay !== undefined && votingDelay !== null + const hasVotingPeriod = votingPeriod !== undefined && votingPeriod !== null + const hasTimelockDelay = timelockDelay !== undefined && timelockDelay !== null + + const estimatedVotingStartsAt = hasVotingDelay + ? nowTimestamp + Number(votingDelay) + : undefined + const estimatedVotingEndsAt = + hasVotingDelay && hasVotingPeriod + ? nowTimestamp + Number(votingDelay) + Number(votingPeriod) + : undefined + const estimatedEarliestExecutionAt = + estimatedVotingEndsAt !== undefined && hasTimelockDelay + ? estimatedVotingEndsAt + Number(timelockDelay) + : undefined return ( {(formik) => (
+ + + + + + {isEditingMetadata ? ( + { + formik.setFieldValue('title', value) + setTitle(value) + }} + onSummaryChange={(value) => { + formik.setFieldValue('summary', value) + setSummary(value) + }} + disabled={disabledForm} + titleError={formik.errors['title']} + summaryError={formik.errors['summary']} + /> + ) : ( + (() => { + const { targets, calldata, values } = prepareProposalTransactions( + formik.values.transactions + ) + + const previewProposal = { + proposer: (address || + '0x0000000000000000000000000000000000000000') as `0x${string}`, + description: formik.values.summary || '', + title: formik.values.title || '', + targets, + calldatas: calldata, + values: values.map((value) => value.toString()), + } as unknown as Proposal + + return ( + undefined} + isPreview + /> + ) + })() + )} + + - - {() => ( - - )} - - - - {({ field }: FieldProps) => ( - formik?.setFieldValue(field.name, value)} - disabled={disabledForm} - inputLabel={'Summary'} - errorMessage={formik.errors['summary']} - /> - )} - + + + + Submitted: + {formatTimestamp(nowTimestamp)} + + + Voting starts: + {formatTimestamp(estimatedVotingStartsAt)} + + + Voting ends: + {formatTimestamp(estimatedVotingEndsAt)} + + + Earliest execution: + {formatTimestamp(estimatedEarliestExecutionAt)} + + {(!!simulationError || failedSimulations.length > 0) && ( @@ -295,6 +433,7 @@ export const ReviewProposalForm = ({ disabled={simulating || proposing} handleClick={formik.handleSubmit} h={'x15'} + display={{ '@initial': 'none', '@768': 'flex' }} > {'Submit Proposal'} {!!votes && ( @@ -312,6 +451,21 @@ export const ReviewProposalForm = ({ )} + + { + void formik.handleSubmit() + }} + continueDisabled={simulating || proposing} + continueLoading={simulating} + continueLabel={'Submit Proposal'} + /> )}
diff --git a/packages/create-proposal-ui/src/components/SelectTransactionType/AdminNav.tsx b/packages/create-proposal-ui/src/components/SelectTransactionType/AdminNav.tsx deleted file mode 100644 index c16464c42..000000000 --- a/packages/create-proposal-ui/src/components/SelectTransactionType/AdminNav.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Flex, Icon, Stack, Text } from '@buildeross/zord' -import React from 'react' - -const AdminNav: React.FC<{ onClick: () => void }> = ({ onClick }) => { - return ( - - - - Configure DAO Settings - - - Change all the main DAO settings in the Admin Tab - - - - - ) -} - -export default AdminNav diff --git a/packages/create-proposal-ui/src/components/SelectTransactionType/SelectTransactionType.tsx b/packages/create-proposal-ui/src/components/SelectTransactionType/SelectTransactionType.tsx deleted file mode 100644 index 2c7bea168..000000000 --- a/packages/create-proposal-ui/src/components/SelectTransactionType/SelectTransactionType.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Flex, Stack, Text } from '@buildeross/zord' -import React from 'react' - -import { TransactionFormType } from '../TransactionForm' -import AdminNav from './AdminNav' -import TransactionTypeCard from './TransactionTypeCard' - -interface SelectTransactionTypeProps { - transactionTypes: TransactionFormType[] - onSelect: (value: TransactionFormType) => void - onOpenAdminSettings?: () => void -} - -export const SelectTransactionType: React.FC = ({ - transactionTypes, - onSelect, - onOpenAdminSettings, -}) => { - return ( - - - Select Transaction Type - - - {transactionTypes.map((transactionType) => ( - onSelect(transactionType)} - /> - ))} - - {onOpenAdminSettings && } - - ) -} diff --git a/packages/create-proposal-ui/src/components/SelectTransactionType/TransactionTypeCard.tsx b/packages/create-proposal-ui/src/components/SelectTransactionType/TransactionTypeCard.tsx deleted file mode 100644 index ccc2bac45..000000000 --- a/packages/create-proposal-ui/src/components/SelectTransactionType/TransactionTypeCard.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { TRANSACTION_TYPES, TransactionType } from '@buildeross/proposal-ui' -import { Flex, Stack, Text } from '@buildeross/zord' -import React from 'react' - -import { TransactionTypeIcon } from '../TransactionCard' - -interface TransactionTypeCardProps { - transactionType: TransactionType - onClick: () => void -} - -const TransactionTypeCard: React.FC = ({ - transactionType, - onClick, -}) => { - return ( - - - - - {TRANSACTION_TYPES[transactionType].title} - - {TRANSACTION_TYPES[transactionType].subTitle} - - - ) -} - -export default TransactionTypeCard diff --git a/packages/create-proposal-ui/src/components/SelectTransactionType/index.ts b/packages/create-proposal-ui/src/components/SelectTransactionType/index.ts deleted file mode 100644 index acd26966f..000000000 --- a/packages/create-proposal-ui/src/components/SelectTransactionType/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './SelectTransactionType' diff --git a/packages/create-proposal-ui/src/components/TransactionForm/Droposal/DroposalForm.tsx b/packages/create-proposal-ui/src/components/TransactionForm/Droposal/DroposalForm.tsx index 5ea3cb8f8..bc22d7799 100644 --- a/packages/create-proposal-ui/src/components/TransactionForm/Droposal/DroposalForm.tsx +++ b/packages/create-proposal-ui/src/components/TransactionForm/Droposal/DroposalForm.tsx @@ -84,22 +84,15 @@ export const DroposalForm: React.FC = ({ onSubmit, disabled } return ( <> - - - - + + This droposal uses the ZORA 721 Contract.{' '} - Lean more + Learn more = ({ formik.values return ( - + = ({ mediaMimeType={mediaType} type="drop" /> - + EDITION PRICE diff --git a/packages/create-proposal-ui/src/components/index.ts b/packages/create-proposal-ui/src/components/index.ts index a9cc3ef6e..74d0ef526 100644 --- a/packages/create-proposal-ui/src/components/index.ts +++ b/packages/create-proposal-ui/src/components/index.ts @@ -1,7 +1,11 @@ export * from './CreateProposalHeading' +export * from './MobileProposalActionBar' +export * from './ProposalDraftForm' +export * from './ProposalHelpLinks' +export * from './ProposalStageIndicator' export * from './Queue' +export * from './ResetConfirmationModal' export * from './ReviewProposalForm' -export * from './SelectTransactionType' export * from './TransactionCard' export * from './TransactionForm' export * from './TwoColumnLayout' diff --git a/packages/dao-ui/src/components/Activity/Activity.tsx b/packages/dao-ui/src/components/Activity/Activity.tsx index ea5066099..fab8a5a9e 100644 --- a/packages/dao-ui/src/components/Activity/Activity.tsx +++ b/packages/dao-ui/src/components/Activity/Activity.tsx @@ -39,7 +39,7 @@ export const Activity: React.FC = ({ const addresses = useDaoStore((state) => state.addresses) const chain = useChainStore((x) => x.chain) - const { createProposal } = useProposalStore() + const { startProposalDraft } = useProposalStore() const { address } = useAccount() const { query } = useQueryParams() const page: number = query.page ? Number(query.page) : 1 @@ -81,12 +81,7 @@ export const Activity: React.FC = ({ }) const handleProposalCreation = () => { - createProposal({ - title: undefined, - summary: undefined, - disabled: false, - transactions: [], - }) + startProposalDraft() onOpenProposalCreate() } diff --git a/packages/dao-ui/src/components/AdminForm/AdminForm.tsx b/packages/dao-ui/src/components/AdminForm/AdminForm.tsx index 8ba2f9b73..5119813fa 100644 --- a/packages/dao-ui/src/components/AdminForm/AdminForm.tsx +++ b/packages/dao-ui/src/components/AdminForm/AdminForm.tsx @@ -59,7 +59,7 @@ const vetoerAnimation = { } export const AdminForm: React.FC = ({ onOpenProposalReview }) => { - const createProposal = useProposalStore((state) => state.createProposal) + const startProposalDraft = useProposalStore((state) => state.startProposalDraft) const addresses = useDaoStore((state) => state.addresses) const chain = useChainStore((x) => x.chain) @@ -289,7 +289,7 @@ export const AdminForm: React.FC = ({ onOpenProposalReview }) => addresses.auction as Address ) - createProposal({ + startProposalDraft({ disabled: false, title: undefined, summary: undefined, diff --git a/packages/dao-ui/src/components/FixRendererBase/FixRendererBase.tsx b/packages/dao-ui/src/components/FixRendererBase/FixRendererBase.tsx index bfa5d0b92..41105b3e5 100644 --- a/packages/dao-ui/src/components/FixRendererBase/FixRendererBase.tsx +++ b/packages/dao-ui/src/components/FixRendererBase/FixRendererBase.tsx @@ -21,7 +21,7 @@ export const FixRendererBase = ({ addresses: DaoContractAddresses onOpenProposalReview: () => void }) => { - const createProposal = useProposalStore((state) => state.createProposal) + const startProposalDraft = useProposalStore((state) => state.startProposalDraft) const chain = useChainStore((x) => x.chain) const { description, transaction, shouldFix } = useRendererBaseFix({ @@ -32,7 +32,7 @@ export const FixRendererBase = ({ if (!shouldFix) return null const handleUpgrade = (): void => { - createProposal({ + startProposalDraft({ transactions: [transaction!], disabled: true, title: `Fix Metadata Renderer Base`, diff --git a/packages/dao-ui/src/components/Gallery/Gallery.tsx b/packages/dao-ui/src/components/Gallery/Gallery.tsx index 6578f171d..2a5b515ca 100644 --- a/packages/dao-ui/src/components/Gallery/Gallery.tsx +++ b/packages/dao-ui/src/components/Gallery/Gallery.tsx @@ -5,7 +5,7 @@ import { type GalleryItem, useGalleryItems } from '@buildeross/hooks/useGalleryI import { useNowSeconds } from '@buildeross/hooks/useNowSeconds' import { useVotes } from '@buildeross/hooks/useVotes' import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' -import { CHAIN_ID, TransactionType } from '@buildeross/types' +import { CHAIN_ID, ProposalCreateStage, TransactionType } from '@buildeross/types' import { DropdownSelect, type SelectOption } from '@buildeross/ui/DropdownSelect' import { DropMintWidget } from '@buildeross/ui/DropMintWidget' import { useLinks } from '@buildeross/ui/LinksProvider' @@ -134,7 +134,7 @@ const MintWidgetModal: React.FC = ({ } export type GalleryProps = { - onOpenProposalCreate: () => void + onOpenProposalCreate: (stage?: ProposalCreateStage) => void onOpenCoinCreate: () => void } @@ -149,7 +149,7 @@ export const Gallery: React.FC = ({ const { address } = useAccount() const { getCoinCreateLink } = useLinks() - const { createProposal, setTransactionType } = useProposalStore() + const { startProposalDraft } = useProposalStore() // State for dropdown selection type CreateOption = 'permissionless-post' | 'dao-post' | 'dao-drop' | 'dao-creator-coin' @@ -254,34 +254,22 @@ export const Gallery: React.FC = ({ onOpenCoinCreate() break case 'dao-post': - setTransactionType(TransactionType.CONTENT_COIN) - createProposal({ - title: undefined, - summary: undefined, - disabled: false, - transactions: [], + startProposalDraft({ + transactionType: TransactionType.CONTENT_COIN, }) - onOpenProposalCreate() + onOpenProposalCreate('transactions') break case 'dao-drop': - setTransactionType(TransactionType.DROPOSAL) - createProposal({ - title: undefined, - summary: undefined, - disabled: false, - transactions: [], + startProposalDraft({ + transactionType: TransactionType.DROPOSAL, }) - onOpenProposalCreate() + onOpenProposalCreate('transactions') break case 'dao-creator-coin': - setTransactionType(TransactionType.CREATOR_COIN) - createProposal({ - title: undefined, - summary: undefined, - disabled: false, - transactions: [], + startProposalDraft({ + transactionType: TransactionType.CREATOR_COIN, }) - onOpenProposalCreate() + onOpenProposalCreate('transactions') break } } diff --git a/packages/dao-ui/src/components/Upgrade/Upgrade.tsx b/packages/dao-ui/src/components/Upgrade/Upgrade.tsx index 800616c8d..e76b9fd4e 100644 --- a/packages/dao-ui/src/components/Upgrade/Upgrade.tsx +++ b/packages/dao-ui/src/components/Upgrade/Upgrade.tsx @@ -26,7 +26,7 @@ export const Upgrade = ({ addresses: DaoContractAddresses onOpenProposalReview: () => void }) => { - const createProposal = useProposalStore((state) => state.createProposal) + const startProposalDraft = useProposalStore((state) => state.startProposalDraft) const chain = useChainStore((x) => x.chain) const { @@ -49,7 +49,7 @@ export const Upgrade = ({ ) const handleUpgrade = (): void => { - createProposal({ + startProposalDraft({ transactions: [upgradeTransaction!], disabled: true, title: `Nouns Builder Upgrade v${latest} ${dayjs().format('YYYY-MM-DD')}`, diff --git a/packages/hooks/src/useDecodedTransactions.ts b/packages/hooks/src/useDecodedTransactions.ts index d40efef12..aaccc0b58 100644 --- a/packages/hooks/src/useDecodedTransactions.ts +++ b/packages/hooks/src/useDecodedTransactions.ts @@ -128,7 +128,8 @@ export const decodeTransactions = async ( export const useDecodedTransactions = ( chainId: CHAIN_ID, - proposal: Proposal + proposal: Proposal, + enabled = true ): { decodedTransactions: DecodedTransaction[] | undefined isValidating: boolean @@ -145,7 +146,7 @@ export const useDecodedTransactions = ( error, mutate, } = useSWR( - targets && calldatas && values + targets && calldatas && values && enabled ? ([SWR_KEYS.PROPOSALS_TRANSACTIONS, chainId, targets, calldatas, values] as const) : null, async ([, _chainId, _targets, _calldatas, _values]) => diff --git a/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx b/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx index de544b182..f6e253d66 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx @@ -59,7 +59,7 @@ export const EscrowInstance = ({ }: EscrowInstanceProps) => { const { chain } = useChainStore() const { addresses } = useDaoStore() - const { addTransaction } = useProposalStore() + const { startProposalDraft } = useProposalStore() const { address } = useAccount() const config = useConfig() const { getProposalLink } = useLinks() @@ -139,12 +139,15 @@ export const EscrowInstance = ({ transactions: [releaseMilestone], } - addTransaction(releaseEscrowTxnData) + startProposalDraft({ + transactions: [releaseEscrowTxnData], + disabled: false, + }) onOpenProposalReview() }, [ onOpenProposalReview, - addTransaction, + startProposalDraft, invoiceData?.title, invoiceAddress, currentMilestone, diff --git a/packages/proposal-ui/src/components/ProposalDescription/ProposalDescription.tsx b/packages/proposal-ui/src/components/ProposalDescription/ProposalDescription.tsx index 7544425a6..5beb16fc7 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/ProposalDescription.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/ProposalDescription.tsx @@ -18,7 +18,7 @@ import { DecodedTransactions } from '@buildeross/ui/DecodedTransactions' import { MarkdownDisplay } from '@buildeross/ui/MarkdownDisplay' import { getEscrowBundler, getEscrowBundlerLegacy } from '@buildeross/utils/escrow' import { getSablierContracts } from '@buildeross/utils/sablier/contracts' -import { atoms, Box, Flex, Paragraph } from '@buildeross/zord' +import { atoms, Box, Flex, Paragraph, Text } from '@buildeross/zord' import { toLower } from 'lodash' import React, { useMemo } from 'react' import useSWR from 'swr' @@ -33,21 +33,30 @@ import { Section } from './Section' import { StreamDetails } from './StreamDetails' type ProposalDescriptionProps = { + title?: string proposal: Proposal collection: string onOpenProposalReview: () => Promise + isPreview?: boolean } export const ProposalDescription: React.FC = ({ + title, proposal, collection, onOpenProposalReview, + isPreview = false, }) => { const { displayName } = useEnsData(proposal.proposer) const { chain } = useChainStore() const { addresses } = useDaoStore() - const { decodedTransactions } = useDecodedTransactions(chain.id, proposal) + const enableDecodedTransactions = !isPreview + const { decodedTransactions } = useDecodedTransactions( + chain.id, + proposal, + enableDecodedTransactions + ) // Check if proposal has escrow milestone transactions const hasEscrowMilestone = useMemo(() => { @@ -125,8 +134,24 @@ export const ProposalDescription: React.FC = ({ ) return ( - - + + + {title && ( +
+ + {title} + +
+ )} + {hasEscrowMilestone && ( = ({ -
+
= ({
-
- -
+ {!isPreview && ( +
+ +
+ )} ) diff --git a/packages/proposal-ui/src/components/ProposalDescription/Section.tsx b/packages/proposal-ui/src/components/ProposalDescription/Section.tsx index ce5b35a11..0d35fa49f 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/Section.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/Section.tsx @@ -1,8 +1,14 @@ -import { Box } from '@buildeross/zord' +import { Box, type BoxProps } from '@buildeross/zord' import { ReactNode } from 'react' -export const Section = ({ children, title }: { children: ReactNode; title: string }) => ( - +type SectionProps = { + children: ReactNode + title: string + mb?: BoxProps['mb'] +} + +export const Section = ({ children, title, mb }: SectionProps) => ( + {title} diff --git a/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx b/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx index b50f98455..9b8f0284e 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx @@ -75,7 +75,7 @@ export const StreamItem = ({ }: StreamItemProps) => { const { chain } = useChainStore() const { addresses } = useDaoStore() - const { addTransaction } = useProposalStore() + const { startProposalDraft } = useProposalStore() const { address } = useAccount() const config = useConfig() const { getProposalLink } = useLinks() @@ -211,9 +211,16 @@ export const StreamItem = ({ transactions: [cancelTransaction], } - addTransaction(cancelTxnData) - onOpenProposalReview() - }, [onOpenProposalReview, addTransaction, lockupAddress, liveData]) + startProposalDraft({ + transactions: [cancelTxnData], + disabled: false, + }) + try { + await onOpenProposalReview() + } catch (error) { + console.error('Failed to open proposal review:', error) + } + }, [onOpenProposalReview, startProposalDraft, lockupAddress, liveData]) const recipientDisplay = recipientName || walletSnippet(stream.recipient) diff --git a/packages/stores/src/hooks/useProposalStore.ts b/packages/stores/src/hooks/useProposalStore.ts index eeba8822d..ae526aafa 100644 --- a/packages/stores/src/hooks/useProposalStore.ts +++ b/packages/stores/src/hooks/useProposalStore.ts @@ -21,13 +21,15 @@ type Actions = { addTransactions: (builderTransactions: BuilderTransaction[]) => void removeTransaction: (index: number) => void removeAllTransactions: () => void - createProposal: ({ - title, - summary, - disabled, - transactions, - }: Pick) => void clearProposal: () => void + setTitle: (title?: string) => void + setSummary: (summary?: string) => void + setDraftMetadata: ({ title, summary }: Pick) => void + startProposalDraft: ( + draft?: Partial< + Pick + > + ) => void setTransactionType: (type: TransactionFormType | null) => void resetTransactionType: () => void } @@ -62,9 +64,20 @@ export const useProposalStore = create()( removeAllTransactions: () => { set(() => ({ transactions: [] })) }, - createProposal: ({ title, summary, disabled, transactions }) => - set({ title, summary, disabled, transactions }), clearProposal: () => set(() => ({ ...initialState })), + setTitle: (title) => set({ title }), + setSummary: (summary) => set({ summary }), + setDraftMetadata: ({ title, summary }) => set({ title, summary }), + startProposalDraft: (draft = {}) => { + const sanitizedDraft = Object.fromEntries( + Object.entries(draft).filter(([, value]) => value !== undefined) + ) as Partial + + set(() => ({ + ...initialState, + ...sanitizedDraft, + })) + }, setTransactionType: (type) => set({ transactionType: type }), resetTransactionType: () => set({ transactionType: null }), }), diff --git a/packages/types/src/links.ts b/packages/types/src/links.ts index bf4a99c8f..866eddf9c 100644 --- a/packages/types/src/links.ts +++ b/packages/types/src/links.ts @@ -2,11 +2,24 @@ import { CHAIN_ID } from './chain' import { AddressType } from './hex' export type LinkOptions = { href: string } +export type ProposalCreateStage = 'draft' | 'transactions' +export type DaoTab = + | 'about' + | 'activity' + | 'admin' + | 'gallery' + | 'contracts' + | 'custom-minter' + | 'erc721-redeem' + | 'feed' + | 'merkle-reserve' + | 'treasury' +export type ProposalTab = 'details' | 'votes' | 'propdates' export type DaoLinkHandler = ( chainId: CHAIN_ID, daoTokenAddress: AddressType, - tab?: string + tab?: DaoTab ) => LinkOptions export type AuctionLinkHandler = ( @@ -19,12 +32,13 @@ export type ProposalLinkHandler = ( chainId: CHAIN_ID, daoTokenAddress: AddressType, proposalId: number | string | bigint, - tab?: string + tab?: ProposalTab ) => LinkOptions export type ProposalCreateLinkHandler = ( chainId: CHAIN_ID, - daoTokenAddress: AddressType + daoTokenAddress: AddressType, + stage?: ProposalCreateStage ) => LinkOptions export type ProfileLinkHandler = (address: AddressType) => LinkOptions diff --git a/packages/ui/src/DropdownSelect/DropdownSelect.tsx b/packages/ui/src/DropdownSelect/DropdownSelect.tsx index 98376be92..69eeda98a 100644 --- a/packages/ui/src/DropdownSelect/DropdownSelect.tsx +++ b/packages/ui/src/DropdownSelect/DropdownSelect.tsx @@ -1,4 +1,13 @@ -import { Box, Button, ButtonProps, Flex, Icon, Spinner } from '@buildeross/zord' +import { + Box, + Button, + ButtonProps, + Flex, + Icon, + Spinner, + Stack, + Text, +} from '@buildeross/zord' import { AnimatePresence, motion } from 'framer-motion' import React, { ReactElement, ReactNode, useEffect, useRef, useState } from 'react' @@ -48,11 +57,12 @@ const absoluteVariants = { export interface SelectOption { value: T label: string + description?: string icon?: ReactNode } interface DropdownSelectProps { - value: T + value?: T options: SelectOption[] inputLabel?: string | ReactElement onChange: (value: T) => void @@ -89,7 +99,7 @@ export function DropdownSelect({ } const selectedOption = options.find((option) => option.value === value) - const displayLabel = customLabel || selectedOption?.label + const displayLabel = customLabel ?? selectedOption?.label ?? 'Select option' // Click outside handler for absolute positioning useEffect(() => { @@ -118,15 +128,33 @@ export function DropdownSelect({ onClick={() => handleOptionSelect(option)} className={optionClassName} pl={'x4'} + pr={option.description ? 'x4' : undefined} direction={'row'} align={'center'} - height={'x18'} + py={option.description ? 'x3' : undefined} + height={option.description ? undefined : 'x18'} + minHeight={'x18'} width={'100%'} - fontSize={16} - fontWeight={'display'} + gap={option.description ? 'x3' : undefined} + fontSize={option.description ? undefined : 16} + fontWeight={option.description ? undefined : 'display'} > - {option.icon && {option.icon}} - {option.label} + {option.icon && ( + {option.icon} + )} + + {option.description ? ( + + + {option.label} + + + {option.description} + + + ) : ( + option.label + )} )) diff --git a/packages/ui/src/LinksProvider/LinksProvider.tsx b/packages/ui/src/LinksProvider/LinksProvider.tsx index 7e9f11214..4f91eeab4 100644 --- a/packages/ui/src/LinksProvider/LinksProvider.tsx +++ b/packages/ui/src/LinksProvider/LinksProvider.tsx @@ -6,10 +6,13 @@ import { CoinCreateLinkHandler, CoinLinkHandler, DaoLinkHandler, + DaoTab, DropLinkHandler, ProfileLinkHandler, ProposalCreateLinkHandler, + ProposalCreateStage, ProposalLinkHandler, + ProposalTab, } from '@buildeross/types' import { chainIdToSlug } from '@buildeross/utils/chains' import { createContext, useContext } from 'react' @@ -42,7 +45,7 @@ const defaultGetAuctionLink = ( const defaultGetDaoLink = ( chainId: CHAIN_ID, tokenAddress: AddressType, - tab?: string + tab?: DaoTab ) => { const baseHref = `${BASE_URL}/dao/${chainIdToSlug(chainId)}/${tokenAddress}` return { @@ -54,7 +57,7 @@ const defaultGetProposalLink = ( chainId: CHAIN_ID, tokenAddress: AddressType, proposalId: number | string | bigint, - tab?: string + tab?: ProposalTab ) => { const baseHref = `${BASE_URL}/dao/${chainIdToSlug(chainId)}/${tokenAddress}/vote/${proposalId}` return { @@ -80,9 +83,14 @@ const defaultGetCoinCreateLink = (chainId: CHAIN_ID, tokenAddress: AddressType) } } -const defaultGetProposalCreateLink = (chainId: CHAIN_ID, tokenAddress: AddressType) => { +const defaultGetProposalCreateLink = ( + chainId: CHAIN_ID, + tokenAddress: AddressType, + stage?: ProposalCreateStage +) => { + const baseHref = `${BASE_URL}/dao/${chainIdToSlug(chainId)}/${tokenAddress}/proposal/create` return { - href: `${BASE_URL}/dao/${chainIdToSlug(chainId)}/${tokenAddress}/proposal/create`, + href: stage ? `${baseHref}?stage=${stage}` : baseHref, } } diff --git a/packages/ui/src/MarkdownEditor/MarkdownEditor.tsx b/packages/ui/src/MarkdownEditor/MarkdownEditor.tsx index 20f9f8ba5..b036e4df9 100644 --- a/packages/ui/src/MarkdownEditor/MarkdownEditor.tsx +++ b/packages/ui/src/MarkdownEditor/MarkdownEditor.tsx @@ -95,7 +95,28 @@ export const MarkdownEditor: React.FC = ({ {!ReactMdeComp ? ( - <>{fallback ?? {value}} + fallback ? ( + <>{fallback} + ) : disabled ? ( + {value} + ) : ( +