diff --git a/packages/client/src/Routes.tsx b/packages/client/src/Routes.tsx index ace2927f6..ee394295e 100644 --- a/packages/client/src/Routes.tsx +++ b/packages/client/src/Routes.tsx @@ -5,6 +5,8 @@ import { singletonEntity } from '@latticexyz/store-sync/recs'; import { Route, Routes, useLocation } from 'react-router-dom'; import { useMUD } from './contexts/MUDContext'; +import { AuctionHouse } from './pages/AuctionHouse'; +import { AuctionItem } from './pages/AuctionItem'; import { CharacterPage } from './pages/Character'; import { CharacterCreation } from './pages/CharacterCreation'; import { GameBoard } from './pages/GameBoard'; @@ -15,6 +17,8 @@ export const HOME_PATH = '/'; export const CHARACTER_CREATION_PATH = '/character-creation'; export const GAME_BOARD_PATH = '/game-board'; export const LEADERBOARD_PATH = '/leaderboard'; +export const AUCTION_HOUSE_PATH = '/auction-house'; +export const ITEM_PATH = AUCTION_HOUSE_PATH + '/items/'; const AppRoutes: React.FC = () => { const { pathname } = useLocation(); @@ -47,6 +51,8 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> ); }; diff --git a/packages/client/src/components/AuctionAllowance.tsx b/packages/client/src/components/AuctionAllowance.tsx new file mode 100644 index 000000000..d08893475 --- /dev/null +++ b/packages/client/src/components/AuctionAllowance.tsx @@ -0,0 +1,245 @@ +import { + Button, + FormControl, + FormHelperText, + FormLabel, + HStack, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Skeleton, + Switch, + Text, + VStack, +} from '@chakra-ui/react'; +import { useComponentValue } from '@latticexyz/react'; +import { singletonEntity } from '@latticexyz/store-sync/recs'; +import { useCallback, useEffect, useState } from 'react'; +import { Address, erc20Abi, parseEther } from 'viem'; +import { useAccount, useBalance, useWalletClient } from 'wagmi'; + +import { useMUD } from '../contexts/MUDContext'; +import { useToast } from '../hooks/useToast'; +import { ERC_1155ABI } from '../utils/constants'; +import { ConnectWalletButton } from './ConnectWalletButton'; +export const AuctionAllowance = ({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}): JSX.Element => { + const { renderSuccess, renderError } = useToast(); + const { data: externalWalletClient } = useWalletClient(); + const { isConnected, address } = useAccount(); + const { + network: { walletClient, worldContract, publicClient }, + components: { UltimateDominionConfig }, + } = useMUD(); + useBalance({ + address: externalWalletClient?.account.address, + }); + const { goldToken } = useComponentValue( + UltimateDominionConfig, + singletonEntity, + ) ?? { goldToken: null }; + + const { items: itemsContract } = useComponentValue( + UltimateDominionConfig, + singletonEntity, + ) ?? { items: null }; + + const [goldAllowance, setGoldAllowance] = useState('100'); + const [isApprovingGold, setIsApprovingGold] = useState(false); + const [goldErrorMessage, setGoldErrorMessage] = useState(null); + + const [itemsApprovedInitial, setItemsApprovedInitial] = useState< + boolean | null + >(null); + const [itemAllowed, setItemAllowed] = useState(false); + const [isApprovingItems, setIsApprovingItems] = useState(false); + + // Reset errorMessage state when any of the form fields change + useEffect(() => { + setGoldErrorMessage(null); + }, [goldAllowance]); + + useEffect(() => { + if (isOpen) { + setGoldAllowance('100'); + if (externalWalletClient && itemsApprovedInitial == null) { + (async function () { + const auction = await worldContract.read.UD__auctionHouseAddress(); + const t = await publicClient.readContract({ + address: itemsContract as Address, + abi: ERC_1155ABI, + functionName: 'isApprovedForAll', + args: [externalWalletClient.account.address, auction as Address], + }); + setItemAllowed(t as boolean); + setItemsApprovedInitial(true); + })(); + } + } + }, [ + externalWalletClient, + isOpen, + itemsApprovedInitial, + itemsContract, + publicClient, + walletClient.account, + worldContract.read, + ]); + + const onGoldAllowance = useCallback(async () => { + try { + if (!externalWalletClient) { + throw new Error('No external wallet client found.'); + } + + setIsApprovingGold(true); + if (!goldAllowance || parseEther(goldAllowance) <= 0) { + setGoldErrorMessage('Amount must be greater than 0.'); + return; + } + + const auction = await worldContract.read.UD__auctionHouseAddress(); + + const { request } = await publicClient.simulateContract({ + address: goldToken as Address, + abi: erc20Abi, + functionName: 'approve', + args: [auction, parseEther(goldAllowance)], + }); + await externalWalletClient.writeContract(request); + + setGoldAllowance(goldAllowance); + renderSuccess('Gold allowance successfully set!'); + } catch (e) { + renderError((e as Error)?.message ?? 'Error setting gold allowance.', e); + } finally { + setIsApprovingGold(false); + } + }, [ + externalWalletClient, + goldAllowance, + goldToken, + publicClient, + renderError, + renderSuccess, + worldContract.read, + ]); + const onItemsApproved = useCallback(async () => { + try { + if (!externalWalletClient) { + throw new Error('No external wallet client found.'); + } + + setIsApprovingItems(true); + const auction = await worldContract.read.UD__auctionHouseAddress(); + + const { request } = await publicClient.simulateContract({ + address: itemsContract as Address, + abi: ERC_1155ABI, + functionName: 'setApprovalForAll', + args: [auction as Address, !itemAllowed], + }); + await externalWalletClient.writeContract(request); + setItemAllowed(!itemAllowed); + setIsApprovingItems(false); + renderSuccess('Item allowance successfully set!'); + } catch (e) { + renderError((e as Error)?.message ?? 'Error setting item allowance.', e); + } finally { + setIsApprovingItems(false); + } + }, [ + externalWalletClient, + itemAllowed, + itemsContract, + publicClient, + renderError, + renderSuccess, + worldContract.read, + ]); + + return ( + + + + + {isConnected ? 'Wallet Details' : 'Connect Wallet'} + + + + {address && externalWalletClient && isConnected ? ( + + + + + + Set Auction House gold allowance + + {!!goldErrorMessage && ( + + {goldErrorMessage} + + )} + setGoldAllowance(e.target.value)} + placeholder="Amount" + type="number" + value={goldAllowance} + /> + + + + + + + Set Auction House item approval + + {!itemsApprovedInitial ? ( + + + + ) : ( + + )} + + + + + ) : ( + + Connect your wallet to play. + + + )} + + + + + + + ); +}; diff --git a/packages/client/src/components/AuctionRow.tsx b/packages/client/src/components/AuctionRow.tsx new file mode 100644 index 000000000..1d560e1d2 --- /dev/null +++ b/packages/client/src/components/AuctionRow.tsx @@ -0,0 +1,123 @@ +import { + Avatar, + Box, + Button, + Center, + Flex, + HStack, + Text, + VStack, +} from '@chakra-ui/react'; +import { FaHatWizard } from 'react-icons/fa'; +import { GiAxeSword, GiRogue } from 'react-icons/gi'; +import { IoIosArrowForward } from 'react-icons/io'; +import { useNavigate } from 'react-router-dom'; + +import { ITEM_PATH } from '../Routes'; +export const AuctionRow = ({ + name, + agiModifier, + hitPointModifier, + intModifier, + minLevel, + strModifier, + emoji, + tokenId, + floor, + itemClass, +}: { + name: string; + image: string; + description: string; + agiModifier: string; + hitPointModifier: string; + intModifier: string; + minLevel: string; + strModifier: string; + emoji: string; + tokenId: string; + floor: string; + itemClass: string; +}): JSX.Element => { + const navigate = useNavigate(); + + return ( + navigate(`${ITEM_PATH}${tokenId}`)} + w="100%" + _hover={{ + cursor: 'pointer', + button: { + bgColor: 'grey300', + }, + }} + _active={{ + button: { + bgColor: 'grey400', + }, + }} + > + + + {emoji} + + + + {name} + + + HP {hitPointModifier} • STR {strModifier} • AGI {agiModifier} • INT{' '} + {intModifier} + + + + + + +
+ {itemClass == '0' && } + {itemClass == '1' && } + {itemClass == '2' && } +
+
+ + {Number(minLevel).toLocaleString()} + + + {Number(floor) == 0 ? 'N/A' : Number(floor).toLocaleString()} + +
+ + + +
+
+ ); +}; diff --git a/packages/client/src/components/DevTools.ts b/packages/client/src/components/DevTools.ts index a1a337381..f606dcca2 100644 --- a/packages/client/src/components/DevTools.ts +++ b/packages/client/src/components/DevTools.ts @@ -1,8 +1,10 @@ import mudConfig from 'contracts/mud.config'; +import auctionSystemAbi from 'contracts/out/AuctionSystem.sol/AuctionSystem.abi.json'; import characterSystemAbi from 'contracts/out/CharacterSystem.sol/CharacterSystem.abi.json'; import combatSystemAbi from 'contracts/out/CombatSystem.sol/CombatSystem.abi.json'; import encounterSystemAbi from 'contracts/out/EncounterSystem.sol/EncounterSystem.abi.json'; import equipmentSystemAbi from 'contracts/out/EquipmentSystem.sol/EquipmentSystem.abi.json'; +import worldAbi from 'contracts/out/IWorld.sol/IWorld.abi.json'; import mapSystemAbi from 'contracts/out/MapSystem.sol/MapSystem.abi.json'; import { useEffect, useMemo } from 'react'; @@ -20,6 +22,8 @@ export function DevTools(): null { ...encounterSystemAbi, ...equipmentSystemAbi, ...mapSystemAbi, + ...auctionSystemAbi, + ...worldAbi, ], [network.worldContract.abi], ); diff --git a/packages/client/src/components/EditCharacterModal.tsx b/packages/client/src/components/EditCharacterModal.tsx index 906a63b9c..07b8b9587 100644 --- a/packages/client/src/components/EditCharacterModal.tsx +++ b/packages/client/src/components/EditCharacterModal.tsx @@ -59,7 +59,6 @@ export const EditCharacterModal: React.FC = ({ setFile: setAvatar, onUpload, isUploading, - isUploaded, } = useUploadFile({ fileName: 'characterAvatar' }); useEffect(() => { @@ -223,14 +222,14 @@ export const EditCharacterModal: React.FC = ({ setAvatar(e.target.files?.[0] ?? null)} style={{ display: 'none' }} type="file" /> {' '} + + + + + ); +}; diff --git a/packages/client/src/components/StatsPanel.tsx b/packages/client/src/components/StatsPanel.tsx index c02dda166..a1bf1f12e 100644 --- a/packages/client/src/components/StatsPanel.tsx +++ b/packages/client/src/components/StatsPanel.tsx @@ -20,7 +20,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { useCharacter } from '../contexts/CharacterContext'; import { useMUD } from '../contexts/MUDContext'; -import { LEADERBOARD_PATH } from '../Routes'; +import { AUCTION_HOUSE_PATH, LEADERBOARD_PATH } from '../Routes'; import { MAX_EQUIPPED_ARMOR, MAX_EQUIPPED_WEAPONS } from '../utils/constants'; import { Level } from './Level'; @@ -242,6 +242,7 @@ export const StatsPanel = (): JSX.Element => { ('0'); const [isDepositing, setIsDepositing] = useState(false); @@ -57,19 +71,55 @@ export const WalletDetailsModal = ({ string | null >(null); + const [goldAllowance, setGoldAllowance] = useState('100'); + const [isApprovingGold, setIsApprovingGold] = useState(false); + const [goldErrorMessage, setGoldErrorMessage] = useState(null); + + const [itemsApprovedInitial, setItemsApprovedInitial] = useState< + boolean | null + >(null); + const [itemAllowed, setItemAllowed] = useState(false); + const [isApprovingItems, setIsApprovingItems] = useState(false); + // Reset errorMessage state when any of the form fields change useEffect(() => { setDepositErrorMessage(null); setWithdrawErrorMessage(null); - }, [depositAmount, withdrawAmount]); + setGoldErrorMessage(null); + }, [depositAmount, withdrawAmount, goldAllowance]); useEffect(() => { if (isOpen) { setDepositAmount('0'); setWithdrawAmount('0'); + refetch(); + + setGoldAllowance('100'); + if (externalWalletClient && itemsApprovedInitial == null) { + (async function () { + const auction = await worldContract.read.UD__auctionHouseAddress(); + const t = await publicClient.readContract({ + address: itemsContract as Address, + abi: ERC_1155ABI, + functionName: 'isApprovedForAll', + args: [externalWalletClient.account.address, auction as Address], + }); + setItemAllowed(t as boolean); + setItemsApprovedInitial(true); + })(); + } } - }, [isOpen, refetch]); + }, [ + externalWalletClient, + isOpen, + itemsApprovedInitial, + itemsContract, + publicClient, + refetch, + walletClient.account, + worldContract.read, + ]); const onDeposit = useCallback(async () => { try { @@ -146,6 +196,77 @@ export const WalletDetailsModal = ({ walletClient, withdrawAmount, ]); + const onGoldAllowance = useCallback(async () => { + try { + if (!externalWalletClient) { + throw new Error('No external wallet client found.'); + } + + setIsApprovingGold(true); + if (!goldAllowance || parseEther(goldAllowance) <= 0) { + setGoldErrorMessage('Amount must be greater than 0.'); + return; + } + + const auction = await worldContract.read.UD__auctionHouseAddress(); + + const { request } = await publicClient.simulateContract({ + address: goldToken as Address, + abi: erc20Abi, + functionName: 'approve', + args: [auction, parseEther(goldAllowance)], + }); + await externalWalletClient.writeContract(request); + + setGoldAllowance(goldAllowance); + renderSuccess('Gold allowance successfully set!'); + } catch (e) { + renderError((e as Error)?.message ?? 'Error setting gold allowance.', e); + } finally { + setIsApprovingGold(false); + } + }, [ + externalWalletClient, + goldAllowance, + goldToken, + publicClient, + renderError, + renderSuccess, + worldContract.read, + ]); + const onItemsApproved = useCallback(async () => { + try { + if (!externalWalletClient) { + throw new Error('No external wallet client found.'); + } + + setIsApprovingItems(true); + const auction = await worldContract.read.UD__auctionHouseAddress(); + + const { request } = await publicClient.simulateContract({ + address: itemsContract as Address, + abi: ERC_1155ABI, + functionName: 'setApprovalForAll', + args: [auction as Address, !itemAllowed], + }); + await externalWalletClient.writeContract(request); + setItemAllowed(!itemAllowed); + setIsApprovingItems(false); + renderSuccess('Item allowance successfully set!'); + } catch (e) { + renderError((e as Error)?.message ?? 'Error setting item allowance.', e); + } finally { + setIsApprovingItems(false); + } + }, [ + externalWalletClient, + itemAllowed, + itemsContract, + publicClient, + renderError, + renderSuccess, + worldContract.read, + ]); return ( @@ -243,6 +364,51 @@ export const WalletDetailsModal = ({ Withdraw + + + + Set Auction House gold allowance + + {!!goldErrorMessage && ( + + {goldErrorMessage} + + )} + setGoldAllowance(e.target.value)} + placeholder="Amount" + type="number" + value={goldAllowance} + /> + + + + + + + Set Auction House item approval + + {!itemsApprovedInitial ? ( + + + + ) : ( + + )} + + ) : ( diff --git a/packages/client/src/pages/AuctionHouse.tsx b/packages/client/src/pages/AuctionHouse.tsx new file mode 100644 index 000000000..c018c343d --- /dev/null +++ b/packages/client/src/pages/AuctionHouse.tsx @@ -0,0 +1,610 @@ +import { + Box, + Button, + Center, + Flex, + HStack, + Input, + InputGroup, + InputLeftElement, + Spinner, + Stack, + Text, + VStack, +} from '@chakra-ui/react'; +import { useComponentValue } from '@latticexyz/react'; +import { getComponentValueStrict, Has, runQuery } from '@latticexyz/recs'; +import { + decodeEntity, + encodeEntity, + singletonEntity, +} from '@latticexyz/store-sync/recs'; +import FuzzySearch from 'fuzzy-search'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FaSearch, FaSortAmountDown, FaSortAmountUp } from 'react-icons/fa'; +import { FaBackwardStep, FaForwardStep } from 'react-icons/fa6'; +import { IoCaretBack, IoCaretForward } from 'react-icons/io5'; +import { useNavigate } from 'react-router-dom'; +import { Address, maxUint256 } from 'viem'; +import { useAccount } from 'wagmi'; + +import { AuctionRow } from '../components/AuctionRow'; +import { useMUD } from '../contexts/MUDContext'; +import { useToast } from '../hooks/useToast'; +import { HOME_PATH } from '../Routes'; +import { fetchMetadataFromUri, getEmoji, uriToHttp } from '../utils/helpers'; +import { + ArmorStats, + ConsiderationData, + Item, + ItemType, + OfferData, + Order, + StatsClasses, + WeaponStats, +} from '../utils/types'; + +interface Price { + tokenId: string; + floor: string; + ceiling: string; +} +const PER_PAGE = 5; + +export const AuctionHouse = (): JSX.Element => { + const { renderError } = useToast(); + const navigate = useNavigate(); + const { isConnected } = useAccount(); + + const { + components: { + UltimateDominionConfig, + Items, + ItemsBaseURI, + ItemsOwners, + ItemsTokenURI, + Offers, + }, + + network: { worldContract }, + } = useMUD(); + + const [isFetchingOrders, setIsFetchingOrders] = useState(false); + const [isFetchingItems, setIsFetchingItems] = useState(false); + const [items, setItems] = useState(null); + const [prices, setPrices] = useState(null); + + const [entries, setEntries] = useState([]); + const [, setOrders] = useState(null); + + const [sort, setSort] = useState({ sorted: 'byClass', reversed: false }); + const [filter, setFilter] = useState({ filtered: 'all' }); + const [query, setQuery] = useState(''); + const [page, setPage] = useState('1'); + const [pageLimit, setPageLimit] = useState(0); + + const { goldToken } = useComponentValue( + UltimateDominionConfig, + singletonEntity, + ) ?? { goldToken: null }; + + const fetchOrders = useCallback(async () => { + try { + setIsFetchingOrders(true); + setPrices(null); + setOrders( + await Promise.all( + Array.from(runQuery([Has(Offers)])).map(async orderHash => { + const offerData = await worldContract.read.UD__getOffer([ + orderHash as Address, + ]); + const considerationData = + await worldContract.read.UD__getConsideration([ + orderHash as Address, + ]); + const orderStatus = await worldContract.read.UD__getOrderStatus([ + orderHash as Address, + ]); + const price = { + tokenId: + considerationData.token == goldToken + ? offerData.identifier.toString() + : considerationData.identifier.toString(), + floor: '0', + ceiling: maxUint256.toString(), + } as Price; + if (considerationData.token == goldToken && orderStatus == 1) { + price.floor = BigInt( + considerationData.amount / offerData.amount < + BigInt(price.floor) + ? considerationData.amount / offerData.amount + : BigInt(price.floor), + ).toString(); + } + if (offerData.token == goldToken && orderStatus == 1) { + price.ceiling = BigInt( + offerData.amount / considerationData.amount > + BigInt(price.ceiling) + ? offerData.amount / considerationData.amount + : BigInt(price.ceiling), + ).toString(); + } + if (price != null) setPrices([price]); + else { + setPrices(prev => [...(prev as []), price]); + } + return { + orderHash: orderHash.toString(), + orderStatus: orderStatus.toString(), + offer: { + amount: offerData.amount.toString(), + identifier: offerData.identifier.toString(), + token: offerData.token.toString(), + tokenType: offerData.tokenType.toString(), + } as OfferData, + consideration: { + amount: considerationData.amount.toString(), + identifier: considerationData.identifier.toString(), + token: considerationData.token.toString(), + tokenType: considerationData.tokenType.toString(), + recipient: considerationData.recipient.toString(), + } as ConsiderationData, + } as Order; + }), + ), + ); + } catch (err) { + renderError('Could not get order data.'); + } finally { + setIsFetchingOrders(false); + } + }, [Offers, goldToken, renderError, worldContract.read]); + + const fetchItems = useCallback(async () => { + try { + setIsFetchingItems(true); + const _items = Array.from(runQuery([Has(ItemsOwners)])) + .map(entity => { + const { owner, tokenId } = decodeEntity( + { owner: 'address', tokenId: 'uint256' }, + entity, + ); + + const tokenIdEntity = encodeEntity( + { tokenId: 'uint256' }, + { tokenId }, + ); + + const itemTemplate = getComponentValueStrict(Items, tokenIdEntity); + + return { + itemId: entity, + itemType: itemTemplate.itemType, + owner, + tokenId: tokenId.toString(), + tokenIdEntity, + }; + }) + .filter( + (item1, i, arr) => + arr.findIndex(item2 => item2.tokenId === item1.tokenId) === i, + ) + .sort((a, b) => { + return Number(a.tokenId) - Number(b.tokenId); + }); + const allItems = await Promise.all( + _items.map(async item => { + const baseURI = getComponentValueStrict( + ItemsBaseURI, + singletonEntity, + ).uri; + + const tokenURI = getComponentValueStrict( + ItemsTokenURI, + item.tokenIdEntity, + ).uri; + + const metadata = await fetchMetadataFromUri( + uriToHttp(`${baseURI}${tokenURI}`)[0], + ); + const data = { + ...metadata, + tokenId: item.tokenId, + itemType: item.itemType as ItemType, + stats: null, + class: StatsClasses.Mage, + } as Item; + switch (item.itemType) { + case ItemType.Weapon: { + const w = await worldContract.read.UD__getWeaponStats([ + BigInt(item.tokenId), + ]); + const highestStat = Math.max( + ...Object.values({ + a: Number(w.agiModifier.toString()), + i: Number(w.intModifier.toString()), + s: Number(w.strModifier.toString()), + }), + ); + data.class = + w.strModifier.toString() == highestStat.toString() + ? StatsClasses.Warrior + : data.class; + data.class = + w.agiModifier.toString() == highestStat.toString() + ? StatsClasses.Rogue + : data.class; + data.class = + w.intModifier.toString() == highestStat.toString() + ? StatsClasses.Mage + : data.class; + data.stats = { + agiModifier: w.agiModifier.toString(), + classRestrictions: w.classRestrictions.map( + (classRestriction: number) => + classRestriction as StatsClasses, + ), + hitPointModifier: w.hitPointModifier.toString(), + intModifier: w.intModifier.toString(), + itemId: item.itemId, + maxDamage: w.maxDamage.toString(), + minDamage: w.minDamage.toString(), + minLevel: w.minLevel.toString(), + owner: item.owner, + strModifier: w.strModifier.toString(), + tokenId: item.tokenId, + } as WeaponStats; + break; + } + case ItemType.Armor: { + const a = await worldContract.read.UD__getArmorStats([ + BigInt(item.tokenId), + ]); + const highestStat = Math.max( + ...Object.values({ + a: Number(a.agiModifier.toString()), + i: Number(a.intModifier.toString()), + s: Number(a.strModifier.toString()), + }), + ); + data.class = + a.strModifier.toString() == highestStat.toString() + ? StatsClasses.Warrior + : data.class; + data.class = + a.agiModifier.toString() == highestStat.toString() + ? StatsClasses.Rogue + : data.class; + data.class = + a.intModifier.toString() == highestStat.toString() + ? StatsClasses.Mage + : data.class; + + data.stats = { + armorModifier: a.armorModifier.toString(), + agiModifier: a.agiModifier.toString(), + classRestrictions: a.classRestrictions.map( + (classRestriction: number) => + classRestriction as StatsClasses, + ), + hitPointModifier: a.hitPointModifier.toString(), + intModifier: a.intModifier.toString(), + itemId: item.itemId, + minLevel: a.minLevel.toString(), + owner: item.owner, + strModifier: a.strModifier.toString(), + tokenId: item.tokenId, + } as ArmorStats; + break; + } + default: + break; + } + return data as Item; + }), + ); + setItems(allItems); + } catch (err) { + renderError('Failed to load items'); + } finally { + setIsFetchingItems(false); + } + }, [ + Items, + ItemsBaseURI, + ItemsOwners, + ItemsTokenURI, + renderError, + worldContract.read, + ]); + + useEffect(() => { + if (!isConnected) { + navigate(HOME_PATH); + window.location.reload(); + } + }, [isConnected, navigate]); + + const pageNumber = useMemo(() => { + if (isNaN(Number(page))) { + return 1; + } + return Number(page); + }, [page]); + + useEffect(() => { + (async (): Promise => { + await fetchItems(); + await fetchOrders(); + })(); + }, [fetchItems, fetchOrders]); + + useEffect(() => { + if (pageNumber < 1) { + return; + } + let entriesCopy: Item[] = items ? items : Array(); + entriesCopy = [...entriesCopy].sort((entryA, entryB) => { + let result = false; + const floorA = + prices?.filter(price => entryA.tokenId == price.tokenId)[0]?.floor || + '0'; + const floorB = + prices?.filter(price => entryB.tokenId == price.tokenId)[0]?.floor || + '0'; + switch (sort.sorted) { + case 'byClass': + result = sort.reversed + ? entryA.class.toString().localeCompare(entryB.class.toString()) > 0 + : entryB.class.toString().localeCompare(entryA.class.toString()) > + 0; + break; + case 'byLevel': + result = sort.reversed + ? BigInt(entryA?.stats?.minLevel || '0') >= + BigInt(entryB?.stats?.minLevel || '0') + : BigInt(entryB?.stats?.minLevel || '0') > + BigInt(entryA?.stats?.minLevel || '0'); + break; + case 'byPrice': + result = sort.reversed + ? BigInt(floorA) >= BigInt(floorB) + : BigInt(floorB) > BigInt(floorA); + break; + default: + result = + entryA.class.toString().localeCompare(entryB.class.toString()) > 0; + } + return result ? 1 : -1; + }); + entriesCopy = [...entriesCopy].filter(entry => { + switch (filter.filtered) { + case 'byArmor': + return entry.itemType == ItemType.Armor; + case 'byWeapon': + return entry.itemType == ItemType.Weapon; + default: + return true; + } + }); + const searcher = new FuzzySearch( + [...entriesCopy], + ['name', 'description'], + { caseSensitive: false }, + ); + entriesCopy = searcher.search(query); + const _pageLimit = + Math.floor(Math.ceil(entriesCopy.length / PER_PAGE)) || 1; + setPageLimit(_pageLimit); + setEntries( + entriesCopy.slice((pageNumber - 1) * PER_PAGE, pageNumber * PER_PAGE), + ); + + if (pageNumber > _pageLimit) { + setPage(_pageLimit.toString()); + } + }, [ + filter.filtered, + items, + pageNumber, + prices, + query, + sort.reversed, + sort.sorted, + ]); + + if (isFetchingItems || isFetchingOrders) { + return ( +
+ +
+ ); + } + + return ( + + + + + + + setQuery(e.target.value)} + placeholder="Search" + value={query} + /> + + + + {Object.values(ItemType) + .filter( + key => !isNaN(Number(ItemType[key as keyof typeof ItemType])), + ) + .sort() + .filter(key => + items + ? items?.filter( + item => + item.itemType == ItemType[key as keyof typeof ItemType], + )?.length > 0 + : false, + ) + .map(k => { + return ( + + ); + })} + + + + Items {entries.length} + + + {['byClass', 'byLevel', 'byPrice'].map(s => { + return ( + + ); + })} + + + + + + + {entries && entries.length > 0 ? ( + entries.map(function (item, i) { + return ( + price.tokenId == item.tokenId) + .length > 0 + ? prices?.filter(price => price.tokenId == item.tokenId)[0] + .floor + : '0' + } + {...item} + {...item.stats} + emoji={getEmoji(item?.name as string)} + itemClass={item.class.toString()} + image={item.image} + /> + ); + }) + ) : ( + No items + )} + + 0 ? 'visible' : 'hidden'} + > + + + { + const value = e.target.value; + if (value === '') { + setPage(value); + return; + } + if (isNaN(Number(value))) { + return; + } + if (Number(value) < 1) { + return; + } + if (Number(value) > pageLimit) { + return; + } + setPage(value); + }} + p={2} + size="sm" + value={page} + w={10} + /> + of {pageLimit} + + + + + ); +}; diff --git a/packages/client/src/pages/AuctionItem.tsx b/packages/client/src/pages/AuctionItem.tsx new file mode 100644 index 000000000..7cbb368b8 --- /dev/null +++ b/packages/client/src/pages/AuctionItem.tsx @@ -0,0 +1,786 @@ +import { + Avatar, + Box, + Button, + Center, + Grid, + GridItem, + Heading, + HStack, + Image, + Input, + InputGroup, + InputLeftAddon, + Skeleton, + Spacer, + Stack, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, +} from '@chakra-ui/react'; +import { useComponentValue } from '@latticexyz/react'; +import { getComponentValueStrict, Has, runQuery } from '@latticexyz/recs'; +import { + decodeEntity, + encodeEntity, + singletonEntity, +} from '@latticexyz/store-sync/recs'; +import worldAbi from 'contracts/out/IWorld.sol/IWorld.abi.json'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Address, erc20Abi, formatEther, maxUint256, parseEther } from 'viem'; +import { useWalletClient } from 'wagmi'; + +import { ItemCard } from '../components/ItemCard'; +import { OrderRow } from '../components/OrderRow'; +import { useCharacter } from '../contexts/CharacterContext'; +import { useMUD } from '../contexts/MUDContext'; +import { useToast } from '../hooks/useToast'; +import { AUCTION_HOUSE_PATH } from '../Routes'; +import { ERC_1155ABI } from '../utils/constants'; +import { + fetchMetadataFromUri, + getEmoji, + removeEmoji, + uriToHttp, +} from '../utils/helpers'; +import { + ArmorStats, + Character, + ConsiderationData, + Item, + ItemType, + OfferData, + Order, + StatsClasses, + WeaponStats, +} from '../utils/types'; + +export const AuctionItem = (): JSX.Element => { + const { data: externalWalletClient } = useWalletClient(); + + const { renderSuccess, renderError } = useToast(); + const navigate = useNavigate(); + const params = useParams(); + + const { character: userCharacter } = useCharacter(); + const [current, setCurrent] = useState(null); + const [currentBalance, setCurrentBalance] = useState('0'); + const [floor, setFloor] = useState(maxUint256); + const [ceiling, setCeiling] = useState(0n); + const [isSelling, setIsSelling] = useState(false); + const [offerAmount, setOfferAmount] = useState('1'); + const [offerPrice, setOfferPrice] = useState('1'); + const [listingAmount, setListingAmount] = useState('1'); + const [listingPrice, setListingPrice] = useState('1'); + const [itemType, setItemType] = useState(null); + const [goldAllowance, setGoldAllowance] = useState(null); + const [itemAllowance, setItemAllowance] = useState(null); + const [auctionContractAddress, setAuctionContractAddress] = useState(''); + const [orders, setOrders] = useState(null); + + const { + components: { + UltimateDominionConfig, + Characters, + Items, + ItemsBaseURI, + ItemsOwners, + ItemsTokenURI, + Offers, + }, + network: { worldContract, publicClient }, + } = useMUD(); + const { goldToken } = useComponentValue( + UltimateDominionConfig, + singletonEntity, + ) ?? { goldToken: null }; + const { items: itemsContract } = useComponentValue( + UltimateDominionConfig, + singletonEntity, + ) ?? { items: null }; + + const _sell = async function ( + wanted: Item | bigint, + offered: Item | bigint, + purchaser: Character, + amount: bigint, + ) { + if (!externalWalletClient) { + renderError('Wallet not connected.'); + return; + } + try { + setIsSelling(true); + // this is covering both selling an item for gold or gold for an item + // wanted is either an item or a bigint (representing a gold amount) + const { request } = await publicClient.simulateContract({ + address: worldContract.address, + abi: worldAbi, + functionName: 'UD__createOrder', + account: externalWalletClient.account, + args: [ + { + signature: '' as Address, + offerer: purchaser.owner as Address, + offer: { + // 1 is ERC20 in the contracts. 3 is ERC1155 + tokenType: typeof offered === 'bigint' ? 1 : 3, + token: + typeof offered === 'bigint' + ? (goldToken as Address) + : (itemsContract as Address), + // Identifier will be ignored if it's a bigint (representing gold), otherwise it represents the ERC1155 token ID + identifier: + typeof offered === 'bigint' + ? 0n + : BigInt((offered as Item).tokenId), + // Amount is the amount of the ERC1155 token that is offered. For ERC20, use the offered value + amount: typeof offered === 'bigint' ? offered : amount, + }, + consideration: { + // 1 is ERC20 in the contracts. 3 is ERC1155 + tokenType: typeof wanted === 'bigint' ? 1 : 3, + token: + // Identifier will be ignored if it's a bigint (representing gold), otherwise it represents the ERC1155 token ID + typeof wanted === 'bigint' + ? (goldToken as Address) + : (itemsContract as Address), + identifier: + typeof wanted === 'bigint' + ? 0n + : BigInt((wanted as Item).tokenId), + // Amount is the amount of the ERC1155 token that is wanted. For ERC20, use the offered value + amount: typeof wanted === 'bigint' ? wanted : amount, + recipient: purchaser.owner as Address, + }, + }, + ], + }); + await externalWalletClient?.writeContract(request); + renderSuccess('Order placed successfully!'); + } catch (e) { + renderError((e as Error)?.message ?? 'Error placing order.', e); + } finally { + setIsSelling(false); + } + }; + const sellItem = async function ( + amount: string | number, + price: string | number, + ) { + if ( + !params.itemId || + !currentBalance || + Number(amount) > Number(currentBalance) + ) { + renderError('You do not have enough items to sell'); + } else if (current && userCharacter) { + _sell( + parseEther(price.toString()), + current, + userCharacter, + BigInt(amount), + ); + } + }; + const orderItem = async function (amount: string, price: string) { + if ( + !params.itemId || + goldAllowance == null || + goldAllowance < parseEther(price.toString()) + ) { + renderError('Approve more funds in your wallet details'); + } else if (current && userCharacter) { + _sell( + current, + parseEther(price.toString()), + userCharacter, + BigInt(amount), + ); + } + }; + + const fetchCharacterItems = useCallback( + async (character: Character) => { + try { + Array.from(runQuery([Has(ItemsOwners)])) + .map(entity => { + const itemBalance = getComponentValueStrict( + ItemsOwners, + entity, + ).balance; + + const { owner, tokenId } = decodeEntity( + { owner: 'address', tokenId: 'uint256' }, + entity, + ); + + const tokenIdEntity = encodeEntity( + { tokenId: 'uint256' }, + { tokenId }, + ); + + const itemTemplate = getComponentValueStrict(Items, tokenIdEntity); + + return { + balance: itemBalance.toString(), + itemId: entity, + itemType: itemTemplate.itemType, + owner, + tokenId: tokenId.toString(), + tokenIdEntity, + }; + }) + + .filter( + item => + item.owner === character?.owner && + ItemType[item.itemType] === itemType && + item.tokenId === params.itemId, + ) + .sort((a, b) => { + return Number(a.tokenId) - Number(b.tokenId); + }) + .map(item => { + setCurrentBalance(item.balance.toString()); + switch (ItemType[item.itemType]) { + case 'Weapon': { + setCurrent({ + ...current, + } as Item); + break; + } + case 'Armor': { + setCurrent({ + ...current, + } as Item); + break; + } + default: + break; + } + return item; + }); + } catch (e) { + renderError('Failed to fetch character data.', e); + } finally { + /* empty */ + } + }, + [Items, ItemsOwners, current, itemType, params.itemId, renderError], + ); + const fetchOrders = useCallback(async () => { + setOrders( + await Promise.all( + Array.from(runQuery([Has(Offers)])).map(async orderHash => { + const offerData = await worldContract.read.UD__getOffer([ + orderHash as Address, + ]); + const considerationData = + await worldContract.read.UD__getConsideration([ + orderHash as Address, + ]); + const orderStatus = await worldContract.read.UD__getOrderStatus([ + orderHash as Address, + ]); + if (considerationData.token == goldToken) { + setFloor( + BigInt( + considerationData.amount / offerData.amount < floor + ? considerationData.amount / offerData.amount + : floor, + ), + ); + } + if (offerData.token == goldToken) { + setCeiling( + BigInt( + offerData.amount / considerationData.amount > ceiling + ? offerData.amount / considerationData.amount + : ceiling, + ), + ); + } + + return { + orderHash: orderHash.toString(), + orderStatus: orderStatus.toString(), + offer: { + amount: offerData.amount.toString(), + identifier: offerData.identifier.toString(), + token: offerData.token.toString(), + tokenType: offerData.tokenType.toString(), + } as OfferData, + consideration: { + amount: considerationData.amount.toString(), + identifier: considerationData.identifier.toString(), + token: considerationData.token.toString(), + tokenType: considerationData.tokenType.toString(), + recipient: considerationData.recipient.toString(), + } as ConsiderationData, + } as Order; + }), + ), + ); + }, [Offers, ceiling, floor, goldToken, worldContract.read]); + const fetchCurrent = useCallback(async () => { + const _items = Array.from(runQuery([Has(ItemsOwners)])) + .map(entity => { + const { owner, tokenId } = decodeEntity( + { owner: 'address', tokenId: 'uint256' }, + entity, + ); + + const tokenIdEntity = encodeEntity({ tokenId: 'uint256' }, { tokenId }); + + const itemTemplate = getComponentValueStrict(Items, tokenIdEntity); + return { + itemId: entity, + itemType: itemTemplate.itemType, + owner, + tokenId: tokenId.toString(), + tokenIdEntity, + }; + }) + .filter(item => item.tokenId == params.itemId); + await Promise.all( + _items.map(async item => { + setItemType(ItemType[item.itemType]); + const itemData = { + TokenId: item.tokenId, + ItemType: item.itemType as ItemType, + Weapon: null as WeaponStats | null, + Armor: null as ArmorStats | null, + }; + const baseURI = getComponentValueStrict( + ItemsBaseURI, + singletonEntity, + ).uri; + + const tokenURI = getComponentValueStrict( + ItemsTokenURI, + item.tokenIdEntity, + ).uri; + + const metadata = await fetchMetadataFromUri( + uriToHttp(`${baseURI}${tokenURI}`)[0], + ); + switch (item.itemType) { + case ItemType.Weapon: { + const w = await worldContract.read.UD__getWeaponStats([ + BigInt(item.tokenId), + ]); + setCurrent({ + ...metadata, + itemId: item.itemId, + itemType: item.itemType, + tokenId: item.tokenId, + class: 1, + stats: (itemData.Weapon = { + agiModifier: w.agiModifier.toString(), + classRestrictions: w.classRestrictions.map( + (classRestriction: number) => + classRestriction as StatsClasses, + ), + hitPointModifier: w.hitPointModifier.toString(), + intModifier: w.intModifier.toString(), + itemId: item.itemId, + maxDamage: w.maxDamage.toString(), + minDamage: w.minDamage.toString(), + minLevel: w.minLevel.toString(), + owner: item.owner, + strModifier: w.strModifier.toString(), + tokenId: item.tokenId, + } as WeaponStats), + }); + break; + } + case ItemType.Armor: { + const a = await worldContract.read.UD__getArmorStats([ + BigInt(item.tokenId), + ]); + setCurrent({ + ...metadata, + itemId: item.itemId, + itemType: item.itemType, + tokenId: item.tokenId, + class: 1, + stats: { + armorModifier: a.armorModifier.toString(), + agiModifier: a.agiModifier.toString(), + classRestrictions: a.classRestrictions.map( + (classRestriction: number) => + classRestriction as StatsClasses, + ), + hitPointModifier: a.hitPointModifier.toString(), + intModifier: a.intModifier.toString(), + itemId: item.itemId, + minLevel: a.minLevel.toString(), + owner: item.owner, + strModifier: a.strModifier.toString(), + tokenId: item.tokenId, + } as ArmorStats, + }); + break; + } + default: + break; + } + return itemData; + }), + ); + }, [ + Items, + ItemsBaseURI, + ItemsOwners, + ItemsTokenURI, + params.itemId, + worldContract.read, + ]); + useEffect(() => { + (async function () { + setAuctionContractAddress( + await worldContract.read.UD__auctionHouseAddress(), + ); + await fetchCurrent(); + await fetchOrders(); + if (userCharacter && itemType && current) { + await fetchCharacterItems(userCharacter); + } + if (auctionContractAddress && userCharacter) { + setGoldAllowance( + await publicClient.readContract({ + address: goldToken as Address, + abi: erc20Abi, + functionName: 'allowance', + args: [ + userCharacter?.owner as Address, + auctionContractAddress as Address, + ], + }), + ); + } + if (itemAllowance == null && auctionContractAddress && userCharacter) { + setItemAllowance( + (await publicClient.readContract({ + address: itemsContract as Address, + abi: ERC_1155ABI, + functionName: 'isApprovedForAll', + args: [ + userCharacter?.owner as Address, + auctionContractAddress as Address, + ], + })) as boolean, + ); + } + })(); + }, [ + Characters, + auctionContractAddress, + current, + fetchCharacterItems, + fetchCurrent, + fetchOrders, + goldAllowance, + goldToken, + itemAllowance, + itemType, + itemsContract, + publicClient, + userCharacter, + userCharacter?.owner, + worldContract.read, + ]); + return ( + + + + + + + {current != null ? ( + + {current?.name.replace(/[\p{Emoji}\u200d]+/gu, '')} + + ) : ( + + ... + + )} + + +
+ {current != null ? ( + + {(current?.name as string).match(/[\p{Emoji}\u200d]+/gu)} + + ) : ( + + + + )} +
+ {current != null && current.description != null ? ( + {current?.description} + ) : ( + + )} +
+ + + + {current != null && current.stats != null ? ( + [...Object.keys({ ...current.stats })] + .filter(key => + ['itemId', 'owner'].indexOf(key) > -1 ? false : true, + ) + .map((key, i) => ( + + + {key} + + + {current.stats + ? current.stats[ + key as keyof (WeaponStats | ArmorStats) + ] + : ''} + + + + )) + ) : ( + + + INT + + + )}{' '} + + + Floor Price + + + {floor.toString() == maxUint256.toString() + ? 'not enough data' + : formatEther(floor).toString()} + + + + + + Cieling Price + + + {formatEther(ceiling).toString() == '0' + ? 'not enough data' + : formatEther(ceiling).toString()} + + + {' '} + + + + + + Amount + setOfferAmount(e.target.value)} + placeholder="0.00" + type="number" + min={0} + value={offerAmount.toString()} + /> + + + Price + setOfferPrice(e.target.value)} + placeholder="0.00" + type="number" + min={0} + value={offerPrice.toString()} + /> + + + + + + + + + Listing + Offers + Owned + + + + + {orders != null && itemType != null + ? orders + .filter( + item => + item.offer.token == itemsContract && + item.consideration.token == goldToken && + item.offer.identifier == params.itemId, + ) + .filter(item => item.orderStatus == '1') + .map((order, i) => ( + + )) + : ''} + + + + + {orders != null && itemType != null && current != null + ? orders + .filter( + item => + item.offer.token == goldToken && + item.consideration.token == itemsContract && + item.consideration.identifier == params.itemId, + ) + .filter(item => item.orderStatus == '1') + .map((order, i) => ( + + )) + : ''} + + + +
+ + {BigInt(currentBalance) > 0n && + current != null && + current.stats != null && + userCharacter != null && + userCharacter.owner != null ? ( + + ) : ( + '' + )} + +
+ {BigInt(currentBalance) > 0n ? ( + + + + Amount + setListingAmount(e.target.value)} + placeholder="0.00" + type="number" + min={0} + step={1} + max={Number(currentBalance)} + value={listingAmount.toString()} + /> + + + Price + setListingPrice(e.target.value)} + placeholder="0.00" + type="number" + min={0} + value={listingPrice.toString()} + /> + + + + + + ) : ( +
+ None Owned +
+ )} +
+
+
+
+
+
+
+ ); +}; diff --git a/packages/client/src/pages/Character.tsx b/packages/client/src/pages/Character.tsx index 3d38224cd..73daaac16 100644 --- a/packages/client/src/pages/Character.tsx +++ b/packages/client/src/pages/Character.tsx @@ -43,7 +43,7 @@ import { LevelingPanel } from '../components/LevelingPanel'; import { useCharacter } from '../contexts/CharacterContext'; import { useMUD } from '../contexts/MUDContext'; import { useToast } from '../hooks/useToast'; -import { HOME_PATH, LEADERBOARD_PATH } from '../Routes'; +import { AUCTION_HOUSE_PATH, HOME_PATH, LEADERBOARD_PATH } from '../Routes'; import { MAX_EQUIPPED_ARMOR, MAX_EQUIPPED_WEAPONS } from '../utils/constants'; import { decodeArmorStats, @@ -375,7 +375,15 @@ export const CharacterPage = (): JSX.Element => { -