From 437c3f6a95be9505d6b1d1fe81d0844075bbe2ec Mon Sep 17 00:00:00 2001 From: Willy Ogorzaly Date: Thu, 27 Nov 2025 12:08:00 -0300 Subject: [PATCH 1/6] Add Nounish Auctions fidget --- .../community/nouns-dao/NounishAuctions.tsx | 805 ++++++++++++++++++ src/fidgets/index.ts | 2 + 2 files changed, 807 insertions(+) create mode 100644 src/fidgets/community/nouns-dao/NounishAuctions.tsx diff --git a/src/fidgets/community/nouns-dao/NounishAuctions.tsx b/src/fidgets/community/nouns-dao/NounishAuctions.tsx new file mode 100644 index 000000000..d70dd1627 --- /dev/null +++ b/src/fidgets/community/nouns-dao/NounishAuctions.tsx @@ -0,0 +1,805 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { formatEther, isAddress, parseEther } from "viem"; +import { base, mainnet, optimism } from "viem/chains"; +import { + useAccount, + useConnect, + useSwitchChain, + useWriteContract, +} from "wagmi"; +import { waitForTransactionReceipt } from "wagmi/actions"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/common/components/atoms/card"; +import { Input } from "@/common/components/atoms/input"; +import { Button } from "@/common/components/atoms/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/common/components/atoms/select"; +import { Separator } from "@/common/components/atoms/separator"; +import { DAO_OPTIONS } from "@/constants/basedDaos"; +import { mergeClasses } from "@/common/lib/utils/mergeClasses"; +import { FidgetArgs, FidgetModule, FidgetProperties, FidgetSettingsStyle } from "@/common/fidgets"; +import { defaultStyleFields, WithMargin } from "@/fidgets/helpers"; +import { wagmiConfig } from "@/common/providers/Wagmi"; +import { DaoSelector } from "@/common/components/molecules/DaoSelector"; + +const BUILDER_SUBGRAPH_ENDPOINTS: Record = { + base: + "https://api.goldsky.com/api/public/project_clkk1ucdyf6ak38svcatie9tf/subgraphs/nouns-builder-base-mainnet/stable/gn", + mainnet: "https://api.thegraph.com/subgraphs/name/neokry/nouns-builder-mainnet", + optimism: + "https://api.thegraph.com/subgraphs/name/neokry/noun-builder-optimism-mainnet", +}; + +const SUPPORTED_NETWORKS = [ + { label: "Base", value: "base", chain: base }, + { label: "Ethereum", value: "mainnet", chain: mainnet }, + { label: "Optimism", value: "optimism", chain: optimism }, +] as const; + +type SupportedNetwork = (typeof SUPPORTED_NETWORKS)[number]["value"]; + +type DaoOption = { + name: string; + contract: string; + graphUrl: string; + icon?: string; +}; + +type BuilderAuction = { + id: string; + endTime: number; + startTime: number; + settled: boolean; + tokenId: string; + imageUrl?: string; + highestBidAmount?: string; + highestBidder?: string; + winningBidAmount?: string; + winningBidder?: string; +}; + +type BuilderDao = { + auctionAddress?: `0x${string}`; + name?: string; +}; + +const ACTIVE_AUCTION_QUERY = /* GraphQL */ ` + query ActiveAuction($dao: ID!) { + dao(id: $dao) { + auctionAddress + name + } + auctions( + where: { dao: $dao, settled: false } + orderBy: startTime + orderDirection: desc + first: 1 + ) { + id + endTime + startTime + settled + token { + tokenId + image + } + highestBid { + amount + bidder + } + winningBid { + amount + bidder + } + } + } +`; + +const PAST_AUCTIONS_QUERY = /* GraphQL */ ` + query PastAuctions($dao: ID!, $first: Int!, $skip: Int!) { + auctions( + where: { dao: $dao, settled: true } + orderBy: endTime + orderDirection: desc + first: $first + skip: $skip + ) { + id + endTime + token { + tokenId + image + } + winningBid { + amount + bidder + } + highestBid { + amount + bidder + } + } + } +`; + +const auctionAbi = [ + { + type: "function", + stateMutability: "view", + name: "auction", + inputs: [], + outputs: [ + { name: "tokenId", type: "uint256" }, + { name: "highestBid", type: "uint256" }, + { name: "highestBidder", type: "address" }, + { name: "startTime", type: "uint40" }, + { name: "endTime", type: "uint40" }, + { name: "settled", type: "bool" }, + ], + }, + { + type: "function", + stateMutability: "payable", + name: "createBid", + inputs: [{ name: "_tokenId", type: "uint256" }], + outputs: [], + }, + { + type: "function", + stateMutability: "nonpayable", + name: "settleAuction", + inputs: [], + outputs: [], + }, + { + type: "function", + stateMutability: "nonpayable", + name: "settleCurrentAndCreateNewAuction", + inputs: [], + outputs: [], + }, + { + type: "function", + stateMutability: "view", + name: "paused", + inputs: [], + outputs: [{ name: "", type: "bool" }], + }, +] as const; + +async function runGraphQuery(endpoint: string, query: string, variables: Record): Promise { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error(`GraphQL request failed with status ${response.status}`); + } + + const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }; + + if (json.errors?.length) { + throw new Error(json.errors.map((error) => error.message).join(", ")); + } + + if (!json.data) { + throw new Error("No data returned from subgraph"); + } + + return json.data; +} + +const toHttpUri = (uri?: string | null): string | undefined => { + if (!uri) return undefined; + if (uri.startsWith("ipfs://")) { + return uri.replace("ipfs://", "https://ipfs.io/ipfs/"); + } + return uri; +}; + +const formatEth = (value?: string | null): string => { + if (!value) return "0"; + const asBigInt = BigInt(value); + const formatted = Number.parseFloat(formatEther(asBigInt)); + return formatted.toFixed(4); +}; + +const shortenAddress = (address?: string | null): string => { + if (!address) return "-"; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +const AuctionArt: React.FC<{ imageUrl?: string; tokenId?: string }> = ({ imageUrl, tokenId }) => { + return ( +
+ {imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {`Token + ) : ( +
+ No artwork +
+ )} +
+ ); +}; + +const PastAuctionCard: React.FC<{ auction: BuilderAuction }> = ({ auction }) => ( + + + +
+ Token #{auction.tokenId} + {new Date(auction.endTime * 1000).toLocaleDateString()} +
+
+
Final bid
+
{formatEth(auction.winningBidAmount ?? auction.highestBidAmount)} ETH
+
+
+
Winner
+
{shortenAddress(auction.winningBidder ?? auction.highestBidder)}
+
+
+
+); + +const Countdown: React.FC<{ endTime: number }> = ({ endTime }) => { + const [remaining, setRemaining] = useState(endTime * 1000 - Date.now()); + + useEffect(() => { + const interval = setInterval(() => { + setRemaining(endTime * 1000 - Date.now()); + }, 1000); + + return () => clearInterval(interval); + }, [endTime]); + + if (remaining <= 0) { + return Auction ended; + } + + const hours = Math.floor(remaining / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((remaining % (1000 * 60)) / 1000); + + return ( + + {hours.toString().padStart(2, "0")}:{minutes.toString().padStart(2, "0")}:{seconds + .toString() + .padStart(2, "0")} + + ); +}; + +const ActiveAuctionCard: React.FC<{ + auction: BuilderAuction; + dao?: BuilderDao; + onBid: (value: string) => Promise; + onSettle: () => Promise; + submitting: boolean; + disabled: boolean; +}> = ({ auction, dao, onBid, onSettle, submitting, disabled }) => { + const [bidValue, setBidValue] = useState(""); + const endsInFuture = auction.endTime * 1000 > Date.now(); + const currentBid = auction.highestBidAmount ?? auction.winningBidAmount ?? "0"; + + return ( + + +
+
+ {dao?.name ?? "Active Auction"} + Token #{auction.tokenId} +
+
+ {endsInFuture ? "Live" : "Needs settlement"} +
+
+
+ + + +
+
+
Current bid
+
{formatEth(currentBid)} ETH
+
+
+
Highest bidder
+
{shortenAddress(auction.highestBidder ?? auction.winningBidder)}
+
+
+
Ends
+
{new Date(auction.endTime * 1000).toLocaleString()}
+
+
+
Countdown
+
{endsInFuture ? : "Ended"}
+
+
+ +
+
+ + setBidValue(event.target.value)} + placeholder="0.05" + disabled={submitting || disabled || !endsInFuture} + /> +
+
+ + +
+
+
+
+ ); +}; + +export type NounishAuctionsSettings = { + selectedDao: DaoOption; + customDaoContract?: string; + builderNetwork?: SupportedNetwork; + customGraphUrl?: string; +} & FidgetSettingsStyle; + +export const nounishAuctionsConfig: FidgetProperties = { + fidgetName: "Nounish Auctions", + icon: 0x1f3b0, + fields: [ + { + fieldName: "selectedDao", + displayName: "Select DAO", + displayNameHint: "Choose a Builder DAO to prefill address and subgraph", + default: DAO_OPTIONS.find((dao) => dao.name === "Gnars") ?? DAO_OPTIONS[0], + required: false, + inputSelector: (props) => ( + + + + ), + group: "settings", + }, + { + fieldName: "customDaoContract", + displayName: "Custom DAO contract", + displayNameHint: "Override the DAO address if it is not listed", + required: false, + inputSelector: (props) => ( + + + + ), + group: "settings", + }, + { + fieldName: "builderNetwork", + displayName: "Builder network", + displayNameHint: "Network where the DAO is deployed", + default: "base", + required: false, + inputSelector: (props) => ( + + + + ), + group: "settings", + }, + { + fieldName: "customGraphUrl", + displayName: "Custom subgraph URL", + displayNameHint: "Provide a custom Builder subgraph endpoint if needed", + required: false, + inputSelector: (props) => ( + + + + ), + group: "settings", + }, + ...defaultStyleFields, + ], + size: { + minHeight: 8, + maxHeight: 36, + minWidth: 6, + maxWidth: 36, + }, +}; + +const pageSize = 9; + +export const NounishAuctions: React.FC> = ({ settings }) => { + const initialDao = settings.selectedDao ?? DAO_OPTIONS[0]; + const [activeAuction, setActiveAuction] = useState(null); + const [daoDetails, setDaoDetails] = useState(null); + const [pastAuctions, setPastAuctions] = useState([]); + const [loadingActive, setLoadingActive] = useState(false); + const [loadingPast, setLoadingPast] = useState(false); + const [page, setPage] = useState(0); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + const [txPending, setTxPending] = useState(false); + const [hasMorePast, setHasMorePast] = useState(true); + const [daoChoice, setDaoChoice] = useState(initialDao); + const [customAddress, setCustomAddress] = useState(settings.customDaoContract ?? ""); + const [networkValue, setNetworkValue] = useState( + (settings.builderNetwork ?? "base") as SupportedNetwork, + ); + const [customGraphUrl, setCustomGraphUrl] = useState(settings.customGraphUrl ?? ""); + + const { address, chainId } = useAccount(); + const { connectAsync, connectors } = useConnect(); + const { switchChainAsync } = useSwitchChain(); + const { writeContractAsync } = useWriteContract(); + + const daoAddress = useMemo(() => { + const override = customAddress.trim(); + const fromSelector = daoChoice?.contract; + return (override && isAddress(override) ? override : fromSelector) as `0x${string}` | undefined; + }, [customAddress, daoChoice?.contract]); + + const resolvedNetwork = + SUPPORTED_NETWORKS.find((item) => item.value === networkValue) ?? SUPPORTED_NETWORKS[0]; + + const subgraphUrl = + customGraphUrl || + daoChoice?.graphUrl || + BUILDER_SUBGRAPH_ENDPOINTS[networkValue] || + BUILDER_SUBGRAPH_ENDPOINTS.base; + + const hasValidDao = Boolean(daoAddress); + + const fetchActiveAuction = useCallback(async () => { + if (!hasValidDao) return; + setLoadingActive(true); + setError(null); + try { + const data = await runGraphQuery<{ + dao?: BuilderDao; + auctions: Array<{ + id: string; + endTime: string; + startTime: string; + settled: boolean; + token?: { tokenId: string; image?: string | null } | null; + highestBid?: { amount: string; bidder: string } | null; + winningBid?: { amount: string; bidder: string } | null; + }>; + }>(subgraphUrl, ACTIVE_AUCTION_QUERY, { dao: daoAddress?.toLowerCase() }); + + const auction = data.auctions?.[0]; + setDaoDetails(data.dao ?? null); + + if (auction) { + setActiveAuction({ + id: auction.id, + endTime: Number(auction.endTime), + startTime: Number(auction.startTime), + settled: auction.settled, + tokenId: auction.token?.tokenId ?? "", + imageUrl: toHttpUri(auction.token?.image), + highestBidAmount: auction.highestBid?.amount ?? undefined, + highestBidder: auction.highestBid?.bidder, + winningBidAmount: auction.winningBid?.amount ?? undefined, + winningBidder: auction.winningBid?.bidder, + }); + } else { + setActiveAuction(null); + } + } catch (err) { + console.error(err); + setError((err as Error).message); + } finally { + setLoadingActive(false); + } + }, [daoAddress, hasValidDao, subgraphUrl]); + + const fetchPastAuctions = useCallback( + async (pageIndex: number, reset = false) => { + if (!hasValidDao) return; + setLoadingPast(true); + setError(null); + if (reset) { + setHasMorePast(true); + } + try { + const data = await runGraphQuery<{ + auctions: Array<{ + id: string; + endTime: string; + token?: { tokenId: string; image?: string | null } | null; + winningBid?: { amount: string; bidder: string } | null; + highestBid?: { amount: string; bidder: string } | null; + }>; + }>(subgraphUrl, PAST_AUCTIONS_QUERY, { + dao: daoAddress?.toLowerCase(), + first: pageSize, + skip: pageIndex * pageSize, + }); + + const mapped = (data.auctions || []).map((auction) => ({ + id: auction.id, + endTime: Number(auction.endTime), + startTime: Number(auction.endTime), + settled: true, + tokenId: auction.token?.tokenId ?? "", + imageUrl: toHttpUri(auction.token?.image), + highestBidAmount: auction.highestBid?.amount ?? undefined, + highestBidder: auction.highestBid?.bidder, + winningBidAmount: auction.winningBid?.amount ?? undefined, + winningBidder: auction.winningBid?.bidder, + })); + + setPastAuctions((prev) => (reset ? mapped : [...prev, ...mapped])); + if (mapped.length < pageSize) { + setHasMorePast(false); + } + } catch (err) { + console.error(err); + setError((err as Error).message); + } finally { + setLoadingPast(false); + } + }, + [daoAddress, hasValidDao, subgraphUrl], + ); + + useEffect(() => { + setPage(0); + fetchActiveAuction(); + fetchPastAuctions(0, true); + }, [fetchActiveAuction, fetchPastAuctions, refreshKey]); + + const ensureConnection = useCallback(async () => { + if (!connectAsync || !switchChainAsync) return; + if (!address) { + const connector = connectors[0]; + if (!connector) throw new Error("No wallet connector available"); + await connectAsync({ connector, chainId: resolvedNetwork.chain.id }); + return; + } + if (chainId !== resolvedNetwork.chain.id) { + await switchChainAsync({ chainId: resolvedNetwork.chain.id }); + } + }, [address, chainId, connectAsync, connectors, resolvedNetwork.chain.id, switchChainAsync]); + + const handleBid = useCallback( + async (value: string) => { + if (!activeAuction || !daoDetails?.auctionAddress) return; + const trimmed = value.trim(); + if (!trimmed || Number(trimmed) <= 0) { + setError("Enter a valid bid amount"); + return; + } + setError(null); + try { + setTxPending(true); + await ensureConnection(); + const hash = await writeContractAsync({ + address: daoDetails.auctionAddress, + abi: auctionAbi, + functionName: "createBid", + args: [BigInt(activeAuction.tokenId)], + value: parseEther(trimmed), + chainId: resolvedNetwork.chain.id, + }); + await waitForTransactionReceipt(wagmiConfig, { hash, chainId: resolvedNetwork.chain.id }); + setRefreshKey((prev) => prev + 1); + } catch (err) { + console.error(err); + setError((err as Error).message); + } finally { + setTxPending(false); + } + }, + [activeAuction, daoDetails?.auctionAddress, ensureConnection, resolvedNetwork.chain.id, writeContractAsync], + ); + + const handleSettle = useCallback(async () => { + if (!activeAuction || !daoDetails?.auctionAddress) return; + setError(null); + try { + setTxPending(true); + await ensureConnection(); + const hash = await writeContractAsync({ + address: daoDetails.auctionAddress, + abi: auctionAbi, + functionName: "settleCurrentAndCreateNewAuction", + args: [], + chainId: resolvedNetwork.chain.id, + }); + await waitForTransactionReceipt(wagmiConfig, { hash, chainId: resolvedNetwork.chain.id }); + setRefreshKey((prev) => prev + 1); + } catch (err) { + console.error(err); + setError((err as Error).message); + } finally { + setTxPending(false); + } + }, [activeAuction, daoDetails?.auctionAddress, ensureConnection, resolvedNetwork.chain.id, writeContractAsync]); + + const handleLoadMore = () => { + const nextPage = page + 1; + setPage(nextPage); + fetchPastAuctions(nextPage); + }; + + return ( +
+
+ + + DAO + Select a Builder DAO + + + { + setDaoChoice(dao); + setRefreshKey((prev) => prev + 1); + }} + value={daoChoice} + /> +
+
Address: {daoAddress ?? "-"}
+
Network: {resolvedNetwork.label}
+
+
+
+ + + + Custom DAO + Override DAO address + + + setCustomAddress(event.target.value)} + onBlur={() => setRefreshKey((prev) => prev + 1)} + /> + + + + + + + Subgraph endpoint + Override if your DAO uses a custom URL + + + setCustomGraphUrl(event.target.value)} + onBlur={() => setRefreshKey((prev) => prev + 1)} + /> +
Current endpoint: {subgraphUrl}
+
+
+
+ + + + {error ? ( +
+ {error} +
+ ) : null} + + {!hasValidDao && ( +
Enter a valid DAO contract address to load auctions.
+ )} + + {loadingActive &&
Loading active auction...
} + + {hasValidDao && !loadingActive && activeAuction && ( + + )} + + {hasValidDao && !loadingActive && !activeAuction && ( + + + No active auction found for this DAO. + + + )} + +
+
+

Past auctions

+ {loadingPast && Loading...} +
+ {pastAuctions.length === 0 && !loadingPast ? ( +
No past auctions yet.
+ ) : ( +
+ {pastAuctions.map((auction) => ( + + ))} +
+ )} + {pastAuctions.length >= pageSize && hasMorePast && ( +
+ +
+ )} +
+
+ ); +}; + +export default { + fidget: NounishAuctions, + properties: nounishAuctionsConfig, +} as FidgetModule>; diff --git a/src/fidgets/index.ts b/src/fidgets/index.ts index 7cee6a402..c2090df64 100644 --- a/src/fidgets/index.ts +++ b/src/fidgets/index.ts @@ -8,6 +8,7 @@ import Profile from "./ui/profile"; import Channel from "./ui/channel"; import Grid from "./layout/Grid"; import NounishGovernance from "./community/nouns-dao/NounishGovernance"; +import NounishAuctions from "./community/nouns-dao/NounishAuctions"; import Cast from "./farcaster/Cast"; import Feed from "./farcaster/Feed"; import Top8 from "./farcaster/Top8"; @@ -50,6 +51,7 @@ export const CompleteFidgets = { iframe: IFrame, // Nouns governance: NounishGovernance, + nounishAuctions: NounishAuctions, nounsHome: NounsHome, links: Links, // zora: zoraEmbed, -> 500 server error -Frame ancestors block From 55c019476a05c518d9383e2d0d71b83ea1ff5610 Mon Sep 17 00:00:00 2001 From: willyogo Date: Mon, 1 Dec 2025 16:23:54 -0600 Subject: [PATCH 2/6] Improve NounishAuctions layout and simplify state management - Refactor layout to use responsive flex layout with auction art on left - Remove unused Separator import - Simplify state management by using settings directly with useMemo - Improve overflow handling for better scrolling - Streamline UI by removing configuration cards --- .../community/nouns-dao/NounishAuctions.tsx | 315 ++++++++---------- 1 file changed, 137 insertions(+), 178 deletions(-) diff --git a/src/fidgets/community/nouns-dao/NounishAuctions.tsx b/src/fidgets/community/nouns-dao/NounishAuctions.tsx index d70dd1627..bb8a24b57 100644 --- a/src/fidgets/community/nouns-dao/NounishAuctions.tsx +++ b/src/fidgets/community/nouns-dao/NounishAuctions.tsx @@ -26,7 +26,6 @@ import { SelectTrigger, SelectValue, } from "@/common/components/atoms/select"; -import { Separator } from "@/common/components/atoms/separator"; import { DAO_OPTIONS } from "@/constants/basedDaos"; import { mergeClasses } from "@/common/lib/utils/mergeClasses"; import { FidgetArgs, FidgetModule, FidgetProperties, FidgetSettingsStyle } from "@/common/fidgets"; @@ -223,9 +222,13 @@ const shortenAddress = (address?: string | null): string => { return `${address.slice(0, 6)}...${address.slice(-4)}`; }; -const AuctionArt: React.FC<{ imageUrl?: string; tokenId?: string }> = ({ imageUrl, tokenId }) => { +const AuctionArt: React.FC<{ imageUrl?: string; tokenId?: string; className?: string }> = ({ + imageUrl, + tokenId, + className, +}) => { return ( -
+
{imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element {`Token @@ -312,54 +315,65 @@ const ActiveAuctionCard: React.FC<{
- - -
-
-
Current bid
-
{formatEth(currentBid)} ETH
-
-
-
Highest bidder
-
{shortenAddress(auction.highestBidder ?? auction.winningBidder)}
-
-
-
Ends
-
{new Date(auction.endTime * 1000).toLocaleString()}
-
-
-
Countdown
-
{endsInFuture ? : "Ended"}
-
-
- -
-
- - setBidValue(event.target.value)} - placeholder="0.05" - disabled={submitting || disabled || !endsInFuture} +
+
+
-
- - +
+
+
+
Current bid
+
{formatEth(currentBid)} ETH
+
+
+
Highest bidder
+
+ {shortenAddress(auction.highestBidder ?? auction.winningBidder)} +
+
+
+
Ends
+
{new Date(auction.endTime * 1000).toLocaleString()}
+
+
+
Countdown
+
{endsInFuture ? : "Ended"}
+
+
+ +
+
+ + setBidValue(event.target.value)} + placeholder="0.05" + disabled={submitting || disabled || !endsInFuture} + /> +
+
+ + +
+
@@ -452,7 +466,6 @@ export const nounishAuctionsConfig: FidgetProperties = { const pageSize = 9; export const NounishAuctions: React.FC> = ({ settings }) => { - const initialDao = settings.selectedDao ?? DAO_OPTIONS[0]; const [activeAuction, setActiveAuction] = useState(null); const [daoDetails, setDaoDetails] = useState(null); const [pastAuctions, setPastAuctions] = useState([]); @@ -463,18 +476,17 @@ export const NounishAuctions: React.FC> = ({ const [refreshKey, setRefreshKey] = useState(0); const [txPending, setTxPending] = useState(false); const [hasMorePast, setHasMorePast] = useState(true); - const [daoChoice, setDaoChoice] = useState(initialDao); - const [customAddress, setCustomAddress] = useState(settings.customDaoContract ?? ""); - const [networkValue, setNetworkValue] = useState( - (settings.builderNetwork ?? "base") as SupportedNetwork, - ); - const [customGraphUrl, setCustomGraphUrl] = useState(settings.customGraphUrl ?? ""); const { address, chainId } = useAccount(); const { connectAsync, connectors } = useConnect(); const { switchChainAsync } = useSwitchChain(); const { writeContractAsync } = useWriteContract(); + const daoChoice = useMemo(() => settings.selectedDao ?? DAO_OPTIONS[0], [settings.selectedDao]); + const customAddress = settings.customDaoContract ?? ""; + const networkValue = (settings.builderNetwork ?? "base") as SupportedNetwork; + const customGraphUrl = settings.customGraphUrl ?? ""; + const daoAddress = useMemo(() => { const override = customAddress.trim(); const fromSelector = daoChoice?.contract; @@ -668,132 +680,79 @@ export const NounishAuctions: React.FC> = ({ }; return ( -
-
- - - DAO - Select a Builder DAO - - - { - setDaoChoice(dao); - setRefreshKey((prev) => prev + 1); - }} - value={daoChoice} - /> -
-
Address: {daoAddress ?? "-"}
-
Network: {resolvedNetwork.label}
+
+
+
+ + +
+
{daoChoice?.name ?? "Builder DAO"}
+
Address: {daoAddress ?? "-"}
+
Network: {resolvedNetwork.label}
+
Subgraph: {subgraphUrl}
+
+
+
+ + {error ? ( +
+ {error}
- - - - - - Custom DAO - Override DAO address - - - setCustomAddress(event.target.value)} - onBlur={() => setRefreshKey((prev) => prev + 1)} + ) : null} + + {!hasValidDao && ( +
+ Enter a valid DAO contract address in the fidget settings to load auctions. +
+ )} + + {loadingActive &&
Loading active auction...
} + + {hasValidDao && !loadingActive && activeAuction && ( + - -
-
- - - - Subgraph endpoint - Override if your DAO uses a custom URL - - - setCustomGraphUrl(event.target.value)} - onBlur={() => setRefreshKey((prev) => prev + 1)} - /> -
Current endpoint: {subgraphUrl}
-
-
-
- - - - {error ? ( -
- {error} -
- ) : null} - - {!hasValidDao && ( -
Enter a valid DAO contract address to load auctions.
- )} - - {loadingActive &&
Loading active auction...
} - - {hasValidDao && !loadingActive && activeAuction && ( - - )} - - {hasValidDao && !loadingActive && !activeAuction && ( - - - No active auction found for this DAO. - - - )} - -
-
-

Past auctions

- {loadingPast && Loading...} -
- {pastAuctions.length === 0 && !loadingPast ? ( -
No past auctions yet.
- ) : ( -
- {pastAuctions.map((auction) => ( - - ))} +
+ )} + {pastAuctions.length >= pageSize && hasMorePast && ( +
+ +
+ )}
- )} - {pastAuctions.length >= pageSize && hasMorePast && ( -
- -
- )} +
); From 20f08caa68eca32a4b3a90c024cc2ed0f854e0ff Mon Sep 17 00:00:00 2001 From: willyogo Date: Tue, 2 Dec 2025 18:15:27 -0600 Subject: [PATCH 3/6] Improve auction fidget UI --- src/fidgets/nouns-home/NounsHomeFidget.tsx | 428 +++++------------- .../nouns-home/components/AuctionHero.tsx | 329 +++++++------- 2 files changed, 291 insertions(+), 466 deletions(-) diff --git a/src/fidgets/nouns-home/NounsHomeFidget.tsx b/src/fidgets/nouns-home/NounsHomeFidget.tsx index 3df21d0b9..1f14a1ea9 100644 --- a/src/fidgets/nouns-home/NounsHomeFidget.tsx +++ b/src/fidgets/nouns-home/NounsHomeFidget.tsx @@ -11,33 +11,20 @@ import { useAccount, useConnect, useEnsName, + useEnsAvatar, useSwitchChain, useWriteContract, } from "wagmi"; import { mainnet } from "wagmi/chains"; import { simulateContract, waitForTransactionReceipt } from "wagmi/actions"; -import { ContractFunctionExecutionError } from "viem"; import AuctionHero from "./components/AuctionHero"; import BidModal from "./components/BidModal"; -import StatsRow from "./components/StatsRow"; -import { - AlreadyOwnSection, - FaqAccordion, - GetANounSection, - GovernedByYouSection, - JourneySection, - LearnSection, - NounsFundsIdeasSection, - TheseAreNounsStrip, - ThisIsNounsSection, -} from "./components/Sections"; import { nounsWagmiConfig, REQUIRED_CHAIN_ID } from "./wagmiConfig"; import { nounsPublicClient, NOUNS_AH_ADDRESS } from "./config"; import { NounsAuctionHouseV3Abi, NounsAuctionHouseExtraAbi } from "./abis"; -import type { Auction, Settlement } from "./types"; -import { formatEth, getAuctionStatus } from "./utils"; -import { useEthUsdPrice, formatUsd } from "./price"; -import { fetchExecutedProposalsCount, fetchCurrentTokenHolders, fetchLatestAuction, fetchAuctionById, fetchNounSeedBackground, NOUNS_BG_HEX, fetchAccountLeaderboardCount } from "./subgraph"; +import type { Auction } from "./types"; +import { getAuctionStatus } from "./utils"; +import { fetchLatestAuction, fetchAuctionById, fetchNounSeedBackground, NOUNS_BG_HEX } from "./subgraph"; import LinkOut from "./LinkOut"; @@ -118,106 +105,8 @@ const useAuctionData = () => { }; }; -const pageSize = 12; - -const useSettlements = (auction?: Auction) => { - const [settlements, setSettlements] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [page, setPage] = useState(0); - const [hasMore, setHasMore] = useState(true); - - const loadSettlements = useCallback( - async (pageToLoad: number, reset = false) => { - if (!auction) return; - setIsLoading(true); - try { - const currentAuctionId = Number(auction.nounId); - const latestSettledId = auction.settled - ? currentAuctionId - : currentAuctionId - 1; - if (latestSettledId < 0) { - setSettlements([]); - setHasMore(false); - return; - } - const endId = latestSettledId - pageToLoad * pageSize; - if (endId < 0) { - setHasMore(false); - return; - } - const startId = Math.max(0, endId - (pageSize - 1)); - if (startId > endId) { - setHasMore(false); - return; - } - const data = await nounsPublicClient.readContract({ - address: NOUNS_AH_ADDRESS, - abi: NounsAuctionHouseV3Abi, - functionName: "getSettlements", - args: [BigInt(startId), BigInt(endId), true], - }); - const normalized = (data as unknown as Settlement[]) - .map((item) => ({ - blockTimestamp: Number(item.blockTimestamp), - amount: BigInt(item.amount), - winner: item.winner, - nounId: BigInt(item.nounId), - clientId: Number(item.clientId), - })) - .sort((a, b) => Number(b.nounId - a.nounId)); - setSettlements((prev) => { - if (reset || pageToLoad === 0) { - return normalized; - } - const existingIds = new Set(prev.map((item) => item.nounId.toString())); - const merged = [ - ...prev, - ...normalized.filter( - (item) => !existingIds.has(item.nounId.toString()), - ), - ]; - return merged; - }); - setHasMore(startId > 0); - setPage(pageToLoad); - } catch (error) { - if (error instanceof ContractFunctionExecutionError) { - // The contract reverts with "Internal error" when a requested range has - // no settlements yet. This is expected during gaps and does not impact - // the user experience, so we treat it as "no more data". - setHasMore(false); - return; - } - console.error("Failed to load settlements", error); - } finally { - setIsLoading(false); - } - }, - [auction], - ); - - useEffect(() => { - if (!auction) return; - loadSettlements(0, true); - }, [auction, loadSettlements]); - - return { - settlements, - isLoading, - hasMore, - loadNext: () => { - if (!hasMore || isLoading) return; - loadSettlements(page + 1); - }, - refresh: () => loadSettlements(0, true), - }; -}; - -const INNER_PADDING = "space-y-10 md:space-y-14"; - const NounsHomeInner: React.FC = () => { const { auction, refetch } = useAuctionData(); - const { settlements, refresh } = useSettlements(auction); const { isConnected, address, chainId } = useAccount(); const { connectAsync, connectors } = useConnect(); const { switchChainAsync } = useSwitchChain(); @@ -233,22 +122,18 @@ const NounsHomeInner: React.FC = () => { const [minIncrementPct, setMinIncrementPct] = useState(null); const [viewNounId, setViewNounId] = useState(null); const [displayAuction, setDisplayAuction] = useState(); - const [bgHex, setBgHex] = useState(undefined); - const [floorNative, setFloorNative] = useState(undefined); - const [topOfferNative, setTopOfferNative] = useState(undefined); + const [bgHex, setBgHex] = useState(NOUNS_BG_HEX[0]); useEffect(() => { if (!auction) return; - // Do not override if user is viewing a past auction - setViewNounId((prev) => (prev == null || prev >= Number(auction.nounId) ? Number(auction.nounId) : prev)); + const latestId = Number(auction.nounId); + // Follow the live auction unless the user is purposely browsing the past + setViewNounId((prev) => { + if (prev == null) return latestId; + const wasOnLatest = prev >= latestId - 1; + return wasOnLatest ? latestId : prev; + }); setDisplayAuction(auction); - const updateCountdown = () => { - const remaining = Number(auction.endTime) * 1000 - Date.now(); - setCountdown(Math.max(0, remaining)); - }; - updateCountdown(); - const timer = setInterval(updateCountdown, 1_000); - return () => clearInterval(timer); }, [auction]); useEffect(() => { @@ -265,8 +150,6 @@ const NounsHomeInner: React.FC = () => { bidder: (sg.bidder?.id ?? "0x0000000000000000000000000000000000000000") as `0x${string}`, settled: Boolean(sg.settled), }); - const endMs = Number(sg.endTime) * 1000; - setCountdown(Math.max(0, endMs - Date.now())); } const bgIdx = await fetchNounSeedBackground(viewNounId); if (bgIdx !== undefined) setBgHex(NOUNS_BG_HEX[bgIdx as 0 | 1] ?? NOUNS_BG_HEX[0]); @@ -276,34 +159,6 @@ const NounsHomeInner: React.FC = () => { })(); }, [viewNounId]); - // Fetch collection floor / top offer (Reservoir) - useEffect(() => { - let cancelled = false; - (async () => { - try { - const res = await fetch( - "https://api.reservoir.tools/collections/v7?id=0x9C8fF314C9Bc7F6e59A9d9225Fb22946427eDC03&includeTopBid=true", - { cache: 'no-store' } - ); - if (!res.ok) return; - const json = await res.json(); - const col = json?.collections?.[0]; - // Read exactly what nouns.com reads from Reservoir - const floor = col?.floorAsk?.price?.amount?.decimal ?? col?.floorAsk?.price?.native; - const top = col?.topBid?.price?.amount?.decimal ?? col?.topBid?.price?.native; - if (!cancelled) { - if (Number.isFinite(floor)) setFloorNative(floor); - if (Number.isFinite(top)) setTopOfferNative(top); - } - } catch (_) { - // ignore; non-blocking - } - })(); - return () => { - cancelled = true; - }; - }, []); - // Fetch precise bidding parameters when the component mounts useEffect(() => { (async () => { @@ -324,7 +179,28 @@ const NounsHomeInner: React.FC = () => { })(); }, []); - const activeAuction = displayAuction ?? auction; + const activeAuction = useMemo(() => { + if (displayAuction) return displayAuction; + if (auction && (viewNounId == null || Number(auction.nounId) === viewNounId)) { + return auction; + } + return undefined; + }, [auction, displayAuction, viewNounId]); + + useEffect(() => { + if (!activeAuction) { + setCountdown(0); + return; + } + const updateCountdown = () => { + const remaining = Number(activeAuction.endTime) * 1000 - Date.now(); + setCountdown(Math.max(0, remaining)); + }; + updateCountdown(); + if (getAuctionStatus(activeAuction) !== "active") return; + const timer = setInterval(updateCountdown, 1_000); + return () => clearInterval(timer); + }, [activeAuction]); const minRequiredWei = useMemo(() => { if (!activeAuction) return undefined; @@ -346,68 +222,6 @@ const NounsHomeInner: React.FC = () => { }, }); - const totalSettled = useMemo(() => { - if (!activeAuction) return 0; - const base = Number(activeAuction.nounId); - return activeAuction.settled ? base + 1 : base; - }, [activeAuction]); - - const nounHolderCount = useMemo(() => { - if (!settlements?.length) return undefined; - const holders = new Set(); - for (const settlement of settlements) { - const winner = settlement.winner?.toLowerCase(); - if (winner && winner !== '0x0000000000000000000000000000000000000000') { - holders.add(winner); - } - } - if (auction?.bidder && auction.bidder !== '0x0000000000000000000000000000000000000000') { - holders.add(auction.bidder.toLowerCase()); - } - return holders.size || undefined; - }, [settlements, auction]); - - // Fetch precise counts from public subgraph - const [executedCount, setExecutedCount] = useState(); - const [holdersFromSubgraph, setHoldersFromSubgraph] = useState(); - const [holdersFromPonder, setHoldersFromPonder] = useState(); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const [exec, holders, ponder] = await Promise.all([ - fetchExecutedProposalsCount().catch(() => undefined), - fetchCurrentTokenHolders().catch(() => undefined), - fetchAccountLeaderboardCount().catch(() => undefined), - ]); - if (!cancelled) { - setExecutedCount(exec); - setHoldersFromSubgraph(holders); - setHoldersFromPonder(ponder); - } - } catch (_) { - // ignore - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const treasuryRaisedLabel = useMemo(() => { - if (!settlements?.length) return undefined; - const totalEth = settlements.reduce((acc, item) => acc + item.amount, 0n); - return formatEth(totalEth); - }, [settlements]); - const ethUsd = useEthUsdPrice(); - const treasuryRaisedUsdLabel = useMemo(() => { - if (!settlements?.length || !ethUsd) return undefined; - const totalEth = settlements.reduce((acc, item) => acc + item.amount, 0n); - const asEth = Number(formatEth(totalEth).split(' ')[0].replace(/,/g, '')); - if (!Number.isFinite(asEth)) return undefined; - return formatUsd(asEth * ethUsd); - }, [settlements, ethUsd]); - const status = getAuctionStatus(activeAuction); const dateLabel = useMemo(() => { const start = activeAuction ? Number(activeAuction.startTime) * 1000 : undefined; @@ -416,14 +230,24 @@ const NounsHomeInner: React.FC = () => { const handlePrev = () => { if (viewNounId == null) return; - setViewNounId(Math.max(0, viewNounId - 1)); + const target = Math.max(0, viewNounId - 1); + if (target === viewNounId) return; + setDisplayAuction(undefined); + setCountdown(0); + setBgHex(undefined); + setViewNounId(target); }; const handleNext = () => { if (viewNounId == null || !auction) return; if (viewNounId >= Number(auction.nounId)) return; - setViewNounId(viewNounId + 1); + const target = Math.min(Number(auction.nounId), viewNounId + 1); + if (target === viewNounId) return; + setDisplayAuction(undefined); + setCountdown(0); + setBgHex(undefined); + setViewNounId(target); }; - const canGoNext = auction ? (viewNounId ?? 0) < Number(auction.nounId) : false; + const canGoNext = auction && viewNounId != null ? viewNounId < Number(auction.nounId) : false; const canBid = isConnected && chainId === REQUIRED_CHAIN_ID && status === "active" && viewNounId === (auction ? Number(auction.nounId) : undefined); @@ -511,8 +335,8 @@ const NounsHomeInner: React.FC = () => { if (receipt.status === "success") { setActionMessage("Bid confirmed!"); setBidModalOpen(false); - await refetch(); - await refresh(); + const updated = await refetch(); + if (updated) setViewNounId(Number(updated.nounId)); } else { setActionError("Bid transaction failed"); } @@ -533,7 +357,6 @@ const NounsHomeInner: React.FC = () => { chainId, address, refetch, - refresh, writeContractAsync, switchChainAsync, ], @@ -596,8 +419,8 @@ const NounsHomeInner: React.FC = () => { } if (success) { setActionMessage("Auction settled!"); - await refetch(); - await refresh(); + const updated = await refetch(); + if (updated) setViewNounId(Number(updated.nounId)); } else { throw lastError ?? new Error("Settle reverted"); } @@ -611,94 +434,79 @@ const NounsHomeInner: React.FC = () => { setIsSettling(false); setTimeout(() => setActionMessage(null), 6_000); } - }, [auction, attemptSettle, chainId, isConnected, refetch, refresh]); - - const rootRef = React.useRef(null); - const heroRef = React.useRef(null); - const scrollToAuction = React.useCallback(() => { - const root = rootRef.current; - const hero = heroRef.current; - if (!root || !hero) return; - const top = hero.offsetTop - 8; - root.scrollTo({ top, behavior: 'smooth' }); - }, []); + }, [auction, attemptSettle, chainId, isConnected, refetch]); - return ( -
+ const { data: bidderEnsAvatar } = useEnsAvatar({ + name: bidderEns ?? undefined, + address: activeAuction?.bidder, + chainId: mainnet.id, + query: { + enabled: + Boolean(bidderEns || activeAuction?.bidder) && + activeAuction?.bidder !== "0x0000000000000000000000000000000000000000", + }, + }); -
- -
-
- - - - - - - - - - -
+ const backgroundColor = bgHex ?? NOUNS_BG_HEX[0]; + const isViewingLatest = auction ? viewNounId === Number(auction.nounId) : true; - {bidModalOpen && auction && ( - { - setBidModalOpen(false); - setActionError(null); - }} - onConfirm={handleBidSubmit} - isSubmitting={bidSubmitting} - errorMessage={actionError ?? undefined} - /> - )} + return ( +
+
+
+ + + {bidModalOpen && auction && ( + { + setBidModalOpen(false); + setActionError(null); + }} + onConfirm={handleBidSubmit} + isSubmitting={bidSubmitting} + errorMessage={actionError ?? undefined} + /> + )} - {(actionMessage || actionError || txHash) && ( -
- {actionMessage &&

{actionMessage}

} - {actionError &&

{actionError}

} - {txHash && ( -

- Tx hash: {txHash} -

+ {(actionMessage || actionError || txHash) && ( +
+ {actionMessage &&

{actionMessage}

} + {actionError &&

{actionError}

} + {txHash && ( +

+ Tx hash: {txHash} +

+ )} +
)}
- )} +
); }; diff --git a/src/fidgets/nouns-home/components/AuctionHero.tsx b/src/fidgets/nouns-home/components/AuctionHero.tsx index 09fe6b584..a3ec70cd0 100644 --- a/src/fidgets/nouns-home/components/AuctionHero.tsx +++ b/src/fidgets/nouns-home/components/AuctionHero.tsx @@ -1,7 +1,7 @@ 'use client'; -import React, { useMemo, useState } from 'react'; -import { ChevronLeft, ChevronRight, Info } from 'lucide-react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import { parseEther } from 'viem'; import NounImage from '../NounImage'; import { formatCountdown, formatEth, getAuctionStatus } from '../utils'; @@ -10,6 +10,7 @@ import type { Auction } from '../types'; interface AuctionHeroProps { auction?: Auction; ensName?: string | null; + ensAvatarUrl?: string | null; countdownMs: number; onOpenBid: () => void; onSettle: () => void; @@ -24,8 +25,6 @@ interface AuctionHeroProps { backgroundHex?: string; minRequiredWei?: bigint; onPlaceBid?: (valueWei: bigint) => void; - floorPriceNative?: number | undefined; - topOfferNative?: number | undefined; isCurrentView?: boolean; } @@ -34,6 +33,7 @@ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const AuctionHero: React.FC = ({ auction, ensName, + ensAvatarUrl, countdownMs, onOpenBid, onSettle, @@ -48,20 +48,24 @@ const AuctionHero: React.FC = ({ backgroundHex, minRequiredWei, onPlaceBid, - floorPriceNative, - topOfferNative, isCurrentView = true, }) => { const status = getAuctionStatus(auction); const nounId = auction ? Number(auction.nounId) : undefined; + const hasBidder = Boolean(auction && auction.bidder !== ZERO_ADDRESS); const bidderLabel = - auction && auction.bidder !== ZERO_ADDRESS + hasBidder && auction ? ensName || `${auction.bidder.slice(0, 6)}...${auction.bidder.slice(-4)}` : 'No bids yet'; - const countdownLabel = formatCountdown(countdownMs); + const bidderHref = hasBidder && auction ? `https://etherscan.io/address/${auction.bidder}` : undefined; + const countdownLabel = status === 'ended' ? 'Ended' : formatCountdown(countdownMs); const etherLabel = auction ? formatEth(auction.amount, 3) : 'Loading'; + const needsSettlement = Boolean(auction && status === 'ended' && !auction.settled); const isEnded = status === 'ended'; + const isActiveAuction = status === 'active' && isCurrentView; + const showSettleCta = needsSettlement; const buttonDisabled = isEnded ? !auction || auction.settled || isSettling : status === 'pending'; + const bidButtonLabel = isConnected ? 'Place Bid' : 'Connect to bid'; const placeholderEth = useMemo(() => { if (!minRequiredWei) return '0.10'; @@ -71,6 +75,10 @@ const AuctionHero: React.FC = ({ }, [minRequiredWei]); const [bidInput, setBidInput] = useState(''); + useEffect(() => { + setBidInput(''); + }, [nounId]); + const handleBidClick = () => { if (!onPlaceBid) return onOpenBid(); try { @@ -83,15 +91,15 @@ const AuctionHero: React.FC = ({ return (
-
-
+
+
{nounId !== undefined ? ( ) : ( @@ -101,166 +109,175 @@ const AuctionHero: React.FC = ({ )}
-
-
- {/* Mobile top bar: date left, arrows right */} -
- {dateLabel && {dateLabel}} +
+
+
+
+ {dateLabel || 'Date pending'} +
{onPrev && ( - + )} {onNext && ( - + )}
- {/* Desktop top bar (arrows then date) */} -
- {onPrev && ( - - )} - {onNext && ( + +
+

+ {nounId !== undefined ? `Noun ${nounId}` : 'Loading'} +

+
+
+
+ {isEnded ? 'Winning bid' : 'Current bid'} +
+
{etherLabel}
+
+
+
+ {isEnded ? 'Won by' : 'Time left'} +
+
+ {isEnded ? ( + bidderHref ? ( + + {ensAvatarUrl && ( + + )} + {bidderLabel} + + ) : ( + {bidderLabel} + ) + ) : ( + countdownLabel + )} +
+
+
+
+ +
+ {showSettleCta ? ( - )} - {dateLabel && {dateLabel}} -
-

- {nounId !== undefined ? `Noun ${nounId}` : 'Loading'} -

-
- {/* Mobile compact rows */} -
-
- {isCurrentView ? 'Current bid' : 'Winning bid'} - {etherLabel} -
-
- {isCurrentView ? 'Time left' : 'Won by'} - {isCurrentView ? (status === 'ended' ? '00:00' : countdownLabel) : (auction && auction.bidder !== '0x0000000000000000000000000000000000000000' ? ({bidderLabel}) : '-')} -
-
- {/* Desktop stats */} -
-
-
{isCurrentView ? 'Current bid' : 'Winning bid'}
-
{etherLabel}
-
-
-
{isCurrentView ? 'Time left' : 'Won by'}
-
- {isCurrentView - ? (status === 'ended' ? '00:00' : countdownLabel) - : ( - auction && auction.bidder !== '0x0000000000000000000000000000000000000000' ? ( - + ) : isActiveAuction ? ( + <> +
+
+ setBidInput(e.target.value)} + disabled={isEnded} + aria-label="Bid amount in ETH" + /> + + ETH + +
+ +
+
-
-
- {isCurrentView && ( -
- - - - Floor price: - - {typeof floorPriceNative === 'number' ? `${floorPriceNative.toFixed(2)} ETH` : '—'} - - - - Top offer: - - {typeof topOfferNative === 'number' ? `${topOfferNative.toFixed(2)} ETH` : '—'} - -
+ + ) : ( + hasBidder && ( +
+ Won by{' '} + {bidderHref ? ( + + {ensAvatarUrl && ( + + )} + {bidderLabel} + + ) : ( + bidderLabel + )} +
+ ) )}
- -
- {isCurrentView && status === 'ended' && auction && !auction.settled ? ( - - ) : isCurrentView ? ( - <> -
-
- setBidInput(e.target.value)} - disabled={isEnded} - aria-label="Bid amount in ETH" - /> - ETH -
- -
-
- Highest bidder{' '} - {auction && auction.bidder !== ZERO_ADDRESS ? ( - - {bidderLabel} - - ) : ( - bidderLabel - )} -
- - ) : null} -
From 30e59320ed074b91f1f6dd2374a9ae85868e4143 Mon Sep 17 00:00:00 2001 From: willyogo Date: Tue, 2 Dec 2025 18:26:40 -0600 Subject: [PATCH 4/6] Revert "Improve auction fidget UI" This reverts commit 20f08caa68eca32a4b3a90c024cc2ed0f854e0ff. --- src/fidgets/nouns-home/NounsHomeFidget.tsx | 428 +++++++++++++----- .../nouns-home/components/AuctionHero.tsx | 329 +++++++------- 2 files changed, 466 insertions(+), 291 deletions(-) diff --git a/src/fidgets/nouns-home/NounsHomeFidget.tsx b/src/fidgets/nouns-home/NounsHomeFidget.tsx index 1f14a1ea9..3df21d0b9 100644 --- a/src/fidgets/nouns-home/NounsHomeFidget.tsx +++ b/src/fidgets/nouns-home/NounsHomeFidget.tsx @@ -11,20 +11,33 @@ import { useAccount, useConnect, useEnsName, - useEnsAvatar, useSwitchChain, useWriteContract, } from "wagmi"; import { mainnet } from "wagmi/chains"; import { simulateContract, waitForTransactionReceipt } from "wagmi/actions"; +import { ContractFunctionExecutionError } from "viem"; import AuctionHero from "./components/AuctionHero"; import BidModal from "./components/BidModal"; +import StatsRow from "./components/StatsRow"; +import { + AlreadyOwnSection, + FaqAccordion, + GetANounSection, + GovernedByYouSection, + JourneySection, + LearnSection, + NounsFundsIdeasSection, + TheseAreNounsStrip, + ThisIsNounsSection, +} from "./components/Sections"; import { nounsWagmiConfig, REQUIRED_CHAIN_ID } from "./wagmiConfig"; import { nounsPublicClient, NOUNS_AH_ADDRESS } from "./config"; import { NounsAuctionHouseV3Abi, NounsAuctionHouseExtraAbi } from "./abis"; -import type { Auction } from "./types"; -import { getAuctionStatus } from "./utils"; -import { fetchLatestAuction, fetchAuctionById, fetchNounSeedBackground, NOUNS_BG_HEX } from "./subgraph"; +import type { Auction, Settlement } from "./types"; +import { formatEth, getAuctionStatus } from "./utils"; +import { useEthUsdPrice, formatUsd } from "./price"; +import { fetchExecutedProposalsCount, fetchCurrentTokenHolders, fetchLatestAuction, fetchAuctionById, fetchNounSeedBackground, NOUNS_BG_HEX, fetchAccountLeaderboardCount } from "./subgraph"; import LinkOut from "./LinkOut"; @@ -105,8 +118,106 @@ const useAuctionData = () => { }; }; +const pageSize = 12; + +const useSettlements = (auction?: Auction) => { + const [settlements, setSettlements] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + + const loadSettlements = useCallback( + async (pageToLoad: number, reset = false) => { + if (!auction) return; + setIsLoading(true); + try { + const currentAuctionId = Number(auction.nounId); + const latestSettledId = auction.settled + ? currentAuctionId + : currentAuctionId - 1; + if (latestSettledId < 0) { + setSettlements([]); + setHasMore(false); + return; + } + const endId = latestSettledId - pageToLoad * pageSize; + if (endId < 0) { + setHasMore(false); + return; + } + const startId = Math.max(0, endId - (pageSize - 1)); + if (startId > endId) { + setHasMore(false); + return; + } + const data = await nounsPublicClient.readContract({ + address: NOUNS_AH_ADDRESS, + abi: NounsAuctionHouseV3Abi, + functionName: "getSettlements", + args: [BigInt(startId), BigInt(endId), true], + }); + const normalized = (data as unknown as Settlement[]) + .map((item) => ({ + blockTimestamp: Number(item.blockTimestamp), + amount: BigInt(item.amount), + winner: item.winner, + nounId: BigInt(item.nounId), + clientId: Number(item.clientId), + })) + .sort((a, b) => Number(b.nounId - a.nounId)); + setSettlements((prev) => { + if (reset || pageToLoad === 0) { + return normalized; + } + const existingIds = new Set(prev.map((item) => item.nounId.toString())); + const merged = [ + ...prev, + ...normalized.filter( + (item) => !existingIds.has(item.nounId.toString()), + ), + ]; + return merged; + }); + setHasMore(startId > 0); + setPage(pageToLoad); + } catch (error) { + if (error instanceof ContractFunctionExecutionError) { + // The contract reverts with "Internal error" when a requested range has + // no settlements yet. This is expected during gaps and does not impact + // the user experience, so we treat it as "no more data". + setHasMore(false); + return; + } + console.error("Failed to load settlements", error); + } finally { + setIsLoading(false); + } + }, + [auction], + ); + + useEffect(() => { + if (!auction) return; + loadSettlements(0, true); + }, [auction, loadSettlements]); + + return { + settlements, + isLoading, + hasMore, + loadNext: () => { + if (!hasMore || isLoading) return; + loadSettlements(page + 1); + }, + refresh: () => loadSettlements(0, true), + }; +}; + +const INNER_PADDING = "space-y-10 md:space-y-14"; + const NounsHomeInner: React.FC = () => { const { auction, refetch } = useAuctionData(); + const { settlements, refresh } = useSettlements(auction); const { isConnected, address, chainId } = useAccount(); const { connectAsync, connectors } = useConnect(); const { switchChainAsync } = useSwitchChain(); @@ -122,18 +233,22 @@ const NounsHomeInner: React.FC = () => { const [minIncrementPct, setMinIncrementPct] = useState(null); const [viewNounId, setViewNounId] = useState(null); const [displayAuction, setDisplayAuction] = useState(); - const [bgHex, setBgHex] = useState(NOUNS_BG_HEX[0]); + const [bgHex, setBgHex] = useState(undefined); + const [floorNative, setFloorNative] = useState(undefined); + const [topOfferNative, setTopOfferNative] = useState(undefined); useEffect(() => { if (!auction) return; - const latestId = Number(auction.nounId); - // Follow the live auction unless the user is purposely browsing the past - setViewNounId((prev) => { - if (prev == null) return latestId; - const wasOnLatest = prev >= latestId - 1; - return wasOnLatest ? latestId : prev; - }); + // Do not override if user is viewing a past auction + setViewNounId((prev) => (prev == null || prev >= Number(auction.nounId) ? Number(auction.nounId) : prev)); setDisplayAuction(auction); + const updateCountdown = () => { + const remaining = Number(auction.endTime) * 1000 - Date.now(); + setCountdown(Math.max(0, remaining)); + }; + updateCountdown(); + const timer = setInterval(updateCountdown, 1_000); + return () => clearInterval(timer); }, [auction]); useEffect(() => { @@ -150,6 +265,8 @@ const NounsHomeInner: React.FC = () => { bidder: (sg.bidder?.id ?? "0x0000000000000000000000000000000000000000") as `0x${string}`, settled: Boolean(sg.settled), }); + const endMs = Number(sg.endTime) * 1000; + setCountdown(Math.max(0, endMs - Date.now())); } const bgIdx = await fetchNounSeedBackground(viewNounId); if (bgIdx !== undefined) setBgHex(NOUNS_BG_HEX[bgIdx as 0 | 1] ?? NOUNS_BG_HEX[0]); @@ -159,6 +276,34 @@ const NounsHomeInner: React.FC = () => { })(); }, [viewNounId]); + // Fetch collection floor / top offer (Reservoir) + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch( + "https://api.reservoir.tools/collections/v7?id=0x9C8fF314C9Bc7F6e59A9d9225Fb22946427eDC03&includeTopBid=true", + { cache: 'no-store' } + ); + if (!res.ok) return; + const json = await res.json(); + const col = json?.collections?.[0]; + // Read exactly what nouns.com reads from Reservoir + const floor = col?.floorAsk?.price?.amount?.decimal ?? col?.floorAsk?.price?.native; + const top = col?.topBid?.price?.amount?.decimal ?? col?.topBid?.price?.native; + if (!cancelled) { + if (Number.isFinite(floor)) setFloorNative(floor); + if (Number.isFinite(top)) setTopOfferNative(top); + } + } catch (_) { + // ignore; non-blocking + } + })(); + return () => { + cancelled = true; + }; + }, []); + // Fetch precise bidding parameters when the component mounts useEffect(() => { (async () => { @@ -179,28 +324,7 @@ const NounsHomeInner: React.FC = () => { })(); }, []); - const activeAuction = useMemo(() => { - if (displayAuction) return displayAuction; - if (auction && (viewNounId == null || Number(auction.nounId) === viewNounId)) { - return auction; - } - return undefined; - }, [auction, displayAuction, viewNounId]); - - useEffect(() => { - if (!activeAuction) { - setCountdown(0); - return; - } - const updateCountdown = () => { - const remaining = Number(activeAuction.endTime) * 1000 - Date.now(); - setCountdown(Math.max(0, remaining)); - }; - updateCountdown(); - if (getAuctionStatus(activeAuction) !== "active") return; - const timer = setInterval(updateCountdown, 1_000); - return () => clearInterval(timer); - }, [activeAuction]); + const activeAuction = displayAuction ?? auction; const minRequiredWei = useMemo(() => { if (!activeAuction) return undefined; @@ -222,6 +346,68 @@ const NounsHomeInner: React.FC = () => { }, }); + const totalSettled = useMemo(() => { + if (!activeAuction) return 0; + const base = Number(activeAuction.nounId); + return activeAuction.settled ? base + 1 : base; + }, [activeAuction]); + + const nounHolderCount = useMemo(() => { + if (!settlements?.length) return undefined; + const holders = new Set(); + for (const settlement of settlements) { + const winner = settlement.winner?.toLowerCase(); + if (winner && winner !== '0x0000000000000000000000000000000000000000') { + holders.add(winner); + } + } + if (auction?.bidder && auction.bidder !== '0x0000000000000000000000000000000000000000') { + holders.add(auction.bidder.toLowerCase()); + } + return holders.size || undefined; + }, [settlements, auction]); + + // Fetch precise counts from public subgraph + const [executedCount, setExecutedCount] = useState(); + const [holdersFromSubgraph, setHoldersFromSubgraph] = useState(); + const [holdersFromPonder, setHoldersFromPonder] = useState(); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [exec, holders, ponder] = await Promise.all([ + fetchExecutedProposalsCount().catch(() => undefined), + fetchCurrentTokenHolders().catch(() => undefined), + fetchAccountLeaderboardCount().catch(() => undefined), + ]); + if (!cancelled) { + setExecutedCount(exec); + setHoldersFromSubgraph(holders); + setHoldersFromPonder(ponder); + } + } catch (_) { + // ignore + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const treasuryRaisedLabel = useMemo(() => { + if (!settlements?.length) return undefined; + const totalEth = settlements.reduce((acc, item) => acc + item.amount, 0n); + return formatEth(totalEth); + }, [settlements]); + const ethUsd = useEthUsdPrice(); + const treasuryRaisedUsdLabel = useMemo(() => { + if (!settlements?.length || !ethUsd) return undefined; + const totalEth = settlements.reduce((acc, item) => acc + item.amount, 0n); + const asEth = Number(formatEth(totalEth).split(' ')[0].replace(/,/g, '')); + if (!Number.isFinite(asEth)) return undefined; + return formatUsd(asEth * ethUsd); + }, [settlements, ethUsd]); + const status = getAuctionStatus(activeAuction); const dateLabel = useMemo(() => { const start = activeAuction ? Number(activeAuction.startTime) * 1000 : undefined; @@ -230,24 +416,14 @@ const NounsHomeInner: React.FC = () => { const handlePrev = () => { if (viewNounId == null) return; - const target = Math.max(0, viewNounId - 1); - if (target === viewNounId) return; - setDisplayAuction(undefined); - setCountdown(0); - setBgHex(undefined); - setViewNounId(target); + setViewNounId(Math.max(0, viewNounId - 1)); }; const handleNext = () => { if (viewNounId == null || !auction) return; if (viewNounId >= Number(auction.nounId)) return; - const target = Math.min(Number(auction.nounId), viewNounId + 1); - if (target === viewNounId) return; - setDisplayAuction(undefined); - setCountdown(0); - setBgHex(undefined); - setViewNounId(target); + setViewNounId(viewNounId + 1); }; - const canGoNext = auction && viewNounId != null ? viewNounId < Number(auction.nounId) : false; + const canGoNext = auction ? (viewNounId ?? 0) < Number(auction.nounId) : false; const canBid = isConnected && chainId === REQUIRED_CHAIN_ID && status === "active" && viewNounId === (auction ? Number(auction.nounId) : undefined); @@ -335,8 +511,8 @@ const NounsHomeInner: React.FC = () => { if (receipt.status === "success") { setActionMessage("Bid confirmed!"); setBidModalOpen(false); - const updated = await refetch(); - if (updated) setViewNounId(Number(updated.nounId)); + await refetch(); + await refresh(); } else { setActionError("Bid transaction failed"); } @@ -357,6 +533,7 @@ const NounsHomeInner: React.FC = () => { chainId, address, refetch, + refresh, writeContractAsync, switchChainAsync, ], @@ -419,8 +596,8 @@ const NounsHomeInner: React.FC = () => { } if (success) { setActionMessage("Auction settled!"); - const updated = await refetch(); - if (updated) setViewNounId(Number(updated.nounId)); + await refetch(); + await refresh(); } else { throw lastError ?? new Error("Settle reverted"); } @@ -434,79 +611,94 @@ const NounsHomeInner: React.FC = () => { setIsSettling(false); setTimeout(() => setActionMessage(null), 6_000); } - }, [auction, attemptSettle, chainId, isConnected, refetch]); + }, [auction, attemptSettle, chainId, isConnected, refetch, refresh]); - const { data: bidderEnsAvatar } = useEnsAvatar({ - name: bidderEns ?? undefined, - address: activeAuction?.bidder, - chainId: mainnet.id, - query: { - enabled: - Boolean(bidderEns || activeAuction?.bidder) && - activeAuction?.bidder !== "0x0000000000000000000000000000000000000000", - }, - }); - - const backgroundColor = bgHex ?? NOUNS_BG_HEX[0]; - const isViewingLatest = auction ? viewNounId === Number(auction.nounId) : true; + const rootRef = React.useRef(null); + const heroRef = React.useRef(null); + const scrollToAuction = React.useCallback(() => { + const root = rootRef.current; + const hero = heroRef.current; + if (!root || !hero) return; + const top = hero.offsetTop - 8; + root.scrollTo({ top, behavior: 'smooth' }); + }, []); return ( -
-
-
- - - {bidModalOpen && auction && ( - { - setBidModalOpen(false); - setActionError(null); - }} - onConfirm={handleBidSubmit} - isSubmitting={bidSubmitting} - errorMessage={actionError ?? undefined} - /> - )} +
- {(actionMessage || actionError || txHash) && ( -
- {actionMessage &&

{actionMessage}

} - {actionError &&

{actionError}

} - {txHash && ( -

- Tx hash: {txHash} -

- )} -
+
+ +
+
+ + + + + + + + + + +
+ + {bidModalOpen && auction && ( + { + setBidModalOpen(false); + setActionError(null); + }} + onConfirm={handleBidSubmit} + isSubmitting={bidSubmitting} + errorMessage={actionError ?? undefined} + /> + )} + + {(actionMessage || actionError || txHash) && ( +
+ {actionMessage &&

{actionMessage}

} + {actionError &&

{actionError}

} + {txHash && ( +

+ Tx hash: {txHash} +

)}
-
+ )}
); }; diff --git a/src/fidgets/nouns-home/components/AuctionHero.tsx b/src/fidgets/nouns-home/components/AuctionHero.tsx index a3ec70cd0..09fe6b584 100644 --- a/src/fidgets/nouns-home/components/AuctionHero.tsx +++ b/src/fidgets/nouns-home/components/AuctionHero.tsx @@ -1,7 +1,7 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { ChevronLeft, ChevronRight, Info } from 'lucide-react'; import { parseEther } from 'viem'; import NounImage from '../NounImage'; import { formatCountdown, formatEth, getAuctionStatus } from '../utils'; @@ -10,7 +10,6 @@ import type { Auction } from '../types'; interface AuctionHeroProps { auction?: Auction; ensName?: string | null; - ensAvatarUrl?: string | null; countdownMs: number; onOpenBid: () => void; onSettle: () => void; @@ -25,6 +24,8 @@ interface AuctionHeroProps { backgroundHex?: string; minRequiredWei?: bigint; onPlaceBid?: (valueWei: bigint) => void; + floorPriceNative?: number | undefined; + topOfferNative?: number | undefined; isCurrentView?: boolean; } @@ -33,7 +34,6 @@ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const AuctionHero: React.FC = ({ auction, ensName, - ensAvatarUrl, countdownMs, onOpenBid, onSettle, @@ -48,24 +48,20 @@ const AuctionHero: React.FC = ({ backgroundHex, minRequiredWei, onPlaceBid, + floorPriceNative, + topOfferNative, isCurrentView = true, }) => { const status = getAuctionStatus(auction); const nounId = auction ? Number(auction.nounId) : undefined; - const hasBidder = Boolean(auction && auction.bidder !== ZERO_ADDRESS); const bidderLabel = - hasBidder && auction + auction && auction.bidder !== ZERO_ADDRESS ? ensName || `${auction.bidder.slice(0, 6)}...${auction.bidder.slice(-4)}` : 'No bids yet'; - const bidderHref = hasBidder && auction ? `https://etherscan.io/address/${auction.bidder}` : undefined; - const countdownLabel = status === 'ended' ? 'Ended' : formatCountdown(countdownMs); + const countdownLabel = formatCountdown(countdownMs); const etherLabel = auction ? formatEth(auction.amount, 3) : 'Loading'; - const needsSettlement = Boolean(auction && status === 'ended' && !auction.settled); const isEnded = status === 'ended'; - const isActiveAuction = status === 'active' && isCurrentView; - const showSettleCta = needsSettlement; const buttonDisabled = isEnded ? !auction || auction.settled || isSettling : status === 'pending'; - const bidButtonLabel = isConnected ? 'Place Bid' : 'Connect to bid'; const placeholderEth = useMemo(() => { if (!minRequiredWei) return '0.10'; @@ -75,10 +71,6 @@ const AuctionHero: React.FC = ({ }, [minRequiredWei]); const [bidInput, setBidInput] = useState(''); - useEffect(() => { - setBidInput(''); - }, [nounId]); - const handleBidClick = () => { if (!onPlaceBid) return onOpenBid(); try { @@ -91,15 +83,15 @@ const AuctionHero: React.FC = ({ return (
-
-
+
+
{nounId !== undefined ? ( ) : ( @@ -109,175 +101,166 @@ const AuctionHero: React.FC = ({ )}
-
-
-
-
- {dateLabel || 'Date pending'} -
+
+
+ {/* Mobile top bar: date left, arrows right */} +
+ {dateLabel && {dateLabel}}
{onPrev && ( - + )} {onNext && ( - + )}
- -
-

- {nounId !== undefined ? `Noun ${nounId}` : 'Loading'} -

-
-
-
- {isEnded ? 'Winning bid' : 'Current bid'} -
-
{etherLabel}
-
-
-
- {isEnded ? 'Won by' : 'Time left'} -
-
- {isEnded ? ( - bidderHref ? ( - - {ensAvatarUrl && ( - - )} - {bidderLabel} - - ) : ( - {bidderLabel} - ) - ) : ( - countdownLabel - )} -
-
-
-
- -
- {showSettleCta ? ( + {/* Desktop top bar (arrows then date) */} +
+ {onPrev && ( - ) : isActiveAuction ? ( - <> -
-
- setBidInput(e.target.value)} - disabled={isEnded} - aria-label="Bid amount in ETH" - /> - - ETH - -
- -
-
- {hasBidder ? 'Highest bidder' : 'No bids yet'} - {hasBidder && ( - - {ensAvatarUrl && ( - - )} - {bidderHref ? ( - + )} + {onNext && ( + + )} + {dateLabel && {dateLabel}} +
+

+ {nounId !== undefined ? `Noun ${nounId}` : 'Loading'} +

+
+ {/* Mobile compact rows */} + + {/* Desktop stats */} +
+
+
{isCurrentView ? 'Current bid' : 'Winning bid'}
+
{etherLabel}
+
+
+
{isCurrentView ? 'Time left' : 'Won by'}
+
+ {isCurrentView + ? (status === 'ended' ? '00:00' : countdownLabel) + : ( + auction && auction.bidder !== '0x0000000000000000000000000000000000000000' ? ( + {bidderLabel} - ) : ( - bidderLabel - )} - - )} -
- - ) : ( - hasBidder && ( -
- Won by{' '} - {bidderHref ? ( - - {ensAvatarUrl && ( - - )} - {bidderLabel} - - ) : ( - bidderLabel - )} + ) : '-' + )}
- ) +
+
+ {isCurrentView && ( +
+ + + + Floor price: + + {typeof floorPriceNative === 'number' ? `${floorPriceNative.toFixed(2)} ETH` : '—'} + + + + Top offer: + + {typeof topOfferNative === 'number' ? `${topOfferNative.toFixed(2)} ETH` : '—'} + +
)}
+ +
+ {isCurrentView && status === 'ended' && auction && !auction.settled ? ( + + ) : isCurrentView ? ( + <> +
+
+ setBidInput(e.target.value)} + disabled={isEnded} + aria-label="Bid amount in ETH" + /> + ETH +
+ +
+
+ Highest bidder{' '} + {auction && auction.bidder !== ZERO_ADDRESS ? ( + + {bidderLabel} + + ) : ( + bidderLabel + )} +
+ + ) : null} +
From c517d850954baf6175ff69b5ba8630b60cf32525 Mon Sep 17 00:00:00 2001 From: willyogo Date: Tue, 2 Dec 2025 18:46:09 -0600 Subject: [PATCH 5/6] Improve auction fidget UI --- .../community/nouns-dao/NounishAuctions.tsx | 744 ++++++++++++------ 1 file changed, 502 insertions(+), 242 deletions(-) diff --git a/src/fidgets/community/nouns-dao/NounishAuctions.tsx b/src/fidgets/community/nouns-dao/NounishAuctions.tsx index bb8a24b57..753eab890 100644 --- a/src/fidgets/community/nouns-dao/NounishAuctions.tsx +++ b/src/fidgets/community/nouns-dao/NounishAuctions.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { formatEther, isAddress, parseEther } from "viem"; import { base, mainnet, optimism } from "viem/chains"; import { @@ -8,15 +9,10 @@ import { useConnect, useSwitchChain, useWriteContract, + useEnsName, + useEnsAvatar, } from "wagmi"; -import { waitForTransactionReceipt } from "wagmi/actions"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/common/components/atoms/card"; +import { readContract, waitForTransactionReceipt } from "wagmi/actions"; import { Input } from "@/common/components/atoms/input"; import { Button } from "@/common/components/atoms/button"; import { @@ -117,6 +113,7 @@ const PAST_AUCTIONS_QUERY = /* GraphQL */ ` ) { id endTime + startTime token { tokenId image @@ -169,6 +166,27 @@ const auctionAbi = [ inputs: [], outputs: [], }, + { + type: "function", + stateMutability: "view", + name: "reservePrice", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + stateMutability: "view", + name: "minBidIncrement", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + stateMutability: "view", + name: "minBidIncrementPercentage", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, { type: "function", stateMutability: "view", @@ -210,11 +228,13 @@ const toHttpUri = (uri?: string | null): string | undefined => { return uri; }; -const formatEth = (value?: string | null): string => { - if (!value) return "0"; - const asBigInt = BigInt(value); - const formatted = Number.parseFloat(formatEther(asBigInt)); - return formatted.toFixed(4); +const formatEthDisplay = (value?: string | bigint | null): string => { + if (value == null) return "0 ETH"; + const asBigInt = typeof value === "bigint" ? value : BigInt(value); + const numeric = Number.parseFloat(formatEther(asBigInt)); + if (!Number.isFinite(numeric)) return "0 ETH"; + const decimals = numeric >= 1 ? 2 : 4; + return `${numeric.toFixed(decimals)} ETH`; }; const shortenAddress = (address?: string | null): string => { @@ -228,10 +248,20 @@ const AuctionArt: React.FC<{ imageUrl?: string; tokenId?: string; className?: st className, }) => { return ( -
+
{imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element - {`Token + {`Token ) : (
No artwork @@ -241,143 +271,91 @@ const AuctionArt: React.FC<{ imageUrl?: string; tokenId?: string; className?: st ); }; -const PastAuctionCard: React.FC<{ auction: BuilderAuction }> = ({ auction }) => ( - - - -
- Token #{auction.tokenId} - {new Date(auction.endTime * 1000).toLocaleDateString()} -
-
-
Final bid
-
{formatEth(auction.winningBidAmount ?? auction.highestBidAmount)} ETH
-
-
-
Winner
-
{shortenAddress(auction.winningBidder ?? auction.highestBidder)}
-
-
-
-); +const DEFAULT_BG = "#e1d7d5"; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; -const Countdown: React.FC<{ endTime: number }> = ({ endTime }) => { - const [remaining, setRemaining] = useState(endTime * 1000 - Date.now()); +const useImageDominantColor = (imageUrl?: string) => { + const [color, setColor] = useState(); useEffect(() => { - const interval = setInterval(() => { - setRemaining(endTime * 1000 - Date.now()); - }, 1000); - - return () => clearInterval(interval); - }, [endTime]); + if (!imageUrl) { + setColor(undefined); + return; + } + let cancelled = false; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = imageUrl; + img.onload = () => { + if (cancelled) return; + try { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.drawImage(img, 0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + setColor(`rgb(${r}, ${g}, ${b})`); + } catch { + setColor(undefined); + } + }; + img.onerror = () => { + if (!cancelled) setColor(undefined); + }; + return () => { + cancelled = true; + }; + }, [imageUrl]); - if (remaining <= 0) { - return Auction ended; - } + return color; +}; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((remaining % (1000 * 60)) / 1000); +const formatAuctionDate = (timestamp?: number) => { + if (!timestamp) return "-"; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(timestamp * 1000)); +}; - return ( - - {hours.toString().padStart(2, "0")}:{minutes.toString().padStart(2, "0")}:{seconds - .toString() - .padStart(2, "0")} - - ); +const formatCountdown = (endTime?: number) => { + if (!endTime) return "00:00:00"; + const ms = endTime * 1000 - Date.now(); + if (ms <= 0) return "00:00:00"; + const hours = Math.floor(ms / (1000 * 60 * 60)); + const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((ms % (1000 * 60)) / 1000); + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; }; -const ActiveAuctionCard: React.FC<{ - auction: BuilderAuction; - dao?: BuilderDao; - onBid: (value: string) => Promise; - onSettle: () => Promise; - submitting: boolean; - disabled: boolean; -}> = ({ auction, dao, onBid, onSettle, submitting, disabled }) => { - const [bidValue, setBidValue] = useState(""); - const endsInFuture = auction.endTime * 1000 > Date.now(); - const currentBid = auction.highestBidAmount ?? auction.winningBidAmount ?? "0"; +const formatEnsOrAddress = (ens?: string | null, address?: string | null) => { + if (ens) return ens; + if (address) return shortenAddress(address); + return "-"; +}; +const AddressDisplay: React.FC<{ + ensName?: string | null; + address?: string | null; + avatar?: string | null; + className?: string; +}> = ({ ensName, address, avatar, className }) => { + const label = formatEnsOrAddress(ensName, address); return ( - - -
-
- {dao?.name ?? "Active Auction"} - Token #{auction.tokenId} -
-
- {endsInFuture ? "Live" : "Needs settlement"} -
-
-
- -
-
- -
-
-
-
-
Current bid
-
{formatEth(currentBid)} ETH
-
-
-
Highest bidder
-
- {shortenAddress(auction.highestBidder ?? auction.winningBidder)} -
-
-
-
Ends
-
{new Date(auction.endTime * 1000).toLocaleString()}
-
-
-
Countdown
-
{endsInFuture ? : "Ended"}
-
-
- -
-
- - setBidValue(event.target.value)} - placeholder="0.05" - disabled={submitting || disabled || !endsInFuture} - /> -
-
- - -
-
+
+
+ {avatar ? ( + // eslint-disable-next-line @next/next/no-img-element + {label} + ) : ( +
+ {(ensName ?? address ?? "?").slice(0, 2).toUpperCase()}
-
- - + )} +
+ {label} +
); }; @@ -459,23 +437,29 @@ export const nounishAuctionsConfig: FidgetProperties = { minHeight: 8, maxHeight: 36, minWidth: 6, - maxWidth: 36, + maxWidth: 36, }, }; -const pageSize = 9; +const pastFetchSize = 1; export const NounishAuctions: React.FC> = ({ settings }) => { const [activeAuction, setActiveAuction] = useState(null); const [daoDetails, setDaoDetails] = useState(null); - const [pastAuctions, setPastAuctions] = useState([]); + const [pastAuctionCache, setPastAuctionCache] = useState>({}); + const [viewOffset, setViewOffset] = useState(0); const [loadingActive, setLoadingActive] = useState(false); const [loadingPast, setLoadingPast] = useState(false); - const [page, setPage] = useState(0); + const [pastEndReached, setPastEndReached] = useState(false); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const [txPending, setTxPending] = useState(false); - const [hasMorePast, setHasMorePast] = useState(true); + const [minBidIncrementPct, setMinBidIncrementPct] = useState(); + const [minBidIncrementWei, setMinBidIncrementWei] = useState(); + const [reservePrice, setReservePrice] = useState(); + const [bidValue, setBidValue] = useState(""); + const [fallbackChecked, setFallbackChecked] = useState(false); + const [now, setNow] = useState(Date.now()); const { address, chainId } = useAccount(); const { connectAsync, connectors } = useConnect(); @@ -504,6 +488,11 @@ export const NounishAuctions: React.FC> = ({ const hasValidDao = Boolean(daoAddress); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1_000); + return () => clearInterval(id); + }, []); + const fetchActiveAuction = useCallback(async () => { if (!hasValidDao) return; setLoadingActive(true); @@ -549,61 +538,114 @@ export const NounishAuctions: React.FC> = ({ } }, [daoAddress, hasValidDao, subgraphUrl]); - const fetchPastAuctions = useCallback( - async (pageIndex: number, reset = false) => { - if (!hasValidDao) return; + const fetchPastAuction = useCallback( + async (index: number) => { + if (!hasValidDao || index < 0) return null; + if (pastAuctionCache[index]) return pastAuctionCache[index]; setLoadingPast(true); setError(null); - if (reset) { - setHasMorePast(true); - } try { const data = await runGraphQuery<{ auctions: Array<{ id: string; endTime: string; + startTime?: string; token?: { tokenId: string; image?: string | null } | null; winningBid?: { amount: string; bidder: string } | null; highestBid?: { amount: string; bidder: string } | null; }>; }>(subgraphUrl, PAST_AUCTIONS_QUERY, { dao: daoAddress?.toLowerCase(), - first: pageSize, - skip: pageIndex * pageSize, + first: pastFetchSize, + skip: index, }); - const mapped = (data.auctions || []).map((auction) => ({ + const auction = data.auctions?.[0]; + if (!auction) { + setPastEndReached(true); + return null; + } + + const mapped: BuilderAuction = { id: auction.id, endTime: Number(auction.endTime), - startTime: Number(auction.endTime), + startTime: Number(auction.startTime ?? auction.endTime), settled: true, tokenId: auction.token?.tokenId ?? "", imageUrl: toHttpUri(auction.token?.image), - highestBidAmount: auction.highestBid?.amount ?? undefined, - highestBidder: auction.highestBid?.bidder, - winningBidAmount: auction.winningBid?.amount ?? undefined, - winningBidder: auction.winningBid?.bidder, - })); + highestBidAmount: auction.highestBid?.amount ?? auction.winningBid?.amount ?? undefined, + highestBidder: auction.highestBid?.bidder ?? auction.winningBid?.bidder, + winningBidAmount: auction.winningBid?.amount ?? auction.highestBid?.amount ?? undefined, + winningBidder: auction.winningBid?.bidder ?? auction.highestBid?.bidder, + }; - setPastAuctions((prev) => (reset ? mapped : [...prev, ...mapped])); - if (mapped.length < pageSize) { - setHasMorePast(false); - } + setPastAuctionCache((prev) => ({ ...prev, [index]: mapped })); + return mapped; } catch (err) { console.error(err); setError((err as Error).message); + return null; } finally { setLoadingPast(false); } }, - [daoAddress, hasValidDao, subgraphUrl], + [daoAddress, hasValidDao, pastAuctionCache, subgraphUrl], ); useEffect(() => { - setPage(0); + setPastAuctionCache({}); + setViewOffset(0); + setPastEndReached(false); + setFallbackChecked(false); + setBidValue(""); + }, [daoAddress, subgraphUrl]); + + useEffect(() => { fetchActiveAuction(); - fetchPastAuctions(0, true); - }, [fetchActiveAuction, fetchPastAuctions, refreshKey]); + }, [fetchActiveAuction, refreshKey]); + + useEffect(() => { + if (loadingActive || fallbackChecked || !hasValidDao) return; + if (!activeAuction) { + setFallbackChecked(true); + fetchPastAuction(0).then((auction) => { + if (auction) setViewOffset(1); + }); + } + }, [activeAuction, fetchPastAuction, fallbackChecked, hasValidDao, loadingActive]); + + useEffect(() => { + (async () => { + if (!daoAddress) return; + try { + const [reserve, pct, increment] = await Promise.all([ + readContract(wagmiConfig, { + address: daoAddress, + abi: auctionAbi, + functionName: "reservePrice", + chainId: resolvedNetwork.chain.id, + }).catch(() => undefined), + readContract(wagmiConfig, { + address: daoAddress, + abi: auctionAbi, + functionName: "minBidIncrementPercentage", + chainId: resolvedNetwork.chain.id, + }).catch(() => undefined), + readContract(wagmiConfig, { + address: daoAddress, + abi: auctionAbi, + functionName: "minBidIncrement", + chainId: resolvedNetwork.chain.id, + }).catch(() => undefined), + ]); + if (typeof reserve === "bigint") setReservePrice(reserve); + if (typeof pct === "number" || typeof pct === "bigint") setMinBidIncrementPct(Number(pct)); + if (typeof increment === "bigint") setMinBidIncrementWei(increment); + } catch (err) { + console.warn("Failed to load auction parameters", err); + } + })(); + }, [daoAddress, resolvedNetwork.chain.id]); const ensureConnection = useCallback(async () => { if (!connectAsync || !switchChainAsync) return; @@ -618,14 +660,120 @@ export const NounishAuctions: React.FC> = ({ } }, [address, chainId, connectAsync, connectors, resolvedNetwork.chain.id, switchChainAsync]); + const isViewingActive = viewOffset === 0 && Boolean(activeAuction); + const currentAuction = isViewingActive ? activeAuction : pastAuctionCache[viewOffset - 1] ?? null; + const displayDaoName = daoDetails?.name ?? daoChoice?.name ?? "Auction"; + + useEffect(() => { + setBidValue(""); + }, [currentAuction?.id]); + + const activeHighestBidWei = useMemo(() => { + const amount = activeAuction?.highestBidAmount; + if (!amount) return 0n; + return BigInt(amount); + }, [activeAuction?.highestBidAmount]); + + const minBidWei = useMemo(() => { + if (!isViewingActive) return undefined; + const highest = activeHighestBidWei; + if (highest === 0n) { + if (reservePrice !== undefined) return reservePrice; + if (minBidIncrementWei !== undefined) return minBidIncrementWei; + return undefined; + } + if (minBidIncrementWei !== undefined) return highest + minBidIncrementWei; + const pct = minBidIncrementPct ?? 5; + const bump = (highest * BigInt(pct) + 99n) / 100n; + return highest + bump; + }, [activeHighestBidWei, isViewingActive, minBidIncrementPct, minBidIncrementWei, reservePrice]); + + const minBidPlaceholder = useMemo(() => { + if (minBidWei == null) return "0.00"; + const eth = Number.parseFloat(formatEther(minBidWei)); + if (!Number.isFinite(eth)) return "0.00"; + const decimals = eth >= 1 ? 2 : 4; + return eth.toFixed(decimals); + }, [minBidWei]); + + const isEnded = currentAuction ? now >= currentAuction.endTime * 1000 : false; + const needsSettlement = isViewingActive && Boolean(currentAuction) && !currentAuction?.settled && isEnded; + + const displayBidValue = isViewingActive + ? currentAuction?.highestBidAmount ?? "0" + : currentAuction?.winningBidAmount ?? currentAuction?.highestBidAmount ?? "0"; + const displayBidLabel = formatEthDisplay(displayBidValue); + + const displayAddressRaw = isViewingActive + ? currentAuction?.highestBidder + : currentAuction?.winningBidder ?? currentAuction?.highestBidder; + const normalizedDisplayAddress = + displayAddressRaw && isAddress(displayAddressRaw) ? (displayAddressRaw as `0x${string}`) : undefined; + + const { data: ensName } = useEnsName({ + address: normalizedDisplayAddress, + chainId: mainnet.id, + query: { + enabled: Boolean(normalizedDisplayAddress && normalizedDisplayAddress !== ZERO_ADDRESS), + }, + }); + + const { data: ensAvatar } = useEnsAvatar({ + name: ensName ?? undefined, + chainId: mainnet.id, + query: { + enabled: Boolean(ensName), + }, + }); + + const timeLeftLabel = isViewingActive ? formatCountdown(currentAuction?.endTime) : "00:00:00"; + const backgroundColor = useImageDominantColor(currentAuction?.imageUrl) ?? DEFAULT_BG; + const canGoNewer = + viewOffset > 0 && + (!loadingActive || Boolean(activeAuction)) && + !(viewOffset === 1 && !activeAuction); + const nextPastIndex = viewOffset; + const canGoOlder = + hasValidDao && !loadingPast && !loadingActive && (!pastEndReached || Boolean(pastAuctionCache[nextPastIndex])); + + const handlePrev = useCallback(async () => { + const targetIndex = viewOffset; + const cached = pastAuctionCache[targetIndex]; + if (cached) { + setViewOffset((prev) => prev + 1); + return; + } + const fetched = await fetchPastAuction(targetIndex); + if (fetched) { + setViewOffset((prev) => prev + 1); + } + }, [fetchPastAuction, pastAuctionCache, viewOffset]); + + const handleNext = useCallback(() => { + if (viewOffset === 0) return; + if (viewOffset === 1 && !activeAuction) return; + setViewOffset((prev) => Math.max(0, prev - 1)); + }, [activeAuction, viewOffset]); + const handleBid = useCallback( async (value: string) => { - if (!activeAuction || !daoDetails?.auctionAddress) return; + if (!activeAuction || !daoDetails?.auctionAddress || viewOffset !== 0) return; const trimmed = value.trim(); if (!trimmed || Number(trimmed) <= 0) { setError("Enter a valid bid amount"); return; } + let bidWei: bigint; + try { + bidWei = parseEther(trimmed as `${number}`); + } catch { + setError("Enter a valid bid amount"); + return; + } + if (minBidWei != null && bidWei < minBidWei) { + setError(`Bid must be at least ${formatEthDisplay(minBidWei)}`); + return; + } setError(null); try { setTxPending(true); @@ -635,7 +783,7 @@ export const NounishAuctions: React.FC> = ({ abi: auctionAbi, functionName: "createBid", args: [BigInt(activeAuction.tokenId)], - value: parseEther(trimmed), + value: bidWei, chainId: resolvedNetwork.chain.id, }); await waitForTransactionReceipt(wagmiConfig, { hash, chainId: resolvedNetwork.chain.id }); @@ -647,7 +795,15 @@ export const NounishAuctions: React.FC> = ({ setTxPending(false); } }, - [activeAuction, daoDetails?.auctionAddress, ensureConnection, resolvedNetwork.chain.id, writeContractAsync], + [ + activeAuction, + daoDetails?.auctionAddress, + ensureConnection, + minBidWei, + resolvedNetwork.chain.id, + viewOffset, + writeContractAsync, + ], ); const handleSettle = useCallback(async () => { @@ -665,6 +821,7 @@ export const NounishAuctions: React.FC> = ({ }); await waitForTransactionReceipt(wagmiConfig, { hash, chainId: resolvedNetwork.chain.id }); setRefreshKey((prev) => prev + 1); + setViewOffset(0); } catch (err) { console.error(err); setError((err as Error).message); @@ -673,86 +830,189 @@ export const NounishAuctions: React.FC> = ({ } }, [activeAuction, daoDetails?.auctionAddress, ensureConnection, resolvedNetwork.chain.id, writeContractAsync]); - const handleLoadMore = () => { - const nextPage = page + 1; - setPage(nextPage); - fetchPastAuctions(nextPage); - }; + const statusLabel = needsSettlement + ? "Ended • needs settlement" + : isViewingActive + ? "Live auction" + : "Settled auction"; + + const bidderHasValue = Boolean( + displayAddressRaw && displayAddressRaw !== ZERO_ADDRESS && (currentAuction?.highestBidAmount || currentAuction?.winningBidAmount), + ); return (
-
-
- - -
-
{daoChoice?.name ?? "Builder DAO"}
-
Address: {daoAddress ?? "-"}
-
Network: {resolvedNetwork.label}
-
Subgraph: {subgraphUrl}
-
-
-
+
+ {error ? ( +
+ {error} +
+ ) : null} - {error ? ( -
- {error} -
- ) : null} + {!hasValidDao ? ( +
+ Enter a valid DAO contract address in the fidget settings to load auctions. +
+ ) : null} - {!hasValidDao && ( -
- Enter a valid DAO contract address in the fidget settings to load auctions. -
- )} - - {loadingActive &&
Loading active auction...
} - - {hasValidDao && !loadingActive && activeAuction && ( - - )} - - {hasValidDao && !loadingActive && !activeAuction && ( - - - No active auction found for this DAO. - - - )} - -
-
-

Past auctions

- {loadingPast && Loading...} -
- {pastAuctions.length === 0 && !loadingPast ? ( -
No past auctions yet.
- ) : ( -
- {pastAuctions.map((auction) => ( - - ))} + {hasValidDao && (loadingActive || (!currentAuction && loadingPast)) && ( +
+ Loading auction details... +
+ )} + + {hasValidDao && !loadingActive && !loadingPast && !currentAuction && ( +
+ No auctions found for this DAO yet. +
+ )} + + {hasValidDao && currentAuction && ( +
+
+
+
- )} - {pastAuctions.length >= pageSize && hasMorePast && ( -
- +
+ +
+
+
+
+ {formatAuctionDate(currentAuction.startTime || currentAuction.endTime)} +
+
+ + +
+
+ +
+
+ {statusLabel} +
+
+ {displayDaoName} • Token #{currentAuction.tokenId || "—"} +
+
+ +

+ {currentAuction.tokenId ? `Noun ${currentAuction.tokenId}` : displayDaoName} +

+ +
+
+

+ {isViewingActive ? "Current bid" : "Winning bid"} +

+

{displayBidLabel}

+
+
+

{isViewingActive ? "Time left" : "Time left"}

+

+ {isViewingActive ? (isEnded ? "00:00:00" : timeLeftLabel) : "00:00:00"} +

+
+
+ +
+ {needsSettlement ? ( + + ) : isViewingActive ? ( + <> +
+
+ setBidValue(event.target.value)} + placeholder={minBidPlaceholder} + disabled={txPending || isEnded} + className="h-12 w-full rounded-2xl border-2 border-black/10 bg-white pr-16 text-base font-semibold text-[#17171d] placeholder:font-semibold" + /> + + ETH + +
+ +
+
+ {bidderHasValue ? ( + <> + Highest bidder{" "} + + + ) : ( + "No bids yet" + )} +
+ + ) : ( +
+
Winning bid {displayBidLabel}
+
+ Won by{" "} + {bidderHasValue ? ( + + ) : ( + "—" + )} +
+
+ )} +
- )} +
-
+ )}
); From d81deba127cdb9b10448a999b695b8bbeb92cd4a Mon Sep 17 00:00:00 2001 From: willyogo Date: Tue, 2 Dec 2025 18:55:15 -0600 Subject: [PATCH 6/6] fix lint error --- src/fidgets/community/nouns-dao/NounishAuctions.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fidgets/community/nouns-dao/NounishAuctions.tsx b/src/fidgets/community/nouns-dao/NounishAuctions.tsx index 753eab890..1c06445d6 100644 --- a/src/fidgets/community/nouns-dao/NounishAuctions.tsx +++ b/src/fidgets/community/nouns-dao/NounishAuctions.tsx @@ -837,7 +837,9 @@ export const NounishAuctions: React.FC> = ({ : "Settled auction"; const bidderHasValue = Boolean( - displayAddressRaw && displayAddressRaw !== ZERO_ADDRESS && (currentAuction?.highestBidAmount || currentAuction?.winningBidAmount), + displayAddressRaw && + displayAddressRaw !== ZERO_ADDRESS && + (currentAuction?.highestBidAmount || currentAuction?.winningBidAmount), ); return (