From 21e6b4aaf6703c4952a5e5ebad7eed79dfc3c111 Mon Sep 17 00:00:00 2001 From: ox42 Date: Wed, 18 Mar 2026 00:24:11 +0100 Subject: [PATCH] Add auction --- app/components/layout/Sidebar.tsx | 3 +- app/config/auction.ts | 133 +++++++++++++ app/pages/auction/AuctionPage.tsx | 300 ++++++++++++++++++++++++++++++ app/routes.ts | 3 +- app/routes/auction.tsx | 7 + 5 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 app/config/auction.ts create mode 100644 app/pages/auction/AuctionPage.tsx create mode 100644 app/routes/auction.tsx diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index f2944a8..e4f0107 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -3,7 +3,8 @@ import { Link, useLocation } from "react-router"; const navItems = [ { route: "/", label: "Explorer", icon: "home" }, { route: "/status", label: "Status", icon: "notepad" }, - { route: "/transfer", label: "Transfer", icon: "money" } + { route: "/transfer", label: "Transfer", icon: "money" }, + { route: "/auction", label: "Auction", icon: "auction" } ]; export function Sidebar() { diff --git a/app/config/auction.ts b/app/config/auction.ts new file mode 100644 index 0000000..23d5d15 --- /dev/null +++ b/app/config/auction.ts @@ -0,0 +1,133 @@ +export const auctionContractAddress = '0x9d1430b67a7560c5eb450D543947AdB80C03B55A'; + +export const auctionABI = [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "AuctionClosed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "bidder", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "BidIncreased", + "type": "event" + }, + { + "inputs": [], + "name": "bid", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "closeAuction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "closed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "creator", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "highestAmount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "highestBidder", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "addr", + "type": "address" + } + ], + "name": "pendingReturnAmount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/app/pages/auction/AuctionPage.tsx b/app/pages/auction/AuctionPage.tsx new file mode 100644 index 0000000..b5440d7 --- /dev/null +++ b/app/pages/auction/AuctionPage.tsx @@ -0,0 +1,300 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "react-toastify"; +import { formatEther, parseEther, type Address } from "viem"; +import { useBalance, useConnection, useReadContract, useReadContracts, useWaitForTransactionReceipt, useWatchContractEvent, useWriteContract } from "wagmi"; +import { LoadingView } from "~/components/loading/LoadingView"; +import { auctionABI, auctionContractAddress } from "~/config/auction"; + + +function WalletBalanceCard({ address }: { address: Address }) { + const { data: balance, error } = useBalance({ address }); + + if (error) { + return

Failed to fetch wallet balance.

; + } + + return ( +
+
+

+ Wallet Balance +

+ Funds +
+
+ {balance ? Number(formatEther(balance.value)).toLocaleString() : '...'} + ETH +
+
+ ) +} + +function BestOfferCard({ address }: { address: Address }) { + const { data: bestOffer, error } = useReadContracts({ + contracts: [ + { + address: auctionContractAddress, + abi: auctionABI, + functionName: 'highestAmount' + }, + { + address: auctionContractAddress, + abi: auctionABI, + functionName: 'highestBidder' + } + ] + }); + + const [amount, bidder] = bestOffer || []; + + if (error || amount?.error || bidder?.error) { + return

Failed to fetch best bid.

; + } + + return ( +
+
+

+ Best Offer +

+ Top Bid +
+
+ {amount?.result !== undefined ? Number(formatEther(amount.result)).toLocaleString() : '...'} + ETH ({bidder?.result === address ? "by you" : "by someone else"}) +
+
+ ) +} + +function LastEventCard({ event }: { event?: string }) { + return ( +
+
+

+ Last Event +

+ Update +
+
+ {event || 'No events yet...'} +
+
+ ) +} + +function PendingReturnCard({ address }: { address: Address }) { + const { data: refundValue, error } = useReadContract({ + address: auctionContractAddress, + abi: auctionABI, + functionName: 'pendingReturnAmount', + args: [address] + }); + + if (error) { + return

Failed to fetch refund amount.

; + } + + return ( +
+
+

+ Pending Return +

+ Your Refund +
+
+ + {refundValue !== undefined ? Number(formatEther(refundValue)).toLocaleString() : '...'} + + ETH +
+
+ ) +} + + +function MakeBidCard() { + const [inputValue, setInputValue] = useState(""); + const isValidInput = inputValue && !isNaN(Number(inputValue)) && Number(inputValue) > 0; + + const { data: hash, mutate: makeBid, isPending: isWaitingForUser, error: userError, reset } = useWriteContract(); + const { data: receipt, isPending, isFetching, error: receiptError } = useWaitForTransactionReceipt({ + hash + }) + + function onSubmit(e) { + e.preventDefault(); + + if (!isValidInput) { + toast.error('Invalid bid amount.'); + return; + } + + makeBid({ + address: auctionContractAddress, + abi: auctionABI, + functionName: 'bid', + value: parseEther(inputValue) + }) + } + + return ( +
+
+

+ Make Bid +

+ Bid +
+
+
+ auction +

+ Enter your bid amount in ETH +

+
+
+
+ { + setInputValue(e.target.value); + if (hash || userError) { + reset(); + } + }} + /> + +
+ + {isWaitingForUser &&

Waiting for user...

} + {(userError || receiptError) &&

Failed to make bid.

} + {(isPending && isFetching) &&

Waiting for receipt...

} + {receipt &&

Status: {receipt.status}

} +
+
+
+ ) +} + +export function WithdrawCard({ address }: { address: Address }) { + + const { data: refundValue, isPending: isRefundValuePending, error: refundValueError } = useReadContract({ + address: auctionContractAddress, + abi: auctionABI, + functionName: 'pendingReturnAmount', + args: [address] + }); + + const queryClient = useQueryClient(); + const { data: hash, mutate: withdraw, isPending: isWaitingForUser, error: userError } = useWriteContract(); + const { data: receipt, isPending, isFetching, error: receiptError } = useWaitForTransactionReceipt({ + hash + }); + + useEffect(() => { + if (hash && receipt?.status === 'success') { + queryClient.invalidateQueries(); + } + }, [receipt, hash, queryClient]); + + + function onWithdraw(e) { + e.preventDefault(); + + withdraw({ + address: auctionContractAddress, + abi: auctionABI, + functionName: 'withdraw' + }) + } + + if (refundValueError) { + return

Failed to fetch refund value.

; + } + + if (isRefundValuePending || !refundValue) { + // don't render the component if there is nothing to withdraw + return null; + } + + return ( +
+
+

+ Withdraw +

+ Refund +
+
+
+ withdraw +

+ Withdraw your funds +

+
+
+

+ Click the button to withdraw your funds +

+ + + {isWaitingForUser &&

Waiting for user...

} + {(userError || receiptError) &&

Failed to withdraw.

} + {(isFetching && isPending) &&

Waiting for receipt...

} + {receipt &&

Status: {receipt.status}

} +
+
+
+ ) +} + + +export function AuctionPage() { + const { address, isConnected } = useConnection(); + const [isReady, setIsReady] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + if (!isConnected) { + navigate("/status"); + } else if (address && !isReady) { + setIsReady(true); + } + }, [isConnected, address, isReady, navigate, setIsReady]); + + const [lastEvent, setLastEvent] = useState(); + const queryClient = useQueryClient(); + useWatchContractEvent({ + address: auctionContractAddress, + abi: auctionABI, + onLogs: (logs) => { + if (logs.length > 0) { + setLastEvent(logs[logs.length - 1].eventName); + queryClient.invalidateQueries(); + } + } + }) + + if (!isConnected || !address || !isReady) { + return ; + } + + return ( +
+ + + + + + +
+ ) +} \ No newline at end of file diff --git a/app/routes.ts b/app/routes.ts index 1981238..493f44b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -3,5 +3,6 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("status", "routes/status.tsx"), - route("transfer", "routes/transfer.tsx") + route("transfer", "routes/transfer.tsx"), + route("auction", "routes/auction.tsx") ] satisfies RouteConfig; diff --git a/app/routes/auction.tsx b/app/routes/auction.tsx new file mode 100644 index 0000000..6eb12f1 --- /dev/null +++ b/app/routes/auction.tsx @@ -0,0 +1,7 @@ +import { AuctionPage } from "~/pages/auction/AuctionPage"; + +export default function Auction() { + return ( + + ) +} \ No newline at end of file