diff --git a/apps/subgraph/schema.graphql b/apps/subgraph/schema.graphql index 5f9af4f26..7c36147a6 100644 --- a/apps/subgraph/schema.graphql +++ b/apps/subgraph/schema.graphql @@ -380,7 +380,6 @@ type ClankerToken @entity { tokenAddress: Bytes! msgSender: Bytes! tokenAdmin: Bytes! # The admin address (may or may not be a DAO) - # Token metadata tokenImage: String! tokenName: String! @@ -565,6 +564,7 @@ type ZoraDropMintComment @entity { # Swap routing entities enum CoinType { + UNKNOWN WETH CLANKER_TOKEN ZORA_COIN @@ -622,6 +622,8 @@ type PaymentOption @entity { # Payment token details tokenAddress: Bytes! tokenType: CoinType! + tokenName: String! + tokenSymbol: String! # Hops from this payment token to the target coin (when buying) # Or from target coin to this payment token (when selling) diff --git a/apps/subgraph/src/utils/coinInfo.ts b/apps/subgraph/src/utils/coinInfo.ts index c58d92c72..8a8f398ba 100644 --- a/apps/subgraph/src/utils/coinInfo.ts +++ b/apps/subgraph/src/utils/coinInfo.ts @@ -7,6 +7,7 @@ import { CLANKER_TICK_SPACING, DYNAMIC_FEE_FLAG, WETH_ADDRESS } from './constant * Coin type constants for routing */ export namespace CoinType { + export const UNKNOWN: string = 'UNKNOWN' export const WETH: string = 'WETH' export const CLANKER_TOKEN: string = 'CLANKER_TOKEN' export const ZORA_COIN: string = 'ZORA_COIN' @@ -18,6 +19,8 @@ export namespace CoinType { export class CoinInfo { address: Bytes type: string + name: string + symbol: string pairedToken: Bytes | null poolId: Bytes | null // Uniswap V4 pool identifier (from either poolId or poolKeyHash) fee: BigInt | null @@ -27,6 +30,8 @@ export class CoinInfo { constructor( address: Bytes, type: string, + name: string, + symbol: string, pairedToken: Bytes | null, poolId: Bytes | null, fee: BigInt | null, @@ -35,6 +40,8 @@ export class CoinInfo { ) { this.address = address this.type = type + this.name = name + this.symbol = symbol this.pairedToken = pairedToken this.poolId = poolId this.fee = fee @@ -55,6 +62,8 @@ export function loadCoinInfo(tokenAddress: Bytes): CoinInfo | null { return new CoinInfo( tokenAddress, CoinType.WETH, + 'Wrapped Ether', + 'WETH', null, // WETH has no pairedToken null, // WETH has no pool null, @@ -69,6 +78,8 @@ export function loadCoinInfo(tokenAddress: Bytes): CoinInfo | null { return new CoinInfo( zoraCoin.coinAddress, CoinType.ZORA_COIN, + zoraCoin.name, + zoraCoin.symbol, zoraCoin.currency, zoraCoin.poolKeyHash, // poolKeyHash is the Uniswap V4 pool identifier zoraCoin.poolFee, @@ -83,6 +94,8 @@ export function loadCoinInfo(tokenAddress: Bytes): CoinInfo | null { return new CoinInfo( clankerToken.tokenAddress, CoinType.CLANKER_TOKEN, + clankerToken.tokenName, + clankerToken.tokenSymbol, clankerToken.pairedToken, clankerToken.poolId, // poolId is the Uniswap V4 pool identifier DYNAMIC_FEE_FLAG, diff --git a/apps/subgraph/src/utils/swapPath.ts b/apps/subgraph/src/utils/swapPath.ts index 71f59b943..e8907ea51 100644 --- a/apps/subgraph/src/utils/swapPath.ts +++ b/apps/subgraph/src/utils/swapPath.ts @@ -240,11 +240,18 @@ export function buildSwapRoute(coinAddress: Bytes, timestamp: BigInt): SwapRoute continue } - // Determine token type - let tokenType = CoinType.WETH + let tokenType = CoinType.UNKNOWN + let tokenName = 'Unknown Token' + let tokenSymbol = 'UNKNOWN' const info = loadCoinInfo(tokenBytes) - if (info) { + if (!info) { + log.warning('Payment option has missing coin info, using UNKNOWN metadata: {}', [ + tokenAddr, + ]) + } else { tokenType = info.type + tokenName = info.name + tokenSymbol = info.symbol } // Create payment option @@ -253,6 +260,8 @@ export function buildSwapRoute(coinAddress: Bytes, timestamp: BigInt): SwapRoute option.route = routeId option.tokenAddress = tokenBytes option.tokenType = tokenType + option.tokenName = tokenName + option.tokenSymbol = tokenSymbol option.startHopIndex = startHopIndex option.endHopIndex = endHopIndex option.isDirectSwap = endHopIndex - startHopIndex == 0 // Single hop = direct swap diff --git a/apps/web/src/components/HoldersSection/HoldersList.tsx b/apps/web/src/components/HoldersSection/HoldersList.tsx index 2e2ed3d75..4f8fdf0fa 100644 --- a/apps/web/src/components/HoldersSection/HoldersList.tsx +++ b/apps/web/src/components/HoldersSection/HoldersList.tsx @@ -68,7 +68,7 @@ const HolderItem = ({ address, balance, isDrop = false }: HolderItemProps) => { justify="space-between" gap="x3" py="x1" - px="x4" + px="x2" borderRadius="curved" className={holderLink} > diff --git a/apps/web/src/components/HoldersSection/HoldersSection.tsx b/apps/web/src/components/HoldersSection/HoldersSection.tsx index 65b08e223..fe1dbccf3 100644 --- a/apps/web/src/components/HoldersSection/HoldersSection.tsx +++ b/apps/web/src/components/HoldersSection/HoldersSection.tsx @@ -52,13 +52,10 @@ export const HoldersSection = ({ return ( - + {title} - - Showing {filteredHolders.length} - 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/modules/dashboard/CreateActions.tsx b/apps/web/src/modules/dashboard/CreateActions.tsx index 119f9e5ed..a834b2d2d 100644 --- a/apps/web/src/modules/dashboard/CreateActions.tsx +++ b/apps/web/src/modules/dashboard/CreateActions.tsx @@ -1,6 +1,5 @@ -import { COINING_ENABLED } from '@buildeross/constants/coining' import type { AddressType } from '@buildeross/types' -import { Button, Flex, Grid } from '@buildeross/zord' +import { Button, Flex } from '@buildeross/zord' import Link from 'next/link' import React, { useState } from 'react' @@ -13,9 +12,7 @@ export interface CreateActionsProps { export const CreateActions: React.FC = ({ userAddress }) => { const [selectorOpen, setSelectorOpen] = useState(false) - const [actionType, setActionType] = useState<'post' | 'proposal'>( - COINING_ENABLED ? 'post' : 'proposal' - ) + const [actionType, setActionType] = useState<'post' | 'proposal'>('post') const handleCreatePost = () => { setActionType('post') @@ -30,44 +27,19 @@ export const CreateActions: React.FC = ({ userAddress }) => return ( <> - {COINING_ENABLED ? ( - <> - - - Create Post - - - Create Proposal - - - - - Create a DAO - - - > - ) : ( - <> - - - Create Proposal - - - - Create a DAO - - - - > - )} + + + Create Post + + + Create Proposal + + + + + Create a DAO + + { diff --git a/apps/web/src/modules/dashboard/DaoProposalCard.tsx b/apps/web/src/modules/dashboard/DaoProposalCard.tsx index b8898c1ce..6fc848dae 100644 --- a/apps/web/src/modules/dashboard/DaoProposalCard.tsx +++ b/apps/web/src/modules/dashboard/DaoProposalCard.tsx @@ -64,6 +64,7 @@ export const DaoProposalCard = ({ px={'x3'} position={'relative'} link={getProposalLink?.(chainId, collectionAddress, proposalNumber)} + isExternal className={proposalCardVariants[displayWarning ? 'warning' : 'default']} > void }) => { - const { getDaoLink } = useLinks() + const { getDaoLink, getProposalCreateLink } = useLinks() return ( - + {contractImage ? ( - {onOpenCreateProposal && ( - onOpenCreateProposal(chainId, tokenAddress)} - > - Create Proposal - - )} + + Create Proposal + {proposals.map((proposal) => ( diff --git a/apps/web/src/modules/dashboard/DaoSelectorModal.tsx b/apps/web/src/modules/dashboard/DaoSelectorModal.tsx index cbd2f007b..5bf3eefb7 100644 --- a/apps/web/src/modules/dashboard/DaoSelectorModal.tsx +++ b/apps/web/src/modules/dashboard/DaoSelectorModal.tsx @@ -1,5 +1,5 @@ import { PUBLIC_ALL_CHAINS } from '@buildeross/constants/chains' -import { COIN_SUPPORTED_CHAIN_IDS, COINING_ENABLED } from '@buildeross/constants/coining' +import { COIN_SUPPORTED_CHAIN_IDS } from '@buildeross/constants/coining' import type { AddressType, CHAIN_ID } from '@buildeross/types' import { AnimatedModal } from '@buildeross/ui' import { Box, Button, Flex, Icon, Stack, Text } from '@buildeross/zord' @@ -25,7 +25,7 @@ export const DaoSelectorModal: React.FC = ({ const router = useRouter() const [selectedDao, setSelectedDao] = useState() - const isForPost = actionType === 'post' && COINING_ENABLED + const isForPost = actionType === 'post' const title = isForPost ? 'Select DAO for Post' : 'Select DAO for Proposal' const description = isForPost diff --git a/apps/web/src/modules/dashboard/Dashboard.tsx b/apps/web/src/modules/dashboard/Dashboard.tsx index 77946bcfb..394e8a019 100644 --- a/apps/web/src/modules/dashboard/Dashboard.tsx +++ b/apps/web/src/modules/dashboard/Dashboard.tsx @@ -6,7 +6,7 @@ import { useEnsData, } from '@buildeross/hooks' import { ProposalState } from '@buildeross/sdk/contract' -import { AddressType, CHAIN_ID } from '@buildeross/types' +import { AddressType } from '@buildeross/types' import { AccordionItem } from '@buildeross/ui/Accordion' import { DisplayPanel } from '@buildeross/ui/DisplayPanel' import { Box, Stack, Text } from '@buildeross/zord' @@ -23,11 +23,7 @@ import { UserProfileCard } from './UserProfileCard' export type DashboardDaoProps = DashboardDaoWithState -export type DashboardProps = { - handleOpenCreateProposal: (chainId: CHAIN_ID, tokenAddress: AddressType) => void -} - -export const Dashboard: React.FC = ({ handleOpenCreateProposal }) => { +export const Dashboard: React.FC = () => { const { address } = useAccount() const { displayName, ensAvatar } = useEnsData(address) const [openAccordion, setOpenAccordion] = React.useState<'daos' | 'proposals' | null>( @@ -118,10 +114,9 @@ export const Dashboard: React.FC = ({ handleOpenCreateProposal } key={dao.tokenAddress} {...dao} userAddress={address as AddressType} - onOpenCreateProposal={handleOpenCreateProposal} /> )) - }, [sortedDaos, address, handleOpenCreateProposal, hasLiveProposals]) + }, [sortedDaos, address, hasLiveProposals]) // Main content - always show Feed const mainContent = diff --git a/apps/web/src/modules/dashboard/MobileCreateMenu.tsx b/apps/web/src/modules/dashboard/MobileCreateMenu.tsx index b82701285..858757a01 100644 --- a/apps/web/src/modules/dashboard/MobileCreateMenu.tsx +++ b/apps/web/src/modules/dashboard/MobileCreateMenu.tsx @@ -1,4 +1,3 @@ -import { COINING_ENABLED } from '@buildeross/constants/coining' import type { AddressType } from '@buildeross/types' import { Icon, Stack, Text } from '@buildeross/zord' import Link from 'next/link' @@ -19,9 +18,7 @@ export interface MobileCreateMenuProps { export const MobileCreateMenu: React.FC = ({ userAddress }) => { const [selectorOpen, setSelectorOpen] = useState(false) - const [actionType, setActionType] = useState<'post' | 'proposal'>( - COINING_ENABLED ? 'post' : 'proposal' - ) + const [actionType, setActionType] = useState<'post' | 'proposal'>('post') const handleCreatePost = () => { setActionType('post') @@ -39,17 +36,15 @@ export const MobileCreateMenu: React.FC = ({ userAddress What would you like to create? - {COINING_ENABLED && ( - - - - Create Post - - - Share updates and content with your DAO community - - - )} + + + + Create Post + + + Share updates and content with your DAO community + + diff --git a/apps/web/src/pages/api/coins/swap-options.ts b/apps/web/src/pages/api/coins/swap-options.ts deleted file mode 100644 index a42a5e221..000000000 --- a/apps/web/src/pages/api/coins/swap-options.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { buildSwapOptions } from '@buildeross/swap' -import { CHAIN_ID } from '@buildeross/types' -import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' -import { NextApiRequest, NextApiResponse } from 'next' -import { withCors } from 'src/utils/api/cors' -import { withRateLimit } from 'src/utils/api/rateLimit' -import { isAddress } from 'viem' - -/** - * Serialize a SwapOption for JSON transport. - * Converts bigint fields (fee) to strings so JSON.stringify doesn't throw. - */ -function serializeSwapOptions( - result: NonNullable>> -) { - return { - options: result.options.map((opt) => ({ - ...opt, - token: { - ...opt.token, - fee: - opt.token.type === 'zora-coin' || opt.token.type === 'clanker-token' - ? opt.token.fee.toString() - : undefined, - }, - path: { - ...opt.path, - hops: opt.path.hops.map((hop) => ({ - ...hop, - fee: hop.fee !== undefined ? hop.fee.toString() : undefined, - })), - estimatedGas: - opt.path.estimatedGas !== undefined - ? opt.path.estimatedGas.toString() - : undefined, - }, - })), - } -} - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { chainId, coinAddress, isBuying } = req.query - - if (!chainId || !coinAddress) { - return res.status(400).json({ error: 'Missing chainId or coinAddress parameter' }) - } - - const chainIdNum = parseInt(chainId as string, 10) - - if (!Object.values(CHAIN_ID).includes(chainIdNum)) { - return res.status(400).json({ error: 'Invalid chainId' }) - } - - if (!isChainIdSupportedByCoining(chainIdNum as CHAIN_ID)) { - return res.status(400).json({ error: 'Chain not supported for swap options' }) - } - - if (!isAddress(coinAddress as string)) { - return res.status(400).json({ error: 'Invalid coinAddress' }) - } - - const buying = isBuying !== 'false' - - try { - const result = await buildSwapOptions( - chainIdNum as CHAIN_ID, - coinAddress as `0x${string}`, - buying - ) - - if (!result) { - res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=10') - return res.status(200).json({ options: [] }) - } - - res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=10') - return res.status(200).json(serializeSwapOptions(result)) - } catch (error) { - console.error('Swap options API error:', error) - return res.status(500).json({ error: 'Internal server error' }) - } -} - -export default withCors()( - withRateLimit({ - maxRequests: 60, - keyPrefix: 'coins:swapOptions', - })(handler) -) 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/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index ac30a933b..08ef809f1 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -1,6 +1,3 @@ -import { AddressType, CHAIN_ID } from '@buildeross/types' -import { chainIdToSlug } from '@buildeross/utils/chains' -import { useRouter } from 'next/router' import React from 'react' import { Meta } from 'src/components/Meta' import { DefaultLayout } from 'src/layouts/DefaultLayout' @@ -9,22 +6,10 @@ import { Dashboard } from 'src/modules/dashboard' import { container } from 'src/styles/dashboard.css' const DashboardPage = () => { - const { push } = useRouter() - - const handleOpenCreateProposal = (chainId: CHAIN_ID, tokenAddress: AddressType) => { - push({ - pathname: `/dao/[network]/[token]/proposal/create`, - query: { - network: chainIdToSlug(chainId), - token: tokenAddress, - }, - }) - } - return ( <> - + > ) } diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 084913680..570d4eb09 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -1,8 +1,5 @@ import { AuctionFragment } from '@buildeross/sdk/subgraph' -import { AddressType, CHAIN_ID } from '@buildeross/types' -import { chainIdToSlug } from '@buildeross/utils/chains' import { Stack } from '@buildeross/zord' -import { useRouter } from 'next/router' import React, { ReactNode } from 'react' import { Meta } from 'src/components/Meta' import { DefaultLayout } from 'src/layouts/DefaultLayout' @@ -46,23 +43,12 @@ function ConditionalLayout({ children }: { children: ReactNode }) { const HomePage: NextPageWithLayout = () => { const { address } = useAccount() - const { push } = useRouter() - - const handleOpenCreateProposal = (chainId: CHAIN_ID, tokenAddress: AddressType) => { - push({ - pathname: `/dao/[network]/[token]/proposal/create`, - query: { - network: chainIdToSlug(chainId), - token: tokenAddress, - }, - }) - } return ( <> {address ? ( - + ) : ( diff --git a/packages/constants/src/coining.ts b/packages/constants/src/coining.ts index 86caab2f0..d2141c828 100644 --- a/packages/constants/src/coining.ts +++ b/packages/constants/src/coining.ts @@ -1,7 +1,6 @@ import { CHAIN_ID } from '@buildeross/types' -export const COINING_ENABLED = process.env.NEXT_PUBLIC_COINING_ENABLED === 'true' - -export const COIN_SUPPORTED_CHAIN_IDS: readonly CHAIN_ID[] = COINING_ENABLED - ? [CHAIN_ID.BASE, CHAIN_ID.BASE_SEPOLIA] - : [] +export const COIN_SUPPORTED_CHAIN_IDS: readonly CHAIN_ID[] = [ + CHAIN_ID.BASE, + CHAIN_ID.BASE_SEPOLIA, +] 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 && ( = ({ {`${transactions.length} transaction${transactions.length === 1 ? '' : 's'} queued`} )} - {showContinue && onOpenProposalReview && ( - - Continue + {showStepBack && onStepBack && ( + + + + )} + {showReset && onReset && ( + setResetModalOpen(true)} + aria-label={resetLabel} + > + + + )} + {showContinue && continueHandler && ( + + {continueLabel} )} @@ -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 && ( + setQueueModalOpen(true)} + > + + {transactions.length} + + )} + + {showReset && onReset && ( + setResetModalOpen(true)} + > + + + )} + + {showContinue && onContinue && ( + + {continueLabel} + + )} + + + 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. + + + + Got it + + + )} + + + {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} + + + + {confirmLabel} + + + {cancelLabel} + + + + + ) +} 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) => ( + + Proposal Preview + setIsEditingMetadata((state) => !state)} + > + + {isEditingMetadata ? 'Done' : 'Edit'} + + + + {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']} - /> - )} - + + Governance Timeline (estimated) + + + + 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/Cards/Cards.css.ts b/packages/dao-ui/src/components/Cards/Cards.css.ts index 865666a3c..aa53baa48 100644 --- a/packages/dao-ui/src/components/Cards/Cards.css.ts +++ b/packages/dao-ui/src/components/Cards/Cards.css.ts @@ -3,10 +3,12 @@ import { style } from '@vanilla-extract/css' export const card = style([ { - transition: 'transform 0.2s ease, box-shadow 0.2s ease', + position: 'relative', + top: 0, + transition: 'top 0.2s ease, box-shadow 0.2s ease', border: `2px solid ${color.border}`, ':hover': { - transform: 'translateY(-2px)', + top: -2, boxShadow: `0 4px 12px ${theme.colors.ghostHover}`, }, }, 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/feed-ui/src/FeedFiltersModal.tsx b/packages/feed-ui/src/FeedFiltersModal.tsx index 79f741536..53949053d 100644 --- a/packages/feed-ui/src/FeedFiltersModal.tsx +++ b/packages/feed-ui/src/FeedFiltersModal.tsx @@ -1,5 +1,4 @@ import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' -import { COINING_ENABLED } from '@buildeross/constants/coining' import { FeedEventType } from '@buildeross/sdk/subgraph' import type { AddressType, CHAIN_ID } from '@buildeross/types' import { AnimatedModal } from '@buildeross/ui' @@ -175,7 +174,7 @@ export const FeedFiltersModal: React.FC = ({ const eventTypeLabels = useMemo(() => { let entries = Object.entries(EVENT_TYPE_LABELS) - if (hasNoCoinSupportedChains(formik.values.chainIds) || !COINING_ENABLED) { + if (hasNoCoinSupportedChains(formik.values.chainIds)) { entries = entries.filter( ([eventType]) => !COIN_EVENT_TYPES.includes(eventType as FeedEventType) ) diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 9ff6159dd..b9f6683dc 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -51,7 +51,6 @@ export * from './useQueryParams' export * from './useScrollDirection' export * from './useStreamData' export * from './useSwapOptions' -export * from './useSwapPath' export * from './useSwapQuote' export * from './useTimeout' export * from './useTokenBalances' diff --git a/packages/hooks/src/useClankerTokens.ts b/packages/hooks/src/useClankerTokens.ts index 2b9c6bf1c..dd029fc82 100644 --- a/packages/hooks/src/useClankerTokens.ts +++ b/packages/hooks/src/useClankerTokens.ts @@ -1,4 +1,3 @@ -import { COINING_ENABLED } from '@buildeross/constants/coining' import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { type ClankerTokenCardFragment, @@ -33,7 +32,7 @@ export const useClankerTokens = ({ : undefined const { data, error, isLoading, isValidating, mutate } = useSWR( - !!collectionAddress && enabled && isChainSupported && COINING_ENABLED + !!collectionAddress && enabled && isChainSupported ? ([SWR_KEYS.CLANKER_TOKENS, chainId, collectionAddress, first] as const) : null, async ([, _chainId, _collectionAddress, _first]) => diff --git a/packages/hooks/src/useClankerTokensFull.ts b/packages/hooks/src/useClankerTokensFull.ts index 6c8e0b0fd..610ad606f 100644 --- a/packages/hooks/src/useClankerTokensFull.ts +++ b/packages/hooks/src/useClankerTokensFull.ts @@ -1,4 +1,3 @@ -import { COINING_ENABLED } from '@buildeross/constants/coining' import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { type ClankerTokenFragment, @@ -38,7 +37,7 @@ export const useClankerTokensFull = ({ : undefined const { data, error, isLoading, isValidating, mutate } = useSWR( - !!collectionAddress && enabled && isChainSupported && COINING_ENABLED + !!collectionAddress && enabled && isChainSupported ? ([SWR_KEYS.CLANKER_TOKENS_FULL, chainId, collectionAddress, first] as const) : null, async ([, _chainId, _collectionAddress, _first]) => 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/hooks/src/useGalleryItems.ts b/packages/hooks/src/useGalleryItems.ts index 12a45be50..321c1172b 100644 --- a/packages/hooks/src/useGalleryItems.ts +++ b/packages/hooks/src/useGalleryItems.ts @@ -1,4 +1,3 @@ -import { COINING_ENABLED } from '@buildeross/constants/coining' import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { daoZoraCoinsRequest, @@ -58,7 +57,7 @@ export const useGalleryItems = ({ isValidating: isValidatingCoins, mutate: mutateCoins, } = useSWR( - !!collectionAddress && enabled && isCoinSupported && COINING_ENABLED + !!collectionAddress && enabled && isCoinSupported ? ([SWR_KEYS.ZORA_COINS, chainId, collectionAddress, first] as const) : null, async ([, _chainId, _collectionAddress, _first]) => diff --git a/packages/hooks/src/useSwapOptions.ts b/packages/hooks/src/useSwapOptions.ts index 47c66037b..c51daaeea 100644 --- a/packages/hooks/src/useSwapOptions.ts +++ b/packages/hooks/src/useSwapOptions.ts @@ -1,11 +1,11 @@ -import { BASE_URL } from '@buildeross/constants/baseUrl' +import { NATIVE_TOKEN_ADDRESS, WETH_ADDRESS } from '@buildeross/constants/addresses' import { SWR_KEYS } from '@buildeross/constants/swrKeys' -import { SwapOption as SwapOptionType } from '@buildeross/swap' +import { type SwapRouteFragment, swapRouteRequest } from '@buildeross/sdk/subgraph' +import type { SwapOption as SwapOptionType, SwapPath, TokenInfo } from '@buildeross/swap' import { AddressType, CHAIN_ID } from '@buildeross/types' import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' import useSWR from 'swr' -// Re-export SwapOption type from swap package export type SwapOption = SwapOptionType export interface UseSwapOptionsResult { @@ -14,46 +14,291 @@ export interface UseSwapOptionsResult { error: Error | null } +type Hop = { + tokenIn: AddressType + tokenOut: AddressType + poolId: AddressType + fee?: bigint + hooks?: AddressType + tickSpacing?: number +} + /** - * Deserialize swap options from the API response. - * The API serializes bigint fields (fee, estimatedGas) as strings. + * Subgraph mainPath is stored as: COIN -> PAYMENT TOKEN (SELL direction) + * If isBuying=true, we must invert to get PAYMENT -> COIN. */ -function deserializeSwapOptions(data: any): SwapOption[] { - if (!Array.isArray(data?.options)) return [] - return data.options.map((opt: any) => ({ - ...opt, - token: { - ...opt.token, - fee: opt.token.fee !== undefined ? BigInt(opt.token.fee) : undefined, - }, - path: { - ...opt.path, - hops: (opt.path.hops ?? []).map((hop: any) => ({ - ...hop, - fee: hop.fee !== undefined ? BigInt(hop.fee) : undefined, - })), - estimatedGas: - opt.path.estimatedGas !== undefined ? BigInt(opt.path.estimatedGas) : undefined, - }, +function normalizeHopsForTradeDirection( + relevantHops: SwapRouteFragment['mainPath'], + isBuying: boolean +): Hop[] { + if (!isBuying) { + // Selling: Coin -> Payment (as stored) + return relevantHops.map((hop) => ({ + tokenIn: hop.tokenIn as AddressType, + tokenOut: hop.tokenOut as AddressType, + poolId: hop.poolId as AddressType, + fee: hop.fee != null ? BigInt(hop.fee) : undefined, + hooks: hop.hooks ? (hop.hooks as AddressType) : undefined, + tickSpacing: hop.tickSpacing ?? undefined, + })) + } + + // Buying: Payment -> Coin (invert) + return [...relevantHops].reverse().map((hop) => ({ + tokenIn: hop.tokenOut as AddressType, + tokenOut: hop.tokenIn as AddressType, + poolId: hop.poolId as AddressType, + fee: hop.fee ? BigInt(hop.fee) : undefined, + hooks: hop.hooks ? (hop.hooks as AddressType) : undefined, + tickSpacing: hop.tickSpacing ?? undefined, })) } +function convertCoinType( + coinType: string +): 'eth' | 'weth' | 'zora-coin' | 'clanker-token' | undefined { + switch (coinType) { + case 'ETH': + return 'eth' + case 'WETH': + return 'weth' + case 'CLANKER_TOKEN': + return 'clanker-token' + case 'ZORA_COIN': + return 'zora-coin' + case 'UNKNOWN': + return undefined + default: + return undefined + } +} + +function sanitizeHopRange( + startHopIndex: number, + endHopIndex: number, + pathLength: number, + label: string +): { start: number; end: number } | null { + if (pathLength <= 0) { + console.warn(`[useSwapOptions] Skipping ${label}: mainPath is empty`) + return null + } + + const originalStart = startHopIndex + const originalEnd = endHopIndex + + if (!Number.isInteger(startHopIndex) || !Number.isInteger(endHopIndex)) { + console.warn( + `[useSwapOptions] Non-integer hop indices for ${label}: start=${startHopIndex}, end=${endHopIndex}` + ) + } + + if (!Number.isFinite(startHopIndex) || !Number.isFinite(endHopIndex)) { + console.warn( + `[useSwapOptions] Skipping ${label}: non-finite hop indices start=${startHopIndex}, end=${endHopIndex}` + ) + return null + } + + const maxIndex = pathLength - 1 + const start = Math.min(Math.max(Math.trunc(startHopIndex), 0), maxIndex) + const end = Math.min(Math.max(Math.trunc(endHopIndex), 0), maxIndex) + + if (start !== originalStart || end !== originalEnd) { + console.warn( + `[useSwapOptions] Clamped hop indices for ${label}: start=${originalStart}->${start}, end=${originalEnd}->${end}, pathLength=${pathLength}` + ) + } + + if (start > end) { + console.warn( + `[useSwapOptions] Skipping ${label}: invalid hop range start=${start}, end=${end}` + ) + return null + } + + return { start, end } +} + +function addrEq(a?: string, b?: string): boolean { + if (!a || !b) return false + return a.toLowerCase() === b.toLowerCase() +} + +export function convertSwapRouteToOptions( + swapRoute: SwapRouteFragment, + isBuying: boolean, + chainId: CHAIN_ID +): SwapOption[] { + const paymentOptions = swapRoute.paymentOptions ?? [] + if (paymentOptions.length === 0) return [] + + const tokenDetailsMap = new Map() + + // Target token metadata (if present) + if (swapRoute.clankerToken) { + tokenDetailsMap.set(swapRoute.clankerToken.tokenAddress.toLowerCase(), { + symbol: swapRoute.clankerToken.tokenSymbol, + name: swapRoute.clankerToken.tokenName, + }) + } + if (swapRoute.zoraCoin) { + tokenDetailsMap.set(swapRoute.zoraCoin.coinAddress.toLowerCase(), { + symbol: swapRoute.zoraCoin.symbol, + name: swapRoute.zoraCoin.name, + }) + } + + // WETH metadata + const wethAddress = WETH_ADDRESS[chainId] + if (wethAddress) { + tokenDetailsMap.set(wethAddress.toLowerCase(), { + symbol: 'WETH', + name: 'Wrapped Ether', + }) + } + + // Payment options metadata from subgraph + paymentOptions.forEach((paymentOption) => { + tokenDetailsMap.set(paymentOption.tokenAddress.toLowerCase(), { + symbol: paymentOption.tokenSymbol, + name: paymentOption.tokenName, + }) + }) + + const options: SwapOption[] = [] + + // Build options from paymentOptions + for (const paymentOption of paymentOptions) { + const hopRange = sanitizeHopRange( + paymentOption.startHopIndex, + paymentOption.endHopIndex, + swapRoute.mainPath.length, + `payment option ${paymentOption.tokenAddress}` + ) + if (!hopRange) continue + + const relevantHops = swapRoute.mainPath.slice(hopRange.start, hopRange.end + 1) + if (relevantHops.length === 0) { + console.warn( + `[useSwapOptions] Skipping payment option ${paymentOption.tokenAddress}: no hops in sanitized range` + ) + continue + } + + const hops = normalizeHopsForTradeDirection(relevantHops, isBuying) + + const path: SwapPath = { + hops, + isOptimal: true, + estimatedGas: undefined, + } + + const tokenAddress = paymentOption.tokenAddress as AddressType + const tokenDetails = tokenDetailsMap.get(tokenAddress.toLowerCase()) ?? { + symbol: 'TOKEN', + name: 'Token', + } + + const tokenType = convertCoinType(paymentOption.tokenType) + if (!tokenType) { + console.warn( + `[useSwapOptions] Skipping payment option ${paymentOption.tokenAddress}: unsupported tokenType ${paymentOption.tokenType}` + ) + continue + } + + const tokenInfo: TokenInfo = { + address: tokenAddress, + symbol: tokenDetails.symbol, + name: tokenDetails.name, + type: tokenType, + } + + options.push({ + token: tokenInfo, + path, + isDirectSwap: paymentOption.isDirectSwap, + }) + } + + options.reverse() + + // Add ETH option based on the WETH option (same hops), but token is native ETH + const wethOpt = paymentOptions.find((opt) => { + const addr = (opt.tokenAddress as string)?.toLowerCase() + return wethAddress && addr === wethAddress.toLowerCase() + }) + + if (wethOpt) { + const hopRange = sanitizeHopRange( + wethOpt.startHopIndex, + wethOpt.endHopIndex, + swapRoute.mainPath.length, + `WETH payment option ${wethOpt.tokenAddress}` + ) + if (!hopRange) { + return options + } + + const relevantHops = swapRoute.mainPath.slice(hopRange.start, hopRange.end + 1) + if (relevantHops.length === 0) { + console.warn('[useSwapOptions] Skipping ETH option: WETH path has no hops in range') + return options + } + + const hops = normalizeHopsForTradeDirection(relevantHops, isBuying) + + // If user selected ETH (not WETH), preserve native currency in displayed path. + if (hops.length > 0 && wethAddress) { + if (isBuying) { + const firstHop = hops[0] + if (addrEq(firstHop.tokenIn, wethAddress)) { + hops[0] = { ...firstHop, tokenIn: NATIVE_TOKEN_ADDRESS } + } + } else { + const lastHop = hops[hops.length - 1] + if (addrEq(lastHop.tokenOut, wethAddress)) { + hops[hops.length - 1] = { ...lastHop, tokenOut: NATIVE_TOKEN_ADDRESS } + } + } + } + + const ethPath: SwapPath = { + hops, + isOptimal: true, + estimatedGas: undefined, + } + + const ethInfo: TokenInfo = { + address: NATIVE_TOKEN_ADDRESS, + symbol: 'ETH', + name: 'Ethereum', + type: 'eth', + } + + options.unshift({ + token: ethInfo, + path: ethPath, + isDirectSwap: wethOpt.isDirectSwap, + }) + } + + return options +} + async function fetchSwapOptions( chainId: CHAIN_ID, coinAddress: string, isBuying: boolean ): Promise { - const params = new URLSearchParams() - params.set('chainId', chainId.toString()) - params.set('coinAddress', coinAddress) - params.set('isBuying', isBuying.toString()) - - const response = await fetch(`${BASE_URL}/api/coins/swap-options?${params.toString()}`) - if (!response.ok) { - throw new Error('Failed to fetch swap options') + const swapRoute = await swapRouteRequest(coinAddress, chainId) + + if (!swapRoute) { + return [] } - const result = await response.json() - return deserializeSwapOptions(result) + + return convertSwapRouteToOptions(swapRoute, isBuying, chainId) } /** @@ -73,7 +318,7 @@ export function useSwapOptions( async ([, _chainId, _coinAddress, _isBuying]) => fetchSwapOptions(_chainId, _coinAddress, _isBuying), { - refreshInterval: 30_000, + refreshInterval: 60_000, revalidateOnFocus: false, revalidateOnReconnect: false, } diff --git a/packages/hooks/src/useSwapPath.ts b/packages/hooks/src/useSwapPath.ts deleted file mode 100644 index 74dfec3c3..000000000 --- a/packages/hooks/src/useSwapPath.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { buildSwapPath, SwapPath } from '@buildeross/swap' -import { CHAIN_ID } from '@buildeross/types' -import { useEffect, useState } from 'react' -import { Address } from 'viem' - -interface UseSwapPathParams { - chainId: CHAIN_ID - tokenIn?: Address - tokenOut?: Address - enabled?: boolean -} - -interface UseSwapPathReturn { - path: SwapPath | null - isLoading: boolean - error: Error | null -} - -/** - * Hook to build a swap path between two tokens - */ -export function useSwapPath({ - chainId, - tokenIn, - tokenOut, - enabled = true, -}: UseSwapPathParams): UseSwapPathReturn { - const [path, setPath] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - - useEffect(() => { - if (!enabled || !tokenIn || !tokenOut) { - setPath(null) - setIsLoading(false) - setError(null) - return - } - - let cancelled = false - - const fetchPath = async () => { - setIsLoading(true) - setError(null) - - try { - const result = await buildSwapPath(chainId, tokenIn, tokenOut) - - if (!cancelled) { - setPath(result) - setIsLoading(false) - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err : new Error('Failed to build swap path')) - setIsLoading(false) - } - } - } - - fetchPath() - - return () => { - cancelled = true - } - }, [chainId, tokenIn, tokenOut, enabled]) - - return { path, isLoading, error } -} diff --git a/packages/hooks/src/useZoraCoins.ts b/packages/hooks/src/useZoraCoins.ts index c19ccbbfa..78b26619b 100644 --- a/packages/hooks/src/useZoraCoins.ts +++ b/packages/hooks/src/useZoraCoins.ts @@ -1,4 +1,3 @@ -import { COINING_ENABLED } from '@buildeross/constants/coining' import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { daoZoraCoinsRequest, type ZoraCoinCardFragment } from '@buildeross/sdk/subgraph' import type { AddressType, CHAIN_ID } from '@buildeross/types' @@ -30,7 +29,7 @@ export const useZoraCoins = ({ : undefined const { data, error, isLoading, isValidating, mutate } = useSWR( - !!collectionAddress && enabled && isChainSupported && COINING_ENABLED + !!collectionAddress && enabled && isChainSupported ? ([SWR_KEYS.ZORA_COINS, chainId, collectionAddress, first] as const) : null, async ([, _chainId, _collectionAddress, _first]) => 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/sdk/codegen.yml b/packages/sdk/codegen.yml index 7e75d4b78..15f1573fe 100644 --- a/packages/sdk/codegen.yml +++ b/packages/sdk/codegen.yml @@ -1,6 +1,6 @@ generates: src/subgraph/sdk.generated.ts: - schema: 'https://api.goldsky.com/api/public/project_cm33ek8kjx6pz010i2c3w8z25/subgraphs/nouns-builder-base-sepolia/0.1.13/gn' + schema: 'https://api.goldsky.com/api/public/project_cm33ek8kjx6pz010i2c3w8z25/subgraphs/nouns-builder-base-mainnet/0.1.14/gn' documents: 'src/subgraph/**/*.graphql' plugins: - typescript diff --git a/packages/sdk/src/subgraph/fragments/PaymentOption.graphql b/packages/sdk/src/subgraph/fragments/PaymentOption.graphql new file mode 100644 index 000000000..069dbcf72 --- /dev/null +++ b/packages/sdk/src/subgraph/fragments/PaymentOption.graphql @@ -0,0 +1,10 @@ +fragment PaymentOption on PaymentOption { + id + tokenAddress + tokenType + tokenName + tokenSymbol + startHopIndex + endHopIndex + isDirectSwap +} diff --git a/packages/sdk/src/subgraph/fragments/SwapHop.graphql b/packages/sdk/src/subgraph/fragments/SwapHop.graphql new file mode 100644 index 000000000..3af902c36 --- /dev/null +++ b/packages/sdk/src/subgraph/fragments/SwapHop.graphql @@ -0,0 +1,10 @@ +fragment SwapHop on SwapHop { + id + tokenIn + tokenOut + poolId + fee + hooks + tickSpacing + hopIndex +} diff --git a/packages/sdk/src/subgraph/fragments/SwapRoute.graphql b/packages/sdk/src/subgraph/fragments/SwapRoute.graphql new file mode 100644 index 000000000..c443566c6 --- /dev/null +++ b/packages/sdk/src/subgraph/fragments/SwapRoute.graphql @@ -0,0 +1,25 @@ +#import "./SwapHop.graphql" +#import "./PaymentOption.graphql" + +fragment SwapRoute on SwapRoute { + id + coinAddress + clankerToken { + tokenAddress + tokenName + tokenSymbol + } + zoraCoin { + coinAddress + name + symbol + } + mainPath(orderBy: hopIndex, orderDirection: asc) { + ...SwapHop + } + paymentOptions { + ...PaymentOption + } + createdAt + updatedAt +} diff --git a/packages/sdk/src/subgraph/index.ts b/packages/sdk/src/subgraph/index.ts index 0ee10d79e..0acf00350 100644 --- a/packages/sdk/src/subgraph/index.ts +++ b/packages/sdk/src/subgraph/index.ts @@ -21,6 +21,7 @@ export * from './requests/memberSnapshot' export * from './requests/proposalByExecutionTxHashQuery' export * from './requests/proposalQuery' export * from './requests/proposalsQuery' +export * from './requests/swapRouteQuery' export * from './requests/sync' export * from './requests/tokensQuery' export * from './requests/userProposalVote' @@ -42,11 +43,14 @@ export { type DaosForDashboardQuery, FeedEventType, OrderDirection, + type PaymentOptionFragment, type Proposal_Filter, type ProposalFragment, type ProposalVoteFragment, ProposalVoteSupport, Snapshot_OrderBy, + type SwapHopFragment, + type SwapRouteFragment, Token_OrderBy, type TokenWithDaoQuery, ZoraCoin_OrderBy, diff --git a/packages/sdk/src/subgraph/queries/swapRoute.graphql b/packages/sdk/src/subgraph/queries/swapRoute.graphql new file mode 100644 index 000000000..95e006f71 --- /dev/null +++ b/packages/sdk/src/subgraph/queries/swapRoute.graphql @@ -0,0 +1,7 @@ +#import "../fragments/SwapRoute.graphql" + +query swapRoute($coinAddress: ID!) { + swapRoute(id: $coinAddress) { + ...SwapRoute + } +} diff --git a/packages/sdk/src/subgraph/requests/swapRouteQuery.ts b/packages/sdk/src/subgraph/requests/swapRouteQuery.ts new file mode 100644 index 000000000..0fc0a120d --- /dev/null +++ b/packages/sdk/src/subgraph/requests/swapRouteQuery.ts @@ -0,0 +1,29 @@ +import { CHAIN_ID } from '@buildeross/types' +import { isAddress } from 'viem' + +import { SDK } from '../client' +import type { SwapRouteFragment } from '../sdk.generated' + +export const swapRouteRequest = async ( + coinAddress: string, + chainId: CHAIN_ID +): Promise => { + if (!coinAddress) throw new Error('No coin address provided') + if (!isAddress(coinAddress)) throw new Error('Invalid coin address') + + try { + const data = await SDK.connect(chainId).swapRoute({ + coinAddress: coinAddress.toLowerCase(), + }) + + return data.swapRoute || null + } catch (e: any) { + console.error('Error fetching swap route:', e) + try { + const sentry = (await import('@sentry/nextjs')) as typeof import('@sentry/nextjs') + sentry.captureException(e) + sentry.flush(2000).catch(() => {}) + } catch (_) {} + return null + } +} diff --git a/packages/sdk/src/subgraph/sdk.generated.ts b/packages/sdk/src/subgraph/sdk.generated.ts index 6a1cb8df0..bcd8badd9 100644 --- a/packages/sdk/src/subgraph/sdk.generated.ts +++ b/packages/sdk/src/subgraph/sdk.generated.ts @@ -2676,6 +2676,8 @@ export type PaymentOption = { route: SwapRoute startHopIndex: Scalars['Int']['output'] tokenAddress: Scalars['Bytes']['output'] + tokenName: Scalars['String']['output'] + tokenSymbol: Scalars['String']['output'] tokenType: CoinType } @@ -2743,6 +2745,46 @@ export type PaymentOption_Filter = { tokenAddress_not?: InputMaybe tokenAddress_not_contains?: InputMaybe tokenAddress_not_in?: InputMaybe> + tokenName?: InputMaybe + tokenName_contains?: InputMaybe + tokenName_contains_nocase?: InputMaybe + tokenName_ends_with?: InputMaybe + tokenName_ends_with_nocase?: InputMaybe + tokenName_gt?: InputMaybe + tokenName_gte?: InputMaybe + tokenName_in?: InputMaybe> + tokenName_lt?: InputMaybe + tokenName_lte?: InputMaybe + tokenName_not?: InputMaybe + tokenName_not_contains?: InputMaybe + tokenName_not_contains_nocase?: InputMaybe + tokenName_not_ends_with?: InputMaybe + tokenName_not_ends_with_nocase?: InputMaybe + tokenName_not_in?: InputMaybe> + tokenName_not_starts_with?: InputMaybe + tokenName_not_starts_with_nocase?: InputMaybe + tokenName_starts_with?: InputMaybe + tokenName_starts_with_nocase?: InputMaybe + tokenSymbol?: InputMaybe + tokenSymbol_contains?: InputMaybe + tokenSymbol_contains_nocase?: InputMaybe + tokenSymbol_ends_with?: InputMaybe + tokenSymbol_ends_with_nocase?: InputMaybe + tokenSymbol_gt?: InputMaybe + tokenSymbol_gte?: InputMaybe + tokenSymbol_in?: InputMaybe> + tokenSymbol_lt?: InputMaybe + tokenSymbol_lte?: InputMaybe + tokenSymbol_not?: InputMaybe + tokenSymbol_not_contains?: InputMaybe + tokenSymbol_not_contains_nocase?: InputMaybe + tokenSymbol_not_ends_with?: InputMaybe + tokenSymbol_not_ends_with_nocase?: InputMaybe + tokenSymbol_not_in?: InputMaybe> + tokenSymbol_not_starts_with?: InputMaybe + tokenSymbol_not_starts_with_nocase?: InputMaybe + tokenSymbol_starts_with?: InputMaybe + tokenSymbol_starts_with_nocase?: InputMaybe tokenType?: InputMaybe tokenType_in?: InputMaybe> tokenType_not?: InputMaybe @@ -2760,6 +2802,8 @@ export enum PaymentOption_OrderBy { RouteUpdatedAt = 'route__updatedAt', StartHopIndex = 'startHopIndex', TokenAddress = 'tokenAddress', + TokenName = 'tokenName', + TokenSymbol = 'tokenSymbol', TokenType = 'tokenType', } @@ -7371,6 +7415,18 @@ export type ExploreDaoFragment = { token: { __typename?: 'Token'; name: string; image?: string | null; tokenId: any } } +export type PaymentOptionFragment = { + __typename?: 'PaymentOption' + id: string + tokenAddress: any + tokenType: CoinType + tokenName: string + tokenSymbol: string + startHopIndex: number + endHopIndex: number + isDirectSwap: boolean +} + export type ProposalFragment = { __typename?: 'Proposal' abstainVotes: number @@ -7421,6 +7477,60 @@ export type SnapshotFragment = { dao: { __typename?: 'DAO'; name: string; id: string } } +export type SwapHopFragment = { + __typename?: 'SwapHop' + id: string + tokenIn: any + tokenOut: any + poolId: any + fee?: any | null + hooks?: any | null + tickSpacing?: number | null + hopIndex: number +} + +export type SwapRouteFragment = { + __typename?: 'SwapRoute' + id: string + coinAddress: any + createdAt: any + updatedAt: any + clankerToken?: { + __typename?: 'ClankerToken' + tokenAddress: any + tokenName: string + tokenSymbol: string + } | null + zoraCoin?: { + __typename?: 'ZoraCoin' + coinAddress: any + name: string + symbol: string + } | null + mainPath: Array<{ + __typename?: 'SwapHop' + id: string + tokenIn: any + tokenOut: any + poolId: any + fee?: any | null + hooks?: any | null + tickSpacing?: number | null + hopIndex: number + }> + paymentOptions: Array<{ + __typename?: 'PaymentOption' + id: string + tokenAddress: any + tokenType: CoinType + tokenName: string + tokenSymbol: string + startHopIndex: number + endHopIndex: number + isDirectSwap: boolean + }> +} + export type TokenFragment = { __typename?: 'Token' tokenId: any @@ -8765,6 +8875,55 @@ export type SnapshotsQuery = { }> } +export type SwapRouteQueryVariables = Exact<{ + coinAddress: Scalars['ID']['input'] +}> + +export type SwapRouteQuery = { + __typename?: 'Query' + swapRoute?: { + __typename?: 'SwapRoute' + id: string + coinAddress: any + createdAt: any + updatedAt: any + clankerToken?: { + __typename?: 'ClankerToken' + tokenAddress: any + tokenName: string + tokenSymbol: string + } | null + zoraCoin?: { + __typename?: 'ZoraCoin' + coinAddress: any + name: string + symbol: string + } | null + mainPath: Array<{ + __typename?: 'SwapHop' + id: string + tokenIn: any + tokenOut: any + poolId: any + fee?: any | null + hooks?: any | null + tickSpacing?: number | null + hopIndex: number + }> + paymentOptions: Array<{ + __typename?: 'PaymentOption' + id: string + tokenAddress: any + tokenType: CoinType + tokenName: string + tokenSymbol: string + startHopIndex: number + endHopIndex: number + isDirectSwap: boolean + }> + } | null +} + export type TokenWithDaoQueryVariables = Exact<{ id: Scalars['ID']['input'] }> @@ -9264,6 +9423,56 @@ export const SnapshotFragmentDoc = gql` blockNumber } ` +export const SwapHopFragmentDoc = gql` + fragment SwapHop on SwapHop { + id + tokenIn + tokenOut + poolId + fee + hooks + tickSpacing + hopIndex + } +` +export const PaymentOptionFragmentDoc = gql` + fragment PaymentOption on PaymentOption { + id + tokenAddress + tokenType + tokenName + tokenSymbol + startHopIndex + endHopIndex + isDirectSwap + } +` +export const SwapRouteFragmentDoc = gql` + fragment SwapRoute on SwapRoute { + id + coinAddress + clankerToken { + tokenAddress + tokenName + tokenSymbol + } + zoraCoin { + coinAddress + name + symbol + } + mainPath(orderBy: hopIndex, orderDirection: asc) { + ...SwapHop + } + paymentOptions { + ...PaymentOption + } + createdAt + updatedAt + } + ${SwapHopFragmentDoc} + ${PaymentOptionFragmentDoc} +` export const TokenFragmentDoc = gql` fragment Token on Token { tokenId @@ -10178,6 +10387,14 @@ export const SnapshotsDocument = gql` } ${SnapshotFragmentDoc} ` +export const SwapRouteDocument = gql` + query swapRoute($coinAddress: ID!) { + swapRoute(id: $coinAddress) { + ...SwapRoute + } + } + ${SwapRouteFragmentDoc} +` export const TokenWithDaoDocument = gql` query tokenWithDao($id: ID!) { token(id: $id) { @@ -10984,6 +11201,24 @@ export function getSdk( variables ) }, + swapRoute( + variables: SwapRouteQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + signal?: RequestInit['signal'] + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request({ + document: SwapRouteDocument, + variables, + requestHeaders: { ...requestHeaders, ...wrappedRequestHeaders }, + signal, + }), + 'swapRoute', + 'query', + variables + ) + }, tokenWithDao( variables: TokenWithDaoQueryVariables, requestHeaders?: GraphQLClientRequestHeaders, 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/swap/src/buildSwapOptions.ts b/packages/swap/src/buildSwapOptions.ts deleted file mode 100644 index 5760c7955..000000000 --- a/packages/swap/src/buildSwapOptions.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { NATIVE_TOKEN_ADDRESS, WETH_ADDRESS } from '@buildeross/constants/addresses' -import { CHAIN_ID } from '@buildeross/types' -import { Address } from 'viem' - -import { buildSwapPath } from './buildSwapPath' -import { getCoinInfo } from './getCoinInfo' -import { CoinInfo, SwapPath } from './types' - -export interface SwapOption { - /** Token info including address, symbol, and type */ - token: CoinInfo - /** Swap path for this token <-> coin */ - path: SwapPath - /** True if this is a direct swap (single hop or no hop) */ - isDirectSwap: boolean -} - -export interface BuildSwapOptionsResult { - /** All available swap options */ - options: SwapOption[] - /** The main path used to discover options (coin <-> WETH) */ - mainPath: SwapPath -} - -/** - * Build all available swap options for a coin - * Returns ETH, WETH, and all intermediate tokens in the swap path - * - * @param chainId - Chain ID - * @param coinAddress - The coin to swap with - * @param isBuying - True if buying the coin, false if selling - * @returns All swap options with paths and token metadata - */ -export async function buildSwapOptions( - chainId: CHAIN_ID, - coinAddress: Address, - isBuying: boolean -): Promise { - const weth = WETH_ADDRESS[chainId] - if (!weth) return null - - // Build main path: coin <-> WETH to discover all tokens - const tokenIn = isBuying ? weth : coinAddress - const tokenOut = isBuying ? coinAddress : weth - - const mainPath = await buildSwapPath(chainId, tokenIn, tokenOut) - if (!mainPath) return null - - // Base tokens: ETH and WETH - const ethInfo: CoinInfo = { - address: NATIVE_TOKEN_ADDRESS, - type: 'eth', - symbol: 'ETH', - name: 'Ethereum', - } - const wethInfo: CoinInfo = { - address: weth, - type: 'weth', - symbol: 'WETH', - name: 'Wrapped Ether', - } - - const options: SwapOption[] = [] - - // If no hops, just return ETH and WETH (direct swap or no path) - if (!mainPath.hops || mainPath.hops.length === 0) { - // Build ETH path (same as WETH but with wrapping/unwrapping) - const ethPath = await buildSwapPath( - chainId, - isBuying ? NATIVE_TOKEN_ADDRESS : coinAddress, - isBuying ? coinAddress : NATIVE_TOKEN_ADDRESS - ) - - options.push( - { - token: ethInfo, - path: ethPath || { hops: [], isOptimal: true }, - isDirectSwap: true, - }, - { - token: wethInfo, - path: mainPath, - isDirectSwap: true, - } - ) - - return { options, mainPath } - } - - // Extract all unique tokens from the main path - const allTokensInPath = new Set() - mainPath.hops.forEach((hop) => { - allTokensInPath.add(hop.tokenIn.toLowerCase()) - allTokensInPath.add(hop.tokenOut.toLowerCase()) - }) - - // Separate intermediate tokens (exclude WETH, ETH, and the coin itself) - const intermediateAddresses: string[] = [] - allTokensInPath.forEach((addr) => { - if ( - addr !== weth.toLowerCase() && - addr !== coinAddress.toLowerCase() && - addr !== NATIVE_TOKEN_ADDRESS.toLowerCase() - ) { - intermediateAddresses.push(addr) - } - }) - - // Fetch coin info for all intermediate tokens - // getCoinInfo now has built-in caching, so this reuses any fetches from buildSwapPath - const intermediateTokens: CoinInfo[] = [] - for (const addr of intermediateAddresses) { - const info = await getCoinInfo(chainId, addr as Address) - if (info && info.symbol && info.type) { - intermediateTokens.push(info) - } - } - - // Add ETH option (same as WETH but with wrapping/unwrapping) - const ethPath = await buildSwapPath( - chainId, - isBuying ? NATIVE_TOKEN_ADDRESS : coinAddress, - isBuying ? coinAddress : NATIVE_TOKEN_ADDRESS - ) - options.push({ - token: ethInfo, - path: ethPath || mainPath, - isDirectSwap: !ethPath || ethPath.hops.length <= 1, - }) - - // Add WETH option (full main path) - options.push({ - token: wethInfo, - path: mainPath, - isDirectSwap: mainPath.hops.length <= 1, - }) - - // Add intermediate token options using subpaths - for (const token of intermediateTokens) { - const tokenAddr = token.address.toLowerCase() - let hopIndex = -1 - - // Find which hop this token appears in - for (let i = 0; i < mainPath.hops.length; i++) { - const hop = mainPath.hops[i] - if ( - hop.tokenIn.toLowerCase() === tokenAddr || - hop.tokenOut.toLowerCase() === tokenAddr - ) { - hopIndex = i - break - } - } - - if (hopIndex === -1) { - console.warn(`Token ${token.symbol} not found in main path`) - continue - } - - // Build subpath based on buy/sell direction - let subPath: SwapPath - - if (isBuying) { - // Buying: intermediate token -> coin - // Take hops from where intermediate token appears to the end - const hopsFromIntermediate = mainPath.hops.slice(hopIndex) - - // Check if the first hop starts with our token - if (hopsFromIntermediate[0].tokenIn.toLowerCase() === tokenAddr) { - subPath = { hops: hopsFromIntermediate, isOptimal: mainPath.isOptimal } - } else { - // Token is tokenOut of this hop, take from next hop - subPath = { - hops: mainPath.hops.slice(hopIndex + 1), - isOptimal: mainPath.isOptimal, - } - } - } else { - // Selling: coin -> intermediate token - // Take hops from start to where intermediate token appears - const hopsToIntermediate = mainPath.hops.slice(0, hopIndex + 1) - - // Check if the last hop ends with our token - const lastHop = hopsToIntermediate[hopsToIntermediate.length - 1] - if (lastHop.tokenOut.toLowerCase() === tokenAddr) { - subPath = { hops: hopsToIntermediate, isOptimal: mainPath.isOptimal } - } else { - // Token is tokenIn of this hop, don't include it - subPath = { - hops: mainPath.hops.slice(0, hopIndex), - isOptimal: mainPath.isOptimal, - } - } - } - - options.push({ - token, - path: subPath, - isDirectSwap: subPath.hops.length <= 1, - }) - } - - return { options, mainPath } -} diff --git a/packages/swap/src/buildSwapPath.ts b/packages/swap/src/buildSwapPath.ts deleted file mode 100644 index 8da1b649f..000000000 --- a/packages/swap/src/buildSwapPath.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { NATIVE_TOKEN_ADDRESS, WETH_ADDRESS } from '@buildeross/constants/addresses' -import { CHAIN_ID } from '@buildeross/types' -import { Address } from 'viem' - -import { getCoinInfo } from './getCoinInfo' -import { CoinInfo, SwapPath, SwapPathHop } from './types' - -const addrEq = (a?: Address, b?: Address) => - !!a && !!b && a.toLowerCase() === b.toLowerCase() - -/** - * Check if address is a valid payment currency (ETH or WETH) - */ -const isValidPaymentCurrency = (addr: Address, weth: Address): boolean => { - return addrEq(addr, weth) || addrEq(addr, NATIVE_TOKEN_ADDRESS) -} - -function hopFromPairingSide( - pairingSide: CoinInfo, - tokenIn: Address, - tokenOut: Address -): SwapPathHop { - // Only Zora coins and Clanker tokens have pool info - if (pairingSide.type !== 'zora-coin' && pairingSide.type !== 'clanker-token') { - throw new Error('Cannot create hop from non-pool coin') - } - - return { - tokenIn, - tokenOut, - poolId: - pairingSide.type === 'clanker-token' ? pairingSide.poolId : pairingSide.poolKeyHash, - fee: pairingSide.fee, - hooks: pairingSide.hooks, - tickSpacing: pairingSide.tickSpacing, - } -} - -/** - * Create a hop between two tokens if they are paired in either direction. - * We pick hop metadata from the token whose `pairedToken` points at the other. - */ -function makeDirectHop(a: CoinInfo, b: CoinInfo): SwapPathHop | null { - // Check if a has pairedToken and it matches b - if ( - (a.type === 'zora-coin' || a.type === 'clanker-token') && - addrEq(a.pairedToken, b.address) - ) { - return hopFromPairingSide(a, a.address, b.address) - } - // Check if b has pairedToken and it matches a - if ( - (b.type === 'zora-coin' || b.type === 'clanker-token') && - addrEq(b.pairedToken, a.address) - ) { - return hopFromPairingSide(b, a.address, b.address) - } - return null -} - -/** - * Build a coin-info chain from `start` to WETH by following pairedToken pointers. - * Example: Z2 -> C2 -> C1 -> WETH - */ -async function buildChainToWeth( - chainId: CHAIN_ID, - start: Address, - weth: Address, - maxSteps = 4 -): Promise { - const chain: CoinInfo[] = [] - const visited = new Set() - - let cur: Address = start - - for (let i = 0; i < maxSteps; i++) { - const info = await getCoinInfo(chainId, cur) - if (!info) return null - - const key = info.address.toLowerCase() - if (visited.has(key)) return null // cycle / bad data - visited.add(key) - - chain.push(info) - - if (addrEq(info.address, weth)) return chain - - // Only Zora coins and Clanker tokens have pairedToken - if (info.type !== 'zora-coin' && info.type !== 'clanker-token') return null - if (!info.pairedToken) return null - cur = info.pairedToken - } - - return null -} - -/** - * buildSwapPath constraint: - * - tokenIn === WETH/ETH OR tokenOut === WETH/ETH - * - ETH (NATIVE_TOKEN_ADDRESS) and WETH are both valid payment currencies - * - Routing still goes through WETH pools (executeSwap handles ETH wrapping) - */ -export async function buildSwapPath( - chainId: CHAIN_ID, - tokenIn: Address, - tokenOut: Address -): Promise { - const weth = WETH_ADDRESS[chainId] - if (!weth) return null - - const tokenInIsValid = isValidPaymentCurrency(tokenIn, weth) - const tokenOutIsValid = isValidPaymentCurrency(tokenOut, weth) - - // Enforce constraint: one side must be a valid payment currency (ETH or WETH) - if (!tokenInIsValid && !tokenOutIsValid) return null - - // No-op swap between payment currencies (ETH<->WETH or ETH<->ETH or WETH<->WETH) - if (tokenInIsValid && tokenOutIsValid) return { hops: [], isOptimal: true } - - // Determine the non-payment-currency side (the coin we're swapping) - // Note: If tokenIn is ETH/WETH, we're buying the coin (tokenOut) - // If tokenOut is ETH/WETH, we're selling the coin (tokenIn) - const nonPaymentCurrency = (tokenInIsValid ? tokenOut : tokenIn) as Address - const chainToWeth = await buildChainToWeth(chainId, nonPaymentCurrency, weth, 4) - if (!chainToWeth) return null - - // Convert chain to hops: - // - If swapping coin -> ETH/WETH: use chain order - // - If swapping ETH/WETH -> coin: reverse chain - const ordered = tokenOutIsValid ? chainToWeth : [...chainToWeth].reverse() - - const hops: SwapPathHop[] = [] - for (let i = 0; i < ordered.length - 1; i++) { - const a = ordered[i] - const b = ordered[i + 1] - const hop = makeDirectHop(a, b) - if (!hop) return null // inconsistent pairing info vs expected adjacency - hops.push(hop) - } - - // Important: If user selected ETH (not WETH), replace WETH with NATIVE_TOKEN_ADDRESS - // in the first or last hop to preserve their currency choice - if (hops.length > 0) { - const isTokenInEth = addrEq(tokenIn, NATIVE_TOKEN_ADDRESS) - const isTokenOutEth = addrEq(tokenOut, NATIVE_TOKEN_ADDRESS) - - // Replace WETH with ETH in the appropriate hop - if (isTokenInEth) { - // User is buying with ETH - replace WETH in first hop's tokenIn - const firstHop = hops[0] - if (addrEq(firstHop.tokenIn, weth)) { - hops[0] = { ...firstHop, tokenIn: NATIVE_TOKEN_ADDRESS } - } - } - - if (isTokenOutEth) { - // User is selling for ETH - replace WETH in last hop's tokenOut - const lastHop = hops[hops.length - 1] - if (addrEq(lastHop.tokenOut, weth)) { - hops[hops.length - 1] = { ...lastHop, tokenOut: NATIVE_TOKEN_ADDRESS } - } - } - } - - return { hops, isOptimal: true } -} diff --git a/packages/swap/src/getCoinInfo.ts b/packages/swap/src/getCoinInfo.ts deleted file mode 100644 index 2da9a404d..000000000 --- a/packages/swap/src/getCoinInfo.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { NATIVE_TOKEN_ADDRESS, WETH_ADDRESS } from '@buildeross/constants/addresses' -import { clankerTokenRequest, zoraCoinRequest } from '@buildeross/sdk/subgraph' -import { CHAIN_ID } from '@buildeross/types' -import { DEFAULT_CLANKER_TICK_SPACING, DYNAMIC_FEE_FLAG } from '@buildeross/utils/coining' -import { Address } from 'viem' - -import { CoinInfo } from './types' - -/** - * In-memory cache for coin info - * Key format: `${chainId}-${tokenAddress.toLowerCase()}` - */ -const coinInfoCache = new Map>() - -/** - * Clear the coin info cache (useful for testing or forcing refresh) - */ -export function clearCoinInfoCache(): void { - coinInfoCache.clear() -} - -/** - * Internal implementation that fetches coin info without caching - */ -async function getCoinInfoUncached( - chainId: CHAIN_ID, - tokenAddress: Address -): Promise { - const wethAddress = WETH_ADDRESS[chainId] - - // Check if it's native ETH - if (tokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { - return { - address: NATIVE_TOKEN_ADDRESS, - type: 'eth', - symbol: 'ETH', - name: 'Ethereum', - } - } - - // Check if it's WETH - if (tokenAddress.toLowerCase() === wethAddress?.toLowerCase()) { - return { - address: wethAddress, - type: 'weth', - symbol: 'WETH', - name: 'Wrapped Ether', - } - } - - // Try to fetch as ZoraCoin - try { - const coin = await zoraCoinRequest(tokenAddress, chainId) - if (coin) { - return { - address: coin.coinAddress as Address, - type: 'zora-coin', - symbol: coin.symbol, - name: coin.name, - pairedToken: coin.currency as Address, - poolKeyHash: coin.poolKeyHash as string, - hooks: coin.poolHooks as Address, - fee: BigInt(coin.poolFee), - tickSpacing: coin.poolTickSpacing, - } - } - } catch (e) { - // Not a ZoraCoin, try ClankerToken - } - - // Try to fetch as ClankerToken - try { - const token = await clankerTokenRequest(tokenAddress, chainId) - if (token) { - return { - address: token.tokenAddress as Address, - type: 'clanker-token', - symbol: token.tokenSymbol, - name: token.tokenName, - pairedToken: token.pairedToken as Address, - poolId: token.poolId as string, - hooks: token.poolHook as Address, - // ClankerTokens always use dynamic fees - fee: BigInt(DYNAMIC_FEE_FLAG), - tickSpacing: DEFAULT_CLANKER_TICK_SPACING, - } - } - } catch (e) { - // Not a ClankerToken either - } - - return null -} - -/** - * Fetches coin information from the subgraph with caching - * Caches results in memory to avoid duplicate API calls - */ -export async function getCoinInfo( - chainId: CHAIN_ID, - tokenAddress: Address -): Promise { - const cacheKey = `${chainId}-${tokenAddress.toLowerCase()}` - - // Check cache first - if (coinInfoCache.has(cacheKey)) { - return coinInfoCache.get(cacheKey)! - } - - // Fetch and cache the promise (not just the result) - // This prevents multiple concurrent requests for the same token - const promise = getCoinInfoUncached(chainId, tokenAddress) - coinInfoCache.set(cacheKey, promise) - - return promise -} diff --git a/packages/swap/src/index.ts b/packages/swap/src/index.ts index 7ffe7e903..74ae097ee 100644 --- a/packages/swap/src/index.ts +++ b/packages/swap/src/index.ts @@ -7,11 +7,8 @@ * - Execute swaps directly via Uniswap V4 Universal Router */ -export * from './buildSwapOptions' -export * from './buildSwapPath' export * from './errors' export * from './executeSwap' -export { clearCoinInfoCache, getCoinInfo } from './getCoinInfo' export * from './getPoolMaxSwapAmount' export * from './getQuoteFromUniswap' export * from './types' diff --git a/packages/swap/src/types.ts b/packages/swap/src/types.ts index 20a472cdb..1f70acee3 100644 --- a/packages/swap/src/types.ts +++ b/packages/swap/src/types.ts @@ -225,6 +225,16 @@ type ClankerTokenInfo = BaseCoinInfo & { */ export type CoinInfo = EthCoinInfo | WethCoinInfo | ZoraCoinInfo | ClankerTokenInfo +/** + * Minimal token info used for swap option selection. + */ +export type TokenInfo = { + address: Address + symbol: string + name: string + type: CoinType +} + /** * Pool key for Uniswap V4 */ @@ -249,3 +259,15 @@ export type PoolMaxSwapAmountResult = { /** Available liquidity */ liquidity: bigint } + +/** + * Swap option for a coin + */ +export interface SwapOption { + /** Token info including address, symbol, and type */ + token: TokenInfo + /** Swap path for this token <-> coin */ + path: SwapPath + /** True if this is a direct swap (single hop or no hop) */ + isDirectSwap: boolean +} 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/Accordion/AccordionItem.tsx b/packages/ui/src/Accordion/AccordionItem.tsx index 1c1b27f21..9c65de010 100644 --- a/packages/ui/src/Accordion/AccordionItem.tsx +++ b/packages/ui/src/Accordion/AccordionItem.tsx @@ -31,6 +31,12 @@ export const AccordionItem: React.FC<{ // Use controlled state if provided, otherwise use internal state const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen + React.useEffect(() => { + if (!isOpen) { + setAllowOverflow(false) + } + }, [isOpen]) + const handleToggle = () => { if (onToggle) { onToggle() 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/LikeButton/LikeButton.tsx b/packages/ui/src/LikeButton/LikeButton.tsx index 95287bac2..433058b1e 100644 --- a/packages/ui/src/LikeButton/LikeButton.tsx +++ b/packages/ui/src/LikeButton/LikeButton.tsx @@ -82,7 +82,7 @@ const LikeButton: React.FC = ({ justLikedTimeoutRef.current = setTimeout(() => { setJustLiked(false) - }, 3000) + }, 10000) }, [onLikeSuccess] ) 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} + ) : ( + onChange(e.target.value)} + aria-label={typeof inputLabel === 'string' ? inputLabel : 'Markdown editor'} + style={{ + width: '100%', + minHeight: 220, + resize: 'vertical', + borderRadius: 12, + border: '1px solid #d8d8d8', + padding: 12, + fontSize: 16, + fontFamily: 'inherit', + lineHeight: 1.5, + }} + /> + ) ) : ( 0n && !exceedsBalance && !exceedsPoolLimit + // Create dropdown options for payment tokens with balances const tokenOptions: SelectOption[] = useMemo(() => { return swapOptions.map((option) => { @@ -484,7 +491,7 @@ export const SwapWidget = ({ // Format label with balance if available let label: string if (balance !== undefined) { - const formattedBalance = parseFloat(formatEther(balance)).toFixed(4) + const formattedBalance = formatTokenAmount(formatEther(balance)) label = `${displayName} (${formattedBalance} ${token.symbol})` } else { label = displayName @@ -571,7 +578,7 @@ export const SwapWidget = ({ {inputBalance !== undefined && ( - Balance: {parseFloat(formatEther(inputBalance)).toFixed(4)} + Balance: {formatTokenAmount(formatEther(inputBalance))} )} @@ -614,7 +621,7 @@ export const SwapWidget = ({ !isLoadingPoolMax && ( - Pool limit: {parseFloat(formatEther(poolMaxAmount)).toFixed(4)}{' '} + Pool limit: {formatTokenAmount(formatEther(poolMaxAmount))}{' '} {isBuying ? swapOptions.find( (opt) => @@ -629,45 +636,44 @@ export const SwapWidget = ({ {/* Output Display */} - {amountOut && amountOut > 0n && !exceedsBalance && !exceedsPoolLimit && ( - - + + + You receive (estimated) - - {parseFloat(formatEther(amountOut)).toFixed(6)} + {userAddress && receiveTokenBalance !== undefined && ( + + Balance: {formatTokenAmount(formatEther(receiveTokenBalance))} + + )} + + + {hasValidQuote && amountOut ? formatTokenAmount(formatEther(amountOut)) : '--'} + + + + {receiveTokenSymbol} - + {hasValidQuote && outputUsdValue !== null && ( - {isBuying - ? symbol - : swapOptions.find( - (opt) => - opt.token.address.toLowerCase() === - selectedPaymentToken.toLowerCase() - )?.token.symbol || 'ETH'} + ≈ {formatPrice(outputUsdValue)} - {outputUsdValue !== null && ( - - ≈ {formatPrice(outputUsdValue)} - - )} - - - )} + )} + + {/* Pending Transaction Message */} {pendingTxHash && ( - Transaction pending... View transaction:{' '} + Transaction pending... View on{' '} - {truncateHex(pendingTxHash)} + Explorer diff --git a/packages/utils/src/numbers.test.ts b/packages/utils/src/numbers.test.ts new file mode 100644 index 000000000..b8329fe80 --- /dev/null +++ b/packages/utils/src/numbers.test.ts @@ -0,0 +1,21 @@ +import { assert, describe, it } from 'vitest' + +import { formatTokenAmount } from './numbers' + +describe('formatTokenAmount', () => { + it('shows at least two decimals for whole numbers', () => { + assert.equal(formatTokenAmount('42'), '42.00') + }) + + it('truncates (does not round) beyond max decimals', () => { + assert.equal(formatTokenAmount('1.23456789129'), '1.2345678912') + }) + + it('keeps values representable within max decimals', () => { + assert.equal(formatTokenAmount('0.0000000019'), '0.0000000019') + }) + + it('shows a small non-zero indicator for tiny values that collapse to zero', () => { + assert.equal(formatTokenAmount('0.00000000009'), '<0.000000001') + }) +}) diff --git a/packages/utils/src/numbers.ts b/packages/utils/src/numbers.ts index 06d1edb43..69f8f47f5 100644 --- a/packages/utils/src/numbers.ts +++ b/packages/utils/src/numbers.ts @@ -2,6 +2,15 @@ import BigNumber from 'bignumber.js' export type BigNumberish = BigNumber | bigint | string | number +type DecimalFormatMode = 'round' | 'truncate' + +type DecimalFormatOptions = { + minDecimals: number + maxDecimals: number + mode?: DecimalFormatMode + useGrouping?: boolean +} + const ONE_QUADRILLION = new BigNumber(1000000000000000) const ONE_TRILLION = new BigNumber(1000000000000) const ONE_BILLION = new BigNumber(1000000000) @@ -68,6 +77,71 @@ export function formatCryptoVal(cryptoVal: BigNumber | BigNumberish | string) { : formatCryptoValUnder100K(parsedamount) } +function formatDecimalValue( + value: BigNumber | BigNumberish | string, + { minDecimals, maxDecimals, mode = 'round', useGrouping = false }: DecimalFormatOptions +): string { + const raw = typeof value === 'string' ? value : value?.toString() + const parsed = new BigNumber(raw) + + if (!parsed.isFinite()) return '0' + + const roundingMode = + mode === 'truncate' ? BigNumber.ROUND_DOWN : BigNumber.ROUND_HALF_UP + const normalized = parsed.decimalPlaces(maxDecimals, roundingMode) + + let [integerPart, fractionalPart = ''] = normalized.toFixed(maxDecimals).split('.') + + fractionalPart = fractionalPart.replace(/0+$/, '') + if (fractionalPart.length < minDecimals) { + fractionalPart = fractionalPart.padEnd(minDecimals, '0') + } + + if (useGrouping) { + const sign = integerPart.startsWith('-') ? '-' : '' + const absInteger = sign ? integerPart.slice(1) : integerPart + integerPart = `${sign}${absInteger.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}` + } + + return fractionalPart.length > 0 ? `${integerPart}.${fractionalPart}` : integerPart +} + +export function formatTokenAmount(value: BigNumber | BigNumberish | string): string { + const minDecimals = 2 + const maxDecimals = 10 + const mode: DecimalFormatMode = 'truncate' + const useGrouping = false + + const formatted = formatDecimalValue(value, { + minDecimals, + maxDecimals, + mode, + useGrouping, + }) + + const raw = typeof value === 'string' ? value : value?.toString() + const parsed = new BigNumber(raw) + if (!parsed.isFinite()) return formatted + + const zeroFormatted = formatDecimalValue(0, { + minDecimals, + maxDecimals, + mode, + useGrouping, + }) + + const threshold = new BigNumber(10).pow(1 - maxDecimals) + if ( + parsed.isGreaterThan(0) && + parsed.isLessThan(threshold) && + formatted === zeroFormatted + ) { + return `<${threshold.toFixed(Math.max(0, maxDecimals - 1))}` + } + + return formatted +} + /** * Format price to human-readable format with appropriate precision * Uses adaptive precision: @@ -90,31 +164,32 @@ export function formatPrice(price: number | null | undefined): string { // For prices >= $1, use 2 decimals with thousand separators if (absValue >= 1) { - return `${sign}$${absValue.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + return `${sign}$${formatDecimalValue(absValue, { + minDecimals: 2, + maxDecimals: 2, + mode: 'round', + useGrouping: true, })}` } // For prices >= $0.01, use 4 decimals if (absValue >= 0.01) { - return `${sign}$${absValue.toFixed(4)}` + return `${sign}$${formatDecimalValue(absValue, { + minDecimals: 4, + maxDecimals: 4, + mode: 'round', + useGrouping: false, + })}` } // For very small prices (< $0.01), show up to 10 decimals - // This handles values like $0.0000000433 properly - let formatted = absValue.toFixed(10) - - // Trim trailing zeros but keep at least 2 decimals - formatted = formatted.replace(/(\.\d*?)0+$/, '$1') - if (formatted.endsWith('.')) { - formatted += '00' - } else { - const decimalPart = formatted.split('.')[1] - if (decimalPart && decimalPart.length < 2) { - formatted += '0' - } - } + // This handles values like $0.0000000433 properly. + const formatted = formatDecimalValue(absValue, { + minDecimals: 2, + maxDecimals: 10, + mode: 'round', + useGrouping: false, + }) return `${sign}$${formatted}` } diff --git a/packages/zord/src/assets/pencil.svg b/packages/zord/src/assets/pencil.svg new file mode 100644 index 000000000..f4615057d --- /dev/null +++ b/packages/zord/src/assets/pencil.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/packages/zord/src/assets/queue.svg b/packages/zord/src/assets/queue.svg new file mode 100644 index 000000000..c61869508 --- /dev/null +++ b/packages/zord/src/assets/queue.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/zord/src/elements/Spinner.css.ts b/packages/zord/src/elements/Spinner.css.ts index dc79e79e5..479bd5783 100644 --- a/packages/zord/src/elements/Spinner.css.ts +++ b/packages/zord/src/elements/Spinner.css.ts @@ -14,8 +14,8 @@ export const loadingSpinner = recipe({ display: 'block', content: "' '", borderRadius: '50%', - border: '3px solid #000', - borderColor: '#000 #000 #000 transparent', + border: '3px solid currentColor', + borderColor: 'currentColor currentColor currentColor transparent', animation: `${spinAnimation} 1.5s linear infinite`, }, }, diff --git a/packages/zord/src/icons.ts b/packages/zord/src/icons.ts index b63b9fc03..f732b4f8f 100644 --- a/packages/zord/src/icons.ts +++ b/packages/zord/src/icons.ts @@ -40,10 +40,12 @@ import Nft from './assets/nft.svg' import Noggles from './assets/noggles.svg' import Pause from './assets/pause.svg' import PauseTemplate from './assets/pause-template.svg' +import Pencil from './assets/pencil.svg' import Pin from './assets/pin.svg' import Play from './assets/play.svg' import Plus from './assets/plus.svg' import Question from './assets/question.svg' +import Queue from './assets/queue.svg' import Refresh from './assets/refresh.svg' import ResumeTemplate from './assets/resume-template.svg' import Sablier from './assets/sablier.svg' @@ -102,9 +104,11 @@ export const icons = { noggles: Noggles, pause: Pause, pauseTemplate: PauseTemplate, + pencil: Pencil, pin: Pin, play: Play, plus: Plus, + queue: Queue, question: Question, refresh: Refresh, resumeTemplate: ResumeTemplate, diff --git a/turbo.json b/turbo.json index d9f1f92f1..a387f2b67 100644 --- a/turbo.json +++ b/turbo.json @@ -9,7 +9,6 @@ "ETHERSCAN_API_KEY", "AI_GATEWAY_API_KEY", "AI_MODEL", - "NEXT_PUBLIC_COINING_ENABLED", "NEXT_PUBLIC_DISABLE_AI_SUMMARY", "NEXT_PUBLIC_DISABLE_TENDERLY_SIMULATION", "NEXT_PUBLIC_PINATA_GATEWAY",