diff --git a/apps/web/src/components/ContractButton.tsx b/apps/web/src/components/ContractButton.tsx index 1e7cf8d7..3baee555 100644 --- a/apps/web/src/components/ContractButton.tsx +++ b/apps/web/src/components/ContractButton.tsx @@ -6,7 +6,7 @@ import { useBridgeModal } from 'src/hooks/useBridgeModal' import { useChainStore } from 'src/stores/useChainStore' interface ContractButtonProps extends ButtonProps { - handleClick: () => void + handleClick: (e?: React.MouseEvent) => void } export const ContractButton = ({ @@ -28,11 +28,13 @@ export const ContractButton = ({ const handleSwitchNetwork = () => switchNetwork?.(appChain.id) - const handleClickWithValidation = () => { + const handleClickWithValidation = ( + e?: React.MouseEvent + ) => { if (!userAddress) return openConnectModal?.() if (canUserBridge && userBalance?.decimals === 0) return openBridgeModal() if (userChain?.id !== appChain.id) return handleSwitchNetwork() - handleClick() + handleClick(e) } return ( diff --git a/apps/web/src/modules/dao/components/Feed/DisplayPanel.tsx b/apps/web/src/components/DisplayPanel.tsx similarity index 100% rename from apps/web/src/modules/dao/components/Feed/DisplayPanel.tsx rename to apps/web/src/components/DisplayPanel.tsx diff --git a/apps/web/src/components/Home/RecentlyCreated.tsx b/apps/web/src/components/Home/RecentlyCreated.tsx index 81323c69..fd4a4885 100644 --- a/apps/web/src/components/Home/RecentlyCreated.tsx +++ b/apps/web/src/components/Home/RecentlyCreated.tsx @@ -7,19 +7,31 @@ import { homeSectionHeader, homeSectionWrapper } from 'src/styles/home.css' const RecentlyCreated: React.FC<{ children: ReactNode -}> = ({ children }) => { + isDashboard?: boolean +}> = ({ children, isDashboard }) => { const chain = useChainStore((x) => x.chain) return ( - - Recent DAOs on {chain.name} - + {isDashboard ? ( + + Explore + + ) : ( + + Recent DAOs on {chain.name} + + )} {children} diff --git a/apps/web/src/components/Icon/index.ts b/apps/web/src/components/Icon/index.ts index 7aebbe9d..7154ebc0 100644 --- a/apps/web/src/components/Icon/index.ts +++ b/apps/web/src/components/Icon/index.ts @@ -1 +1,2 @@ export * from './Icon' +export * from './icons' diff --git a/apps/web/src/constants/swrKeys.ts b/apps/web/src/constants/swrKeys.ts index 16a25b66..23b1337d 100644 --- a/apps/web/src/constants/swrKeys.ts +++ b/apps/web/src/constants/swrKeys.ts @@ -19,6 +19,7 @@ const SWR_KEYS = { DAO_FEED: 'dao-feed', MEMBERS: 'members', TOKEN_IMAGE: 'token-image', + DASHBOARD: 'dashboard', DYNAMIC: { MY_DAOS(str: string) { return `my-daos-${str}` diff --git a/apps/web/src/data/subgraph/fragments/CurrentAuction.graphql b/apps/web/src/data/subgraph/fragments/CurrentAuction.graphql new file mode 100644 index 00000000..8ad972bf --- /dev/null +++ b/apps/web/src/data/subgraph/fragments/CurrentAuction.graphql @@ -0,0 +1,12 @@ +fragment CurrentAuction on Auction { + endTime + highestBid { + amount + bidder + } + token { + name + image + tokenId + } +} diff --git a/apps/web/src/data/subgraph/queries/dashboardQuery.graphql b/apps/web/src/data/subgraph/queries/dashboardQuery.graphql new file mode 100644 index 00000000..86f124a8 --- /dev/null +++ b/apps/web/src/data/subgraph/queries/dashboardQuery.graphql @@ -0,0 +1,27 @@ +query dashboard($where: DAOTokenOwner_filter, $first: Int, $skip: Int) { + daotokenOwners(where: $where, first: $first, skip: $skip) { + dao { + ...DAO + contractImage + auctionConfig { + minimumBidIncrement + reservePrice + } + proposals( + where: { executed_not: true, canceled_not: true, vetoed_not: true } + first: 10 + skip: 0 + orderBy: proposalNumber + orderDirection: desc + ) { + ...Proposal + voteEnd + voteStart + expiresAt + } + currentAuction { + ...CurrentAuction + } + } + } +} diff --git a/apps/web/src/data/subgraph/requests/daoQuery.ts b/apps/web/src/data/subgraph/requests/daoQuery.ts index 77398e8f..1f16f1e1 100644 --- a/apps/web/src/data/subgraph/requests/daoQuery.ts +++ b/apps/web/src/data/subgraph/requests/daoQuery.ts @@ -11,11 +11,6 @@ export type MyDaosResponse = Array<{ chainId: CHAIN_ID }> -const DAOS_TO_EXCLUDE = [ - '0x9c8ff314c9bc7f6e59a9d9225fb22946427edc03', - '0x4b10701bfd7bfedc47d50562b76b436fbb5bdb3b', -] - export const myDaosRequest = async ( memberAddress: string ): Promise => { @@ -39,17 +34,12 @@ export const myDaosRequest = async ( return data .map((queries) => - queries.daotokenOwners - .map((x) => { - return x.dao - }) - .filter((dao) => !DAOS_TO_EXCLUDE.includes(dao.tokenAddress)) - .map((dao) => ({ - name: dao.name || '', - collectionAddress: dao.tokenAddress, - auctionAddress: dao?.auctionAddress || '', - chainId: queries.chainId, - })) + queries.daotokenOwners.map(({ dao }) => ({ + name: dao.name || '', + collectionAddress: dao.tokenAddress, + auctionAddress: dao?.auctionAddress || '', + chainId: queries.chainId, + })) ) .flat() .sort((a, b) => a.name.localeCompare(b.name)) diff --git a/apps/web/src/data/subgraph/requests/dashboardQuery.ts b/apps/web/src/data/subgraph/requests/dashboardQuery.ts new file mode 100644 index 00000000..edc36688 --- /dev/null +++ b/apps/web/src/data/subgraph/requests/dashboardQuery.ts @@ -0,0 +1,42 @@ +import * as Sentry from '@sentry/nextjs' + +import { PUBLIC_DEFAULT_CHAINS } from 'src/constants/defaultChains' +import { SDK } from 'src/data/subgraph/client' + +export const dashboardRequest = async (memberAddress: string) => { + try { + if (!memberAddress) throw new Error('No user address provided') + const data = await Promise.all( + PUBLIC_DEFAULT_CHAINS.map((chain) => + SDK.connect(chain.id) + .dashboard({ + where: { + owner: memberAddress.toLowerCase(), + }, + first: 30, + }) + .then((x) => ({ ...x, chainId: chain.id })) + ) + ) + + return data + .map((queries) => + queries.daotokenOwners.map(({ dao }) => ({ + ...dao, + name: dao.name || '', + tokenAddress: dao.tokenAddress, + auctionAddress: dao?.auctionAddress || '', + proposals: dao.proposals, + currentAuction: dao.currentAuction, + chainId: queries.chainId, + daoImage: dao.contractImage, + })) + ) + .flat() + .sort((a, b) => a.name.localeCompare(b.name)) + } catch (e: any) { + console.error(e) + Sentry.captureException(e) + await Sentry.flush(2000) + } +} diff --git a/apps/web/src/data/subgraph/sdk.generated.ts b/apps/web/src/data/subgraph/sdk.generated.ts index a7e6d8b4..ac83a9fc 100644 --- a/apps/web/src/data/subgraph/sdk.generated.ts +++ b/apps/web/src/data/subgraph/sdk.generated.ts @@ -1855,6 +1855,13 @@ export type AuctionBidFragment = { bidder: any } +export type CurrentAuctionFragment = { + __typename?: 'Auction' + endTime: any + highestBid?: { __typename?: 'AuctionBid'; amount: any; bidder: any } | null + token: { __typename?: 'Token'; name: string; image: string; tokenId: any } +} + export type DaoFragment = { __typename?: 'DAO' name: string @@ -2041,6 +2048,62 @@ export type DaoTokenOwnersQuery = { }> } +export type DashboardQueryVariables = Exact<{ + where?: InputMaybe + first?: InputMaybe + skip?: InputMaybe +}> + +export type DashboardQuery = { + __typename?: 'Query' + daotokenOwners: Array<{ + __typename?: 'DAOTokenOwner' + dao: { + __typename?: 'DAO' + contractImage: string + name: string + tokenAddress: any + auctionAddress: any + auctionConfig: { + __typename?: 'AuctionConfig' + minimumBidIncrement: any + reservePrice: any + } + proposals: Array<{ + __typename?: 'Proposal' + voteEnd: any + voteStart: any + expiresAt?: any | null + abstainVotes: number + againstVotes: number + calldatas?: string | null + description?: string | null + descriptionHash: any + executableFrom?: any | null + forVotes: number + proposalId: any + proposalNumber: number + proposalThreshold: any + proposer: any + quorumVotes: any + targets: Array + timeCreated: any + title?: string | null + values: Array + snapshotBlockNumber: any + transactionHash: any + dao: { __typename?: 'DAO'; governorAddress: any; tokenAddress: any } + }> + currentAuction?: { + __typename?: 'Auction' + endTime: any + highestBid?: { __typename?: 'AuctionBid'; amount: any; bidder: any } | null + token: { __typename?: 'Token'; name: string; image: string; tokenId: any } + } | null + } + }> +} + export type ExploreDaosPageQueryVariables = Exact<{ orderBy?: InputMaybe orderDirection?: InputMaybe @@ -2286,6 +2349,20 @@ export const AuctionBidFragmentDoc = gql` bidder } ` +export const CurrentAuctionFragmentDoc = gql` + fragment CurrentAuction on Auction { + endTime + highestBid { + amount + bidder + } + token { + name + image + tokenId + } + } +` export const DaoFragmentDoc = gql` fragment DAO on DAO { name @@ -2363,7 +2440,7 @@ export const TokenFragmentDoc = gql` ` export const ActiveAuctionsDocument = gql` query activeAuctions($first: Int!, $where: Auction_filter!) { - auctions(orderBy: endTime, orderDirection: asc, first: $first, where: $where) { + auctions(orderBy: endTime, orderDirection: desc, first: $first, where: $where) { ...Auction } } @@ -2470,6 +2547,38 @@ export const DaoTokenOwnersDocument = gql` } ${DaoFragmentDoc} ` +export const DashboardDocument = gql` + query dashboard($where: DAOTokenOwner_filter, $first: Int, $skip: Int) { + daotokenOwners(where: $where, first: $first, skip: $skip) { + dao { + ...DAO + contractImage + auctionConfig { + minimumBidIncrement + reservePrice + } + proposals( + where: { executed_not: true, canceled_not: true, vetoed_not: true } + first: 10 + skip: 0 + orderBy: proposalNumber + orderDirection: desc + ) { + ...Proposal + voteEnd + voteStart + expiresAt + } + currentAuction { + ...CurrentAuction + } + } + } + } + ${DaoFragmentDoc} + ${ProposalFragmentDoc} + ${CurrentAuctionFragmentDoc} +` export const ExploreDaosPageDocument = gql` query exploreDaosPage( $orderBy: Auction_orderBy @@ -2727,6 +2836,20 @@ export function getSdk( 'query' ) }, + dashboard( + variables?: DashboardQueryVariables, + requestHeaders?: Dom.RequestInit['headers'] + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request(DashboardDocument, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + 'dashboard', + 'query' + ) + }, exploreDaosPage( variables?: ExploreDaosPageQueryVariables, requestHeaders?: Dom.RequestInit['headers'] diff --git a/apps/web/src/layouts/DefaultLayout/Nav.tsx b/apps/web/src/layouts/DefaultLayout/Nav.tsx index e0778271..4c16430d 100644 --- a/apps/web/src/layouts/DefaultLayout/Nav.tsx +++ b/apps/web/src/layouts/DefaultLayout/Nav.tsx @@ -53,6 +53,9 @@ export const Nav = () => { + + + diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu.tsx index 799642d8..0c2b8841 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu.tsx @@ -376,6 +376,13 @@ export const NavMenu = () => { + + + + Dashboard + + + diff --git a/apps/web/src/modules/auction/components/Auction.css.ts b/apps/web/src/modules/auction/components/Auction.css.ts index 04fc48b2..95ff9ee6 100644 --- a/apps/web/src/modules/auction/components/Auction.css.ts +++ b/apps/web/src/modules/auction/components/Auction.css.ts @@ -168,6 +168,7 @@ export const auctionActionButtonVariants = styleVariants({ auctionActionButton, { width: '100%', background: '#F1F1F1', color: '#808080' }, ], + dashSettle: { borderRadius: '12px', width: '100%' }, }) export const bidForm = style({ @@ -278,3 +279,12 @@ export const tokenImage = style({ }, }) export const switcherBox = style({ width: '100%', maxWidth: '912px' }) +export const overflowEllipsis = style([ + { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + atoms({ + overflow: 'hidden', + }), +]) diff --git a/apps/web/src/modules/auction/components/CurrentAuction/Settle.tsx b/apps/web/src/modules/auction/components/CurrentAuction/Settle.tsx index 66ae3dce..cf688f13 100644 --- a/apps/web/src/modules/auction/components/CurrentAuction/Settle.tsx +++ b/apps/web/src/modules/auction/components/CurrentAuction/Settle.tsx @@ -12,6 +12,7 @@ import { ContractButton } from 'src/components/ContractButton' import { auctionAbi } from 'src/data/contract/abis' import { useDaoStore } from 'src/modules/dao' import { useChainStore } from 'src/stores/useChainStore' +import { AddressType } from 'src/typings' import { auctionActionButtonVariants } from '../Auction.css' @@ -19,26 +20,35 @@ interface SettleProps { isEnding: boolean collectionAddress?: string owner?: string | undefined + externalAuctionAddress?: AddressType + compact?: boolean } -export const Settle = ({ isEnding, owner }: SettleProps) => { +export const Settle = ({ + isEnding, + owner, + externalAuctionAddress, + compact = false, +}: SettleProps) => { const chain = useChainStore((x) => x.chain) - const addresses = useDaoStore((state) => state.addresses) + const addresses = useDaoStore?.((state) => state.addresses) || {} const { address } = useAccount() const isWinner = owner != undefined && address == owner + const auctionAddress = externalAuctionAddress || addresses?.auction + const { data: paused } = useContractRead({ - enabled: !!addresses?.auction, - address: addresses?.auction, + enabled: !!auctionAddress, + address: auctionAddress, chainId: chain.id, abi: auctionAbi, functionName: 'paused', }) const { config, error } = usePrepareContractWrite({ - enabled: !!addresses?.auction, - address: addresses?.auction, + enabled: !!auctionAddress, + address: auctionAddress, abi: auctionAbi, functionName: paused ? 'settleAuction' : 'settleCurrentAndCreateNewAuction', }) @@ -73,7 +83,15 @@ export const Settle = ({ isEnding, owner }: SettleProps) => { if (settling) { return ( - @@ -84,7 +102,12 @@ export const Settle = ({ isEnding, owner }: SettleProps) => { {isWinner ? 'Claim NFT' : 'Start next auction'} diff --git a/apps/web/src/modules/dao/components/DaoFeed/DaoFeed.tsx b/apps/web/src/modules/dao/components/DaoFeed/DaoFeed.tsx index f8d53685..8802ad44 100644 --- a/apps/web/src/modules/dao/components/DaoFeed/DaoFeed.tsx +++ b/apps/web/src/modules/dao/components/DaoFeed/DaoFeed.tsx @@ -41,7 +41,11 @@ const DaoFeedContent = ({ ) } -export const DaoFeed = () => { +type DaoFeedProps = { + isDashboard?: boolean +} + +export const DaoFeed = ({ isDashboard }: DaoFeedProps) => { const chain = useChainStore((x) => x.chain) const { data: featuredDaos, error } = useSWR( [SWR_KEYS.FEATURED, chain.id], @@ -59,7 +63,7 @@ export const DaoFeed = () => { if (!isLoading && !hasError && !hasThreeDaos) return null return ( - + { + const router = useRouter() + const Paused = icons.pause + + const handleSelectAuction = () => { + router.push(`/dao/${currentChainSlug}/${tokenAddress}`) + } + return ( + + + + + + + + {chainIcon && ( + + )} + + {chainName} + + + {name} + + + + + + Current Bid + + + N/A + + + + + Ends In + + + N/A + + + + + + + + Auctions are paused. + + + + + See activity + + + + + ) +} diff --git a/apps/web/src/modules/dashboard/BidActionButton.tsx b/apps/web/src/modules/dashboard/BidActionButton.tsx new file mode 100644 index 00000000..7f4e65e6 --- /dev/null +++ b/apps/web/src/modules/dashboard/BidActionButton.tsx @@ -0,0 +1,130 @@ +import * as Sentry from '@sentry/nextjs' +import { Box, Button } from '@zoralabs/zord' +import React, { useState } from 'react' +import { Address, parseEther } from 'viem' +import { useNetwork } from 'wagmi' +import { prepareWriteContract, waitForTransaction, writeContract } from 'wagmi/actions' + +import { ContractButton } from 'src/components/ContractButton' +import { auctionAbi } from 'src/data/contract/abis' +import { AddressType } from 'src/typings' +import { maxChar } from 'src/utils/helpers' + +import { useMinBidIncrement } from '../auction' +import { Settle } from '../auction/components/CurrentAuction/Settle' +import { DashboardDaoProps } from './Dashboard' +import { bidButton, bidForm, bidInput, minButton } from './dashboard.css' + +export const BidActionButton = ({ + chainId, + auctionConfig, + currentAuction, + isEnded, + auctionAddress, + isOver, +}: DashboardDaoProps & { + userAddress: AddressType + isOver: boolean + isEnded: boolean +}) => { + const { minimumBidIncrement, reservePrice } = auctionConfig + const { highestBid } = currentAuction || {} + const { chain: wagmiChain } = useNetwork() + const { minBidAmount } = useMinBidIncrement({ + highestBid: highestBid?.amount ? BigInt(highestBid?.amount) : undefined, + reservePrice: BigInt(reservePrice), + minBidIncrement: BigInt(minimumBidIncrement), + }) + + const [bidAmount, setBidAmount] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const isMinBid = Number(bidAmount) >= minBidAmount + + const isValidBid = bidAmount && isMinBid + + const isValidChain = wagmiChain?.id === chainId + + const handleCreateBid = async () => { + if (!isMinBid || !bidAmount || isLoading) return + + try { + setIsLoading(true) + + const config = await prepareWriteContract({ + abi: auctionAbi, + address: auctionAddress as Address, + functionName: 'createBid', + args: [BigInt(currentAuction?.token?.tokenId)], + value: parseEther(bidAmount.toString()), + }) + + const tx = await writeContract(config) + if (tx?.hash) await waitForTransaction({ hash: tx.hash }) + setBidAmount('') + } catch (error) { + console.error(error) + Sentry.captureException(error) + await Sentry.flush(2000) + } finally { + setIsLoading(false) + } + } + + if (isEnded || isOver) { + return ( + + ) + } + + return ( + <> +
+ + setBidAmount(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> + + +
+ { + handleCreateBid() + }} + position={'relative'} + className={bidButton} + > + Bid + + + ) +} diff --git a/apps/web/src/modules/dashboard/DaoAuctionCard.tsx b/apps/web/src/modules/dashboard/DaoAuctionCard.tsx new file mode 100644 index 00000000..7307f840 --- /dev/null +++ b/apps/web/src/modules/dashboard/DaoAuctionCard.tsx @@ -0,0 +1,163 @@ +import { Box, Flex, Text } from '@zoralabs/zord' +import dayjs from 'dayjs' +import Image from 'next/image' +import { useRouter } from 'next/router' +import React, { useState } from 'react' +import { formatEther } from 'viem' +import { useContractEvent } from 'wagmi' + +import { PUBLIC_ALL_CHAINS } from 'src/constants/defaultChains' +import { auctionAbi } from 'src/data/contract/abis' +import { useCountdown, useIsMounted } from 'src/hooks' +import { AddressType } from 'src/typings' + +import { overflowEllipsis } from '../auction/components/Auction.css' +import { AuctionPaused } from './AuctionPaused' +import { BidActionButton } from './BidActionButton' +import { DashboardDaoProps } from './Dashboard' +import { + auctionCardBrand, + bidBox, + daoAvatar, + daoAvatarBox, + daoTokenName, + outerAuctionCard, + stats, + statsBox, +} from './dashboard.css' + +type DaoAuctionCardProps = DashboardDaoProps & { + userAddress: AddressType + handleMutate: () => void +} + +export const DaoAuctionCard = (props: DaoAuctionCardProps) => { + const { currentAuction, chainId, auctionAddress, handleMutate, tokenAddress } = props + const { name: chainName, icon: chainIcon } = + PUBLIC_ALL_CHAINS.find((chain) => chain.id === chainId) ?? {} + const router = useRouter() + const { endTime } = currentAuction ?? {} + + const [isEnded, setIsEnded] = useState(false) + + const isOver = !!endTime ? dayjs.unix(Date.now() / 1000) >= dayjs.unix(endTime) : true + const onEnd = () => { + setIsEnded(true) + } + + useContractEvent({ + address: auctionAddress, + abi: auctionAbi, + eventName: 'AuctionCreated', + chainId, + listener: async () => { + setTimeout(() => { + handleMutate() + }, 3000) + }, + }) + useContractEvent({ + address: auctionAddress, + abi: auctionAbi, + eventName: 'AuctionBid', + chainId, + listener: async () => { + setTimeout(() => { + handleMutate() + }, 3000) + }, + }) + const handleSelectAuction = () => { + router.push(`/dao/${currentChainSlug}/${tokenAddress}`) + } + const currentChainSlug = PUBLIC_ALL_CHAINS.find((chain) => chain.id === chainId)?.slug + + if (!currentAuction) { + return ( + + ) + } + + const bidText = currentAuction.highestBid?.amount + ? `${formatEther(BigInt(currentAuction.highestBid.amount))} ETH` + : 'N/A' + + return ( + + + + + + + + {chainIcon && ( + + )} + + {chainName} + + + {currentAuction.token.name} + + + + + + Current Bid + + + {bidText} + + + + + Ends In + + + + + + + + + ) +} + +const DashCountdown = ({ + endTime, + onEnd, + isOver, +}: { + endTime: string | null + onEnd: () => void + isOver: boolean +}) => { + const { countdownString } = useCountdown(Number(endTime), onEnd) + const isMounted = useIsMounted() + const countdownText = !endTime || isOver ? 'N/A' : countdownString + if (!isMounted) return null + return ( + + {countdownText} + + ) +} diff --git a/apps/web/src/modules/dashboard/DaoProposalCard.tsx b/apps/web/src/modules/dashboard/DaoProposalCard.tsx new file mode 100644 index 00000000..a73abe0b --- /dev/null +++ b/apps/web/src/modules/dashboard/DaoProposalCard.tsx @@ -0,0 +1,87 @@ +import { Flex, Text } from '@zoralabs/zord' +import Link from 'next/link' + +import { ProposalState } from 'src/data/contract/requests/getProposalState' +import { ProposalFragment } from 'src/data/subgraph/sdk.generated' +import { AddressType, CHAIN_ID } from 'src/typings' + +import { ProposalStatus } from '../proposal/components/ProposalStatus' + +type DaoProposalCardProps = ProposalFragment & { + chainId: CHAIN_ID + tokenAddress: AddressType + proposalState: ProposalState + currentChainSlug?: string +} + +export const DaoProposalCard = ({ + title, + proposalNumber, + tokenAddress, + chainId, + proposalState, + voteEnd, + voteStart, + expiresAt, + currentChainSlug, +}: DaoProposalCardProps) => { + return ( + + + + {proposalNumber} + + + + {title} + + + + + + {proposalNumber} + + + + + + ) +} diff --git a/apps/web/src/modules/dashboard/DaoProposals.tsx b/apps/web/src/modules/dashboard/DaoProposals.tsx new file mode 100644 index 00000000..b65fe993 --- /dev/null +++ b/apps/web/src/modules/dashboard/DaoProposals.tsx @@ -0,0 +1,83 @@ +import { Box, Button, Flex, Text } from '@zoralabs/zord' +import { getFetchableUrl } from 'ipfs-service' +import Image from 'next/image' +import Link from 'next/link' +import { useRouter } from 'next/router' +import React from 'react' + +import { Avatar } from 'src/components/Avatar' +import { PUBLIC_ALL_CHAINS } from 'src/constants/defaultChains' + +import { DaoProposalCard } from './DaoProposalCard' +import { DashboardDaoProps } from './Dashboard' +import { daoName } from './dashboard.css' + +export const DaoProposals = ({ + daoImage, + tokenAddress, + name, + proposals, + chainId, +}: DashboardDaoProps) => { + const daoImageSrc = React.useMemo(() => { + return daoImage ? getFetchableUrl(daoImage) : null + }, [daoImage]) + + const router = useRouter() + + const currentChainSlug = PUBLIC_ALL_CHAINS.find((chain) => chain.id === chainId)?.slug + + return ( + + + + + {daoImageSrc ? ( + + + + ) : ( + + + + )} + + {name} + + + + + + + + {proposals.map((proposal) => ( + + ))} + + + ) +} diff --git a/apps/web/src/modules/dashboard/DashConnect.tsx b/apps/web/src/modules/dashboard/DashConnect.tsx new file mode 100644 index 00000000..c8401eae --- /dev/null +++ b/apps/web/src/modules/dashboard/DashConnect.tsx @@ -0,0 +1,24 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { Button, Flex, Text } from '@zoralabs/zord' + +import { DaoFeed } from '../dao' +import { DashPage } from './DashboardLayout' + +export const DashConnect = () => { + const { openConnectModal } = useConnectModal() + return ( + + + You must connect your wallet to see your DAOs + + + + + ) +} diff --git a/apps/web/src/modules/dashboard/Dashboard.tsx b/apps/web/src/modules/dashboard/Dashboard.tsx new file mode 100644 index 00000000..7c7f583b --- /dev/null +++ b/apps/web/src/modules/dashboard/Dashboard.tsx @@ -0,0 +1,186 @@ +import { Box, Flex, Text } from '@zoralabs/zord' +import React, { useMemo, useState } from 'react' +import useSWR from 'swr' +import { useAccount } from 'wagmi' + +import { DisplayPanel } from 'src/components/DisplayPanel' +import SWR_KEYS from 'src/constants/swrKeys' +import { + ProposalState, + getProposalState, +} from 'src/data/contract/requests/getProposalState' +import { dashboardRequest } from 'src/data/subgraph/requests/dashboardQuery' +import { + CurrentAuctionFragment, + DaoFragment, + ProposalFragment, +} from 'src/data/subgraph/sdk.generated' +import { CHAIN_ID } from 'src/typings' + +import { DaoFeed } from '../dao' +import { DaoAuctionCard } from './DaoAuctionCard' +import { DaoProposals } from './DaoProposals' +import { DashConnect } from './DashConnect' +import { DashPage, DashboardLayout } from './DashboardLayout' +import { AuctionCardSkeleton, DAOCardSkeleton, ProposalCardSkeleton } from './Skeletons' + +const ACTIVE_PROPOSAL_STATES = [ + ProposalState.Active, + ProposalState.Pending, + ProposalState.Queued, +] + +export type DashboardDaoProps = DaoFragment & { + chainId: CHAIN_ID + daoImage: string + auctionConfig: { + minimumBidIncrement: string + reservePrice: string + } + proposals: (ProposalFragment & { proposalState: ProposalState })[] + currentAuction?: CurrentAuctionFragment | null +} + +const fetchDaoProposalState = async (dao: DashboardDaoProps) => { + const proposals = await Promise.all( + dao.proposals.map(async (proposal) => { + const proposalState = await getProposalState( + dao.chainId, + proposal.dao.governorAddress, + proposal.proposalId + ) + return { ...proposal, proposalState: proposalState } + }) + ) + return { + ...dao, + proposals: proposals.filter((proposal) => + ACTIVE_PROPOSAL_STATES.includes(proposal.proposalState) + ), + } +} + +const fetchDashboardData = async (address: string) => { + try { + const userDaos = (await dashboardRequest(address)) as unknown as DashboardDaoProps[] + if (!userDaos) throw new Error('Dashboard DAO query returned undefined') + const resolved = await Promise.all(userDaos.map(fetchDaoProposalState)) + return resolved + } catch (error) { + throw new Error('Error fetching dashboard data') + } +} + +const Dashboard = () => { + const { address } = useAccount() + + const { data, error, isValidating, mutate } = useSWR( + [`${SWR_KEYS.DASHBOARD}:${address}`], + address ? () => fetchDashboardData(address) : null, + { revalidateOnFocus: false } + ) + + const [mutating, setMutating] = useState(false) + + const proposalList = useMemo(() => { + if (!data) return null + const hasLiveProposals = data.some((dao) => dao.proposals.length) + + if (!hasLiveProposals) + return ( + + + No Active Proposals + + + Currently, none of your DAOs have proposals that are in active, queue, or + pending states. Check back later! + + + ) + + return data + .filter((dao) => dao.proposals.length) + .map((dao) => ) + }, [data]) + + if (error) { + return ( + + + Something went wrong. + + + + ) + } + if (isValidating && !mutating) { + return ( + ( + + ))} + daoProposals={ + + + {Array.from({ length: 2 }).map((_, i) => ( + + ))} + + } + /> + ) + } + if (!address) { + return + } + if (!data?.length) { + return ( + + It looks like you haven’t joined any DAOs yet. + + + ) + } + + const handleMutate = async () => { + setMutating(true) + await mutate(() => fetchDashboardData(address)) + setMutating(false) + } + + return ( + ( + + ))} + daoProposals={proposalList} + /> + ) +} + +export default Dashboard diff --git a/apps/web/src/modules/dashboard/DashboardLayout.tsx b/apps/web/src/modules/dashboard/DashboardLayout.tsx new file mode 100644 index 00000000..8d4e2dcd --- /dev/null +++ b/apps/web/src/modules/dashboard/DashboardLayout.tsx @@ -0,0 +1,48 @@ +import { Box, Flex, Text } from '@zoralabs/zord' +import React, { ReactNode } from 'react' + +import { Meta } from 'src/components/Meta' + +export const DashboardLayout = ({ + auctionCards, + daoProposals, +}: { + auctionCards: ReactNode + daoProposals?: ReactNode +}) => { + return ( + + + + DAOs + + {auctionCards} + + + + Proposals + + {daoProposals} + + + ) +} + +export const DashPage = ({ children }: { children: ReactNode }) => { + return ( + + + + + Dashboard + + {children} + + + ) +} diff --git a/apps/web/src/modules/dashboard/Skeletons.tsx b/apps/web/src/modules/dashboard/Skeletons.tsx new file mode 100644 index 00000000..649337ab --- /dev/null +++ b/apps/web/src/modules/dashboard/Skeletons.tsx @@ -0,0 +1,41 @@ +import { Box } from '@zoralabs/zord' +import React from 'react' + +import { + auctionCardSkeleton, + daoCardSkeleton, + proposalCardSkeleton, +} from './dashboard.css' + +export const AuctionCardSkeleton = () => { + return ( + + ) +} + +export const DAOCardSkeleton = () => { + return ( + + ) +} + +export const ProposalCardSkeleton = () => { + return ( + + ) +} diff --git a/apps/web/src/modules/dashboard/dashboard.css.ts b/apps/web/src/modules/dashboard/dashboard.css.ts new file mode 100644 index 00000000..df92851b --- /dev/null +++ b/apps/web/src/modules/dashboard/dashboard.css.ts @@ -0,0 +1,190 @@ +import { style } from '@vanilla-extract/css' +import { atoms, theme } from '@zoralabs/zord' + +import { skeletonAnimation } from 'src/styles/animations.css' + +export const outerAuctionCard = style([ + { + '@media': { + 'screen and (max-width: 768px)': { + gap: '16px', + }, + }, + }, + atoms({ + width: '100%', + alignItems: { '@initial': 'flex-start', '@768': 'center' }, + flexDirection: { '@initial': 'column', '@768': 'row' }, + marginBottom: 'x6', + borderColor: 'border', + borderStyle: 'solid', + borderRadius: 'curved', + borderWidth: 'normal', + py: { '@initial': 'x4', '@768': 'x3' }, + px: { '@initial': 'x2', '@768': 'x6' }, + }), +]) + +export const daoTokenName = style([ + { + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + width: '200px', + '@media': { + 'screen and (max-width: 912px)': { + width: '150px', + }, + 'screen and (max-width:768px)': { + width: '220px', + }, + }, + }, + atoms({ + overflow: 'hidden', + fontWeight: 'label', + fontSize: 20, + }), +]) + +export const daoAvatar = style([ + atoms({ + objectFit: 'contain', + borderRadius: 'curved', + height: 'x16', + width: 'x16', + }), +]) +export const daoAvatarBox = style({ + marginRight: '24px', + width: '64px', + height: '64px', + '@media': { + 'screen and (max-width: 768px)': { + marginRight: '16px', + }, + }, +}) + +export const auctionCardBrand = style([ + { + width: '40%', + '@media': { + 'screen and (max-width: 912px)': { + width: '35%', + }, + 'screen and (max-width: 768px)': { + width: '100%', + }, + }, + }, + atoms({ + cursor: 'pointer', + alignItems: 'center', + }), +]) + +export const stats = style({ + width: '50%', +}) +export const statsBox = style({ + width: '30%', + '@media': { + 'screen and (max-width: 768px)': { + width: '80%', + }, + 'screen and (max-width: 484px)': { + width: '100%', + }, + }, +}) + +export const bidBox = style([ + { + width: '250px', + '@media': { + 'screen and (max-width: 768px)': { + width: '100%', + marginLeft: 0, + }, + }, + }, + atoms({ + marginLeft: 'auto', + }), +]) + +export const bidForm = style({ + width: '75%', +}) + +export const bidButton = style({ + width: '25%', +}) +export const bidInput = style([ + { + outline: 'none', + boxSizing: 'border-box', + transition: '.3s', + selectors: { + '&::placeholder': { + color: theme.colors.tertiary, + }, + }, + }, + atoms({ + borderWidth: 'none', + borderRadius: 'curved', + height: 'x12', + width: '100%', + paddingLeft: 'x4', + paddingRight: 'x11', + backgroundColor: 'background2', + fontSize: 14, + lineHeight: 24, + }), +]) + +export const feed = style([ + atoms({ + m: 'auto', + }), + { + maxWidth: 912, + }, +]) + +export const auctionCardSkeleton = style({ + animation: skeletonAnimation, + height: '96px', +}) + +export const daoCardSkeleton = style({ + animation: skeletonAnimation, + height: '52px', + width: '175px', +}) + +export const proposalCardSkeleton = style({ + animation: skeletonAnimation, + height: '88px', +}) +export const minButton = style({ + minWidth: 'fit-content', + fontWeight: 500, + paddingLeft: 0, + paddingRight: 0, + top: 0, + right: 0, + bottom: 0, +}) +export const daoName = style({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: '250px', + '@media': { + 'screen and (max-width: 484px)': { + maxWidth: '200px', + }, + }, +}) diff --git a/apps/web/src/modules/dashboard/index.ts b/apps/web/src/modules/dashboard/index.ts new file mode 100644 index 00000000..f3598477 --- /dev/null +++ b/apps/web/src/modules/dashboard/index.ts @@ -0,0 +1 @@ +export * from './Dashboard' diff --git a/apps/web/src/modules/proposal/components/ProposalCard.tsx b/apps/web/src/modules/proposal/components/ProposalCard.tsx index 00fdf59f..ef07be1d 100644 --- a/apps/web/src/modules/proposal/components/ProposalCard.tsx +++ b/apps/web/src/modules/proposal/components/ProposalCard.tsx @@ -23,7 +23,6 @@ type ProposalCardProps = { } export const ProposalCard: React.FC = ({ - proposalId, title, proposalNumber, state, diff --git a/apps/web/src/pages/api/dao/[network]/[token]/[tokenId].ts b/apps/web/src/pages/api/dao/[network]/[token]/[tokenId].ts index 67199db8..9986dc94 100644 --- a/apps/web/src/pages/api/dao/[network]/[token]/[tokenId].ts +++ b/apps/web/src/pages/api/dao/[network]/[token]/[tokenId].ts @@ -7,6 +7,7 @@ import { AddressType } from 'src/typings' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { network, token, tokenId } = req.query + const chain = PUBLIC_DEFAULT_CHAINS.find((x) => x.slug == network) if (!chain) { diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx new file mode 100644 index 00000000..1a3ee281 --- /dev/null +++ b/apps/web/src/pages/dashboard.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +import { DefaultLayout } from 'src/layouts/DefaultLayout' +import { LayoutWrapper } from 'src/layouts/LayoutWrapper' +import Dashboard from 'src/modules/dashboard/Dashboard' + +const DashboardPage = () => { + return ( + + + + + + ) +} + +export default DashboardPage diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 132c1df8..ec46bccf 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -1,5 +1,6 @@ import { Stack } from '@zoralabs/zord' import React from 'react' +import { useAccount } from 'wagmi' import Everything from 'src/components/Home/Everything' import FAQ from 'src/components/Home/FAQ' @@ -9,30 +10,46 @@ import Twitter from 'src/components/Home/Twitter' import VisitAlternate from 'src/components/Home/VisitAlternate' import { Meta } from 'src/components/Meta' import { AuctionFragment } from 'src/data/subgraph/sdk.generated' -import { getHomeLayout } from 'src/layouts/HomeLayout' +import { DefaultLayout } from 'src/layouts/DefaultLayout' +import { HomeLayout } from 'src/layouts/HomeLayout' +import { LayoutWrapper } from 'src/layouts/LayoutWrapper' import { DaoFeed } from 'src/modules/dao' +import Dashboard from 'src/modules/dashboard/Dashboard' import { NextPageWithLayout } from './_app' export type DaoProps = AuctionFragment['dao'] const HomePage: NextPageWithLayout = () => { + const { address } = useAccount() + + if (address) { + return ( + + + + + + ) + } return ( - <> - - - - - - - - - - - + + + + + + + + + + + + + + ) } -HomePage.getLayout = getHomeLayout +// HomePage.getLayout = getHomeLayout export default HomePage diff --git a/apps/web/src/utils/helpers.ts b/apps/web/src/utils/helpers.ts index 1bc5dc31..fc4340fe 100644 --- a/apps/web/src/utils/helpers.ts +++ b/apps/web/src/utils/helpers.ts @@ -269,3 +269,10 @@ export function unpackOptionalArray( } return array } + +export function maxChar(str: string, maxLength: number) { + if (str.length <= maxLength) { + return str + } + return str.slice(0, maxLength) + '...' +}