From ddb9b8416a8aa276f5fe4ac30b943992129a460a Mon Sep 17 00:00:00 2001 From: Alec Ananian <1013230+alecananian@users.noreply.github.com> Date: Tue, 9 Jul 2024 16:22:43 -0700 Subject: [PATCH] refactor vault item fetching (#43) * refactor vault item fetches to get correct quantities * code and export clean-up --- app/api/collections.server.ts | 9 +- app/api/pools.server.ts | 4 +- app/api/stats.server.ts | 2 +- app/api/tokens.server.ts | 216 +++++----- app/components/Footer.tsx | 2 +- app/components/Landing/GridTile.tsx | 33 -- app/components/Search.tsx | 16 - app/components/SearchPopup.tsx | 2 +- .../item_selection/SelectionPopup.tsx | 380 ++---------------- app/components/pools/PoolTokenInfo.tsx | 50 --- app/components/ui/Accordion.tsx | 58 --- app/components/ui/Button.tsx | 2 +- app/components/ui/Input.tsx | 2 +- app/components/ui/ScrollArea.tsx | 48 --- app/components/ui/Sheet.tsx | 2 +- app/consts.ts | 2 - app/graphql/token.queries.ts | 26 ++ app/hooks/useContractAddress.ts | 2 +- app/hooks/useVaultItems.ts | 83 ++++ app/lib/og.server.tsx | 5 - app/lib/tokens.server.ts | 2 - app/routes/404.tsx | 2 +- app/routes/faq.tsx | 2 +- app/routes/pools_.$id.tsx | 40 +- app/routes/privacy.tsx | 2 +- .../resources.collections.$slug.filters.ts | 35 -- app/routes/resources.collections.$slug.ts | 45 --- app/routes/resources.vaults.$id.items.ts | 48 +++ app/types.ts | 46 --- package.json | 2 - 30 files changed, 313 insertions(+), 855 deletions(-) delete mode 100644 app/components/Landing/GridTile.tsx delete mode 100644 app/components/Search.tsx delete mode 100644 app/components/pools/PoolTokenInfo.tsx delete mode 100644 app/components/ui/Accordion.tsx delete mode 100644 app/components/ui/ScrollArea.tsx create mode 100644 app/hooks/useVaultItems.ts delete mode 100644 app/routes/resources.collections.$slug.filters.ts delete mode 100644 app/routes/resources.collections.$slug.ts create mode 100644 app/routes/resources.vaults.$id.items.ts diff --git a/app/api/collections.server.ts b/app/api/collections.server.ts index 5cea264..9863b76 100644 --- a/app/api/collections.server.ts +++ b/app/api/collections.server.ts @@ -1,7 +1,7 @@ -import { fetchTroveTokens } from "./tokens.server"; +import { fetchTroveTokenMapping } from "./tokens.server"; import type { Token, TroveCollection, TroveCollectionMapping } from "~/types"; -export const fetchCollections = async (addresses: string[]) => { +const fetchCollections = async (addresses: string[]) => { const url = new URL(`${process.env.TROVE_API_URL}/batch-collections`); url.searchParams.set( "slugs", @@ -42,5 +42,8 @@ export const fetchTokensCollections = async (tokens: Token[]) => { ), ]; - return Promise.all([fetchCollections(addresses), fetchTroveTokens(tokenIds)]); + return Promise.all([ + fetchCollections(addresses), + fetchTroveTokenMapping(tokenIds), + ]); }; diff --git a/app/api/pools.server.ts b/app/api/pools.server.ts index 3fdae34..8451cd1 100644 --- a/app/api/pools.server.ts +++ b/app/api/pools.server.ts @@ -11,7 +11,7 @@ import { } from "../../.graphclient"; import { fetchTokensCollections } from "./collections.server"; import { fetchMagicUSD } from "./stats.server"; -import { fetchTroveTokens } from "./tokens.server"; +import { fetchTroveTokenMapping } from "./tokens.server"; import { uniswapV2PairAbi } from "~/generated"; import { client } from "~/lib/chain.server"; import type { Pool } from "~/lib/pools.server"; @@ -25,7 +25,7 @@ export const fetchTransactions = async (pool: Pool) => { })) as ExecutionResult; const { transactions = [] } = result.data ?? {}; - const tokens = await fetchTroveTokens([ + const tokens = await fetchTroveTokenMapping([ ...new Set([ ...transactions.flatMap((transaction) => [ ...(transaction.items0?.map( diff --git a/app/api/stats.server.ts b/app/api/stats.server.ts index c278c1a..04c30cc 100644 --- a/app/api/stats.server.ts +++ b/app/api/stats.server.ts @@ -2,7 +2,7 @@ import type { ExecutionResult } from "graphql"; import { GetStatsDocument, type GetStatsQuery, execute } from ".graphclient"; -export const fetchStats = async () => { +const fetchStats = async () => { const result = (await execute( GetStatsDocument, {} diff --git a/app/api/tokens.server.ts b/app/api/tokens.server.ts index 9ec6fd8..383923d 100644 --- a/app/api/tokens.server.ts +++ b/app/api/tokens.server.ts @@ -5,35 +5,16 @@ import { fetchMagicUSD } from "./stats.server"; import { GetTokenDocument, type GetTokenQuery, + GetTokenVaultReserveItemsDocument, + type GetTokenVaultReserveItemsQuery, GetTokensDocument, type GetTokensQuery, execute, } from ".graphclient"; -import { ITEMS_PER_PAGE } from "~/consts"; import { sumArray } from "~/lib/array"; import { getCachedValue } from "~/lib/cache.server"; import { createPoolToken } from "~/lib/tokens.server"; -import type { - PoolToken, - TraitsResponse, - TroveApiResponse, - TroveToken, - TroveTokenMapping, -} from "~/types"; - -function filterNullValues( - obj: Record -): Record { - const filteredObj: Record = {}; - - for (const [key, value] of Object.entries(obj)) { - if (value !== null) { - filteredObj[key] = value; - } - } - - return filteredObj; -} +import type { PoolToken, TroveToken, TroveTokenMapping } from "~/types"; export const fetchTokens = () => getCachedValue("tokens", async () => { @@ -69,99 +50,7 @@ export const fetchToken = (id: string) => return createPoolToken(rawToken, collectionMapping, tokenMapping, magicUSD); }); -export const fetchFilters = async (slug: string) => { - const response = await fetch( - `${process.env.TROVE_API_URL}/collection/${process.env.TROVE_API_NETWORK}/${slug}/traits`, - { - headers: { - "X-API-Key": process.env.TROVE_API_KEY, - }, - } - ); - - const { traitsMap } = (await response.json()) as TraitsResponse; - - return Object.entries(traitsMap).map(([traitName, traitMetadata]) => { - const isNumeric = - "display_type" in traitMetadata && - (traitMetadata.display_type === "numeric" || - traitMetadata.display_type === "percentage"); - const values = Object.entries(traitMetadata.valuesMap) - .map(([valueName, valueMetadata]) => { - return { - valueName, - count: valueMetadata.valueCount, - valuePriority: valueMetadata.valuePriority ?? 0, - }; - }) - .sort((a, b) => { - if (a.valuePriority !== b.valuePriority) { - return b.valuePriority - a.valuePriority; - } - - return a.valueName.localeCompare(b.valueName, undefined, { - numeric: isNumeric || a.valueName.includes("%"), - }); - }); - - return { - ...traitMetadata, - traitName, - values, - }; - }); -}; - -export type TroveFilters = ReturnType; - -export const fetchCollectionOwnedByAddress = async ( - address: string, - slug: string, - traits: string[], - tokenIds: string[], - query: string | null, - pageKey: string | null, - offset: number -) => { - try { - const response = await fetch( - `${process.env.TROVE_API_URL}/tokens-for-user-page-fc`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-API-Key": process.env.TROVE_API_KEY, - }, - body: JSON.stringify( - filterNullValues({ - userAddress: address, - ...(tokenIds.length > 0 - ? { - ids: tokenIds.map((tokenId) => `${slug}/${tokenId}`), - } - : { - slugs: [slug], - }), - limit: ITEMS_PER_PAGE, - query, - traits, - pageKey, - offset, - }) - ), - } - ); - const result = (await response.json()) as TroveApiResponse; - - return result; - } catch (e) { - throw new Error("Error fetching collection"); - } -}; - -export const fetchTroveTokens = async ( - ids: string[] -): Promise => { +const fetchTroveTokens = async (ids: string[]) => { const response = await fetch(`${process.env.TROVE_API_URL}/batch-tokens`, { method: "POST", headers: { @@ -172,8 +61,13 @@ export const fetchTroveTokens = async ( ids: ids.map((id) => `${process.env.TROVE_API_NETWORK}/${id}`), }), }); - const result = (await response.json()) as TroveToken[]; - return result.reduce((acc, token) => { + const results = (await response.json()) as TroveToken[]; + return results; +}; + +export const fetchTroveTokenMapping = async (ids: string[]) => { + const tokens = await fetchTroveTokens(ids); + return tokens.reduce((acc, token) => { const collection = (acc[token.collectionAddr.toLowerCase()] ??= {}); collection[token.tokenId] = token; return acc; @@ -212,3 +106,91 @@ export const fetchPoolTokenBalance = async ( const result = (await response.json()) as TroveToken[]; return sumArray(result.map((token) => token.queryUserQuantityOwned ?? 0)); }; + +export const fetchVaultUserInventory = async ({ + id, + address, +}: { + id: string; + address: string; +}) => { + // Fetch vault data from subgraph + const result = (await execute(GetTokenDocument, { + id, + })) as ExecutionResult; + const { token } = result.data ?? {}; + if (!token) { + throw new Error("Vault not found"); + } + + // Fetch user inventory + const url = new URL(`${process.env.TROVE_API_URL}/tokens-for-user`); + url.searchParams.append("userAddress", address); + + const tokenIds = + token.vaultCollections.flatMap( + ({ collection: { id: collectionId }, tokenIds }) => + tokenIds?.map( + (tokenId) => + `${process.env.TROVE_API_NETWORK}/${collectionId}/${tokenId}` + ) ?? [] + ) ?? []; + if (tokenIds.length > 0) { + url.searchParams.append("ids", tokenIds.join(",")); + } else { + url.searchParams.append( + "slugs", + token.vaultCollections + .map( + ({ collection: { id: collectionId } }) => + `${process.env.TROVE_API_NETWORK}/${collectionId}` + ) + .join(",") + ); + } + + const response = await fetch(url, { + headers: { + "X-API-Key": process.env.TROVE_API_KEY, + }, + }); + const results = (await response.json()) as TroveToken[]; + return results; +}; + +export const fetchVaultReserveItems = async ({ + id, + page = 1, + itemsPerPage = 25, +}: { + id: string; + page?: number; + itemsPerPage?: number; +}): Promise => { + // Fetch vault reserve items from subgraph + const result = (await execute(GetTokenVaultReserveItemsDocument, { + id, + first: itemsPerPage, + skip: (page - 1) * itemsPerPage, + })) as ExecutionResult; + const { vaultReserveItems = [] } = result.data ?? {}; + + // Create mapping of tokenIds to amount so we use the vault reserves instead of inventory balances + const amountsMapping = vaultReserveItems.reduce( + (acc, { collection: { id: collectionId }, tokenId, amount }) => { + acc[`${collectionId.toLowerCase()}/${tokenId}`] = amount; + return acc; + }, + {} as Record + ); + + // Fetch token metadata + const items = await fetchTroveTokens(Object.keys(amountsMapping)); + return items.map((item) => ({ + ...item, + queryUserQuantityOwned: + amountsMapping[`${item.collectionAddr.toLowerCase()}/${item.tokenId}`] ?? + item.queryUserQuantityOwned ?? + 0, + })); +}; diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 6df6bf3..f44d1f5 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,5 +1,5 @@ +import { Link } from "@remix-run/react"; import { MagicSwapLogo } from "@treasure-project/branding"; -import { Link } from "react-router-dom"; import { Balancer } from "react-wrap-balancer"; import { DiscordIcon, TwitterIcon } from "~/components/Icons"; diff --git a/app/components/Landing/GridTile.tsx b/app/components/Landing/GridTile.tsx deleted file mode 100644 index 95359bc..0000000 --- a/app/components/Landing/GridTile.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; - -import { cn } from "~/lib/utils"; - -interface GridTileProps { - state: "empty" | "filled" | "image"; - src?: string; -} - -export const GridTile = ({ state, src }: GridTileProps) => { - return ( -
- {!!src && a nft} -
- ); -}; - -// interface FullGridProps { - -// } - -// export const FullGrid = () => { -// return ( -//
- -//
-// ) -// } diff --git a/app/components/Search.tsx b/app/components/Search.tsx deleted file mode 100644 index 3bdb884..0000000 --- a/app/components/Search.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { SearchIcon } from "lucide-react"; - -import { cn } from "~/lib/utils"; - -interface Props { - className?: string; -} - -export const Search = ({ className }: Props) => { - return ( -
- -

Quick Search

-
- ); -}; diff --git a/app/components/SearchPopup.tsx b/app/components/SearchPopup.tsx index 5d1397a..b280f15 100644 --- a/app/components/SearchPopup.tsx +++ b/app/components/SearchPopup.tsx @@ -1,6 +1,6 @@ +import { Link } from "@remix-run/react"; import { SearchIcon } from "lucide-react"; import React, { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; // To-Do // Hover effect on cards diff --git a/app/components/item_selection/SelectionPopup.tsx b/app/components/item_selection/SelectionPopup.tsx index 0e1ed69..da416fb 100644 --- a/app/components/item_selection/SelectionPopup.tsx +++ b/app/components/item_selection/SelectionPopup.tsx @@ -1,34 +1,25 @@ -import { useFetcher } from "@remix-run/react"; import { AnimatePresence, motion } from "framer-motion"; import { - ChevronDownIcon, - ChevronRightIcon, TableIcon as ColumnIcon, ExternalLink, LayoutGridIcon as GridIcon, RotateCwIcon as RefreshIcon, - SearchIcon, XIcon, } from "lucide-react"; import React, { useState } from "react"; import { useAccount } from "wagmi"; -import { Badge } from "../Badge"; -import { CheckIcon, FilledFilterIcon, LoaderIcon } from "../Icons"; +import { CheckIcon, LoaderIcon } from "../Icons"; import { PoolTokenImage } from "../pools/PoolTokenImage"; import { Button } from "../ui/Button"; -import { LabeledCheckbox } from "../ui/Checkbox"; import IconToggle from "../ui/IconToggle"; import { NumberSelect } from "../ui/NumberSelect"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/Popover"; import { DialogClose, DialogContent } from "~/components/ui/Dialog"; -import { ITEMS_PER_PAGE } from "~/consts"; import { useTrove } from "~/hooks/useTrove"; +import { useVaultItems } from "~/hooks/useVaultItems"; import { formatNumber } from "~/lib/number"; import { countTokens } from "~/lib/tokens"; import { cn } from "~/lib/utils"; -import type { CollectionLoader } from "~/routes/resources.collections.$slug"; -import type { CollectionFiltersLoader } from "~/routes/resources.collections.$slug.filters"; import type { PoolToken, TroveToken, TroveTokenWithQuantity } from "~/types"; const ItemCard = ({ @@ -127,32 +118,8 @@ const ItemCard = ({ ); }; -const TraitFilterBadge = ({ trait }: { trait: string }) => { - const [key, value] = trait.split(":"); - - return ( - -

- {key}:{" "} - {value} -

- - -
- ); -}; - type BaseProps = { - token?: PoolToken; + token: PoolToken; type: "vault" | "inventory"; }; @@ -171,83 +138,28 @@ type EditableProps = BaseProps & { type Props = ViewOnlyProps | EditableProps; export const SelectionPopup = ({ token, type, ...props }: Props) => { + const { address } = useAccount(); + const { items, isLoading, refetch } = useVaultItems({ + id: token.id, + type: type === "vault" ? "reserves" : "inventory", + address, + enabled: token.isNFT, + }); const [selectedItems, setSelectedItems] = useState( !props.viewOnly ? props.selectedTokens ?? [] : [] ); - const { load, Form, submit, formData, state, data } = - useFetcher(); - const { - load: loadFilters, - state: filtersState, - data: filtersData, - } = useFetcher(); - const { address } = useAccount(); - const traitInfoRef = React.useRef(""); - const queryFormRef = React.useRef(null); - const offsetRef = React.useRef(0); - const [isFilterOpen, setIsFilterOpen] = useState(false); const [isCompactMode, setIsCompactMode] = useState(false); - const collectionTokenIds = token?.collectionTokenIds.join(","); - const ownerAddress = type === "vault" && token?.id ? token.id : address; - const resourcePath = token?.isNFT - ? `/resources/collections/${token.urlSlug}` - : undefined; - const totalQuantity = selectedItems.reduce( - (acc, curr) => (acc += curr.quantity), + const selectedQuantity = selectedItems.reduce( + (acc, curr) => acc + curr.quantity, 0 ); - const selectionDisabled = - !props.viewOnly && props.limit ? totalQuantity >= props.limit : false; + !props.viewOnly && props.limit ? selectedQuantity >= props.limit : false; const buttonDisabled = !props.viewOnly && props.limit - ? totalQuantity < props.limit - : selectedItems.length === 0 - ? true - : false; - - // Save trait string info to a ref, so when a user clicks Refresh, we can use it to refetch the data with the same filters - React.useEffect(() => { - if (formData) { - const traitsInfo = formData.getAll("traits")[0]; - - if (typeof traitsInfo === "string") { - traitInfoRef.current = traitsInfo; - } - } - }, [formData]); - - const fetchCollection = React.useCallback(() => { - if (!ownerAddress || !resourcePath) { - return; - } - - offsetRef.current = 0; - - const params = new URLSearchParams({ - address: ownerAddress, - }); - - if (collectionTokenIds) { - params.set("tokenIds", collectionTokenIds); - } - - if (traitInfoRef.current.length > 0) { - params.set("traits", traitInfoRef.current); - } - - load(`${resourcePath}?${params.toString()}`); - }, [ownerAddress, load, resourcePath, collectionTokenIds]); - - React.useEffect(() => { - if (!resourcePath) { - return; - } - - fetchCollection(); - loadFilters(`${resourcePath}/filters`); - }, [resourcePath, loadFilters, fetchCollection]); + ? selectedQuantity < props.limit + : selectedItems.length === 0; const selectionHandler = (item: TroveTokenWithQuantity) => { if (selectedItems.some((i) => i.tokenId === item.tokenId)) { @@ -263,28 +175,6 @@ export const SelectionPopup = ({ token, type, ...props }: Props) => { } }; - if (!token) return null; - - const filteredList = - filtersData && filtersData.ok ? filtersData.filterList : []; - - const filterWithValues = filteredList.filter((d) => d.values.length > 0); - - const traits = data && data.ok ? data.traits : []; - - const query = data && data.ok ? data.query : null; - - const tokens = data && data.ok ? data.tokens : null; - - const HiddenInputs = ( - <> - - - - ); - - const loading = state === "loading" || state === "submitting"; - return ( {
-
{ - const formData = new FormData(e.currentTarget); - formData.set("query", formData.get("query") || ""); - - submit(formData, { - action: resourcePath, - }); - - offsetRef.current = 0; - }} - onSubmit={(e) => e.preventDefault()} - > - {HiddenInputs} -
- - -
-
{ onChange={(id) => setIsCompactMode(id === "compact")} />
-
{ - // called when user selects a filter - const formData = new FormData(e.currentTarget); - const traits = formData.getAll("traits"); - const traitsString = traits.join(","); - formData.set("traits", traitsString); - - queryFormRef.current?.reset(); - - submit(formData, { - action: resourcePath, - }); - - offsetRef.current = 0; - }} - onSubmit={(e) => { - // onSubmit is only called when a user removes a filter - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const targetTrait = formData.getAll("deleteTrait")[0]; - - const filteredTraits = - data && data.ok - ? data?.traits.filter((t) => t !== targetTrait) - : null; - - if (!filteredTraits) return; - - formData.set("traits", filteredTraits.join(",")); - - submit(formData, { - action: resourcePath, - }); - - offsetRef.current = 0; - }} - > - {HiddenInputs} - -
- - - -
- {traits.map((trait) => ( - - ))} -
-
- - {filtersState === "loading" ? ( -
- -
- ) : filtersState === "idle" && filteredList.length > 0 ? ( -
- {filterWithValues ? ( -
3, - } - )} - > - {filterWithValues.map((filter) => { - return ( -
- - {filter.traitName} - -
- {filter.values.map((value) => { - return ( -
- - - {value.valueName} - - -
- ); - })} -
-
- ); - })} -
- ) : null} -
- ) : null} -
-
-
-
- {loading ? ( + {isLoading ? (
- ) : state === "idle" && data && data.ok ? ( + ) : (
{ : "grid-cols-3 md:grid-cols-4 lg:grid-cols-5" )} > - {data.tokens.tokens.map((item) => ( + {items.map((item) => ( { quantity: !props.viewOnly && props.limit ? Math.min( - props.limit - totalQuantity, + props.limit - selectedQuantity, item.queryUserQuantityOwned ?? 1 ) : 1, @@ -522,77 +258,7 @@ export const SelectionPopup = ({ token, type, ...props }: Props) => { /> ))}
- ) : null} -
-
-
{ - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - offsetRef.current -= ITEMS_PER_PAGE; - formData.set("offset", offsetRef.current.toString()); - - submit(formData, { - action: resourcePath, - }); - }} - > - {HiddenInputs} - {traits.length > 0 && ( - - )} - {query && } - - -
-
{ - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - offsetRef.current += ITEMS_PER_PAGE; - formData.set("offset", offsetRef.current.toString()); - - submit(formData, { - action: resourcePath, - }); - }} - > - {HiddenInputs} - {tokens && tokens.nextPageKey && ( - - )} - {traits.length > 0 && ( - - )} - {query && } - -
+ )}
{!props.viewOnly && ( @@ -693,8 +359,8 @@ export const SelectionPopup = ({ token, type, ...props }: Props) => { onClick={() => props.onSubmit(selectedItems)} > {props.limit && buttonDisabled - ? `Add ${props.limit - totalQuantity} item${ - props.limit - totalQuantity > 1 ? "s" : "" + ? `Add ${props.limit - selectedQuantity} item${ + props.limit - selectedQuantity > 1 ? "s" : "" }` : "Save selections"} diff --git a/app/components/pools/PoolTokenInfo.tsx b/app/components/pools/PoolTokenInfo.tsx deleted file mode 100644 index 6b67b86..0000000 --- a/app/components/pools/PoolTokenInfo.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { GlobeIcon } from "lucide-react"; -import { ClientOnly } from "remix-utils/client-only"; - -import { useBlockExplorer } from "~/hooks/useBlockExplorer"; -import { cn } from "~/lib/utils"; -import type { PoolToken } from "~/types"; - -type Props = { - token: PoolToken; - className?: string; -}; - -export const PoolTokenInfo = ({ token, className }: Props) => { - const blockExplorer = useBlockExplorer(); - return ( -
-
- -
-
- {token.symbol} - - {token.isNFT ? "NFT " : "Token "} - {token.name.toUpperCase() !== token.symbol.toUpperCase() && ( - <>| {token.name} - )} - - {() => ( - - - - )} - - -
-
- ); -}; diff --git a/app/components/ui/Accordion.tsx b/app/components/ui/Accordion.tsx deleted file mode 100644 index 74de309..0000000 --- a/app/components/ui/Accordion.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as AccordionPrimitive from "@radix-ui/react-accordion"; -import { ChevronDown } from "lucide-react"; -import * as React from "react"; - -import { cn } from "~/lib/utils"; - -const Accordion = AccordionPrimitive.Root; - -const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AccordionItem.displayName = "AccordionItem"; - -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - -)); -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; - -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - -
{children}
-
-)); -AccordionContent.displayName = AccordionPrimitive.Content.displayName; - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx index dc487d8..f63e317 100644 --- a/app/components/ui/Button.tsx +++ b/app/components/ui/Button.tsx @@ -36,7 +36,7 @@ const buttonVariants = cva( } ); -export interface ButtonProps +interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps {} diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx index 5b9f764..b103231 100644 --- a/app/components/ui/Input.tsx +++ b/app/components/ui/Input.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { cn } from "~/lib/utils"; -export type InputProps = React.InputHTMLAttributes; +type InputProps = React.InputHTMLAttributes; const Input = React.forwardRef( ({ className, type, ...props }, ref) => { diff --git a/app/components/ui/ScrollArea.tsx b/app/components/ui/ScrollArea.tsx deleted file mode 100644 index 0106c19..0000000 --- a/app/components/ui/ScrollArea.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; -import * as React from "react"; - -import { cn } from "~/lib/utils"; - -const ScrollArea = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - {children} - - - - -)); -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; - -const ScrollBar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, orientation = "vertical", ...props }, ref) => ( - - - -)); -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; - -export { ScrollArea, ScrollBar }; diff --git a/app/components/ui/Sheet.tsx b/app/components/ui/Sheet.tsx index 4b388f1..c8f250d 100644 --- a/app/components/ui/Sheet.tsx +++ b/app/components/ui/Sheet.tsx @@ -136,7 +136,7 @@ const sheetVariants = cva( } ); -export interface DialogContentProps +interface DialogContentProps extends React.ComponentPropsWithoutRef, VariantProps {} diff --git a/app/consts.ts b/app/consts.ts index 16bb0aa..27843cb 100644 --- a/app/consts.ts +++ b/app/consts.ts @@ -13,5 +13,3 @@ export const media = { github: "https://github.com/TreasureProject", substack: "https://treasuredao.substack.com/", }; - -export const ITEMS_PER_PAGE = 60; diff --git a/app/graphql/token.queries.ts b/app/graphql/token.queries.ts index 3b90073..6c44a02 100644 --- a/app/graphql/token.queries.ts +++ b/app/graphql/token.queries.ts @@ -15,6 +15,10 @@ export const TOKEN_FRAGMENT = gql` } tokenIds } + vaultReserveItems { + tokenId + amount + } } `; @@ -35,3 +39,25 @@ export const getTokens = gql` } } `; + +export const getTokenVaultReserveItems = gql` + query GetTokenVaultReserveItems( + $id: String! + $first: Int = 50 + $skip: Int = 0 + ) { + vaultReserveItems( + first: $first + skip: $skip + where: { vault: $id } + orderBy: tokenId + orderDirection: ASC + ) { + collection { + id + } + tokenId + amount + } + } +`; diff --git a/app/hooks/useContractAddress.ts b/app/hooks/useContractAddress.ts index 226395f..072bbf1 100644 --- a/app/hooks/useContractAddress.ts +++ b/app/hooks/useContractAddress.ts @@ -14,7 +14,7 @@ const CONTRACT_ADDRESSES = { type Contract = keyof (typeof CONTRACT_ADDRESSES)[42161]; -export const useContractAddress = (contract: Contract) => { +const useContractAddress = (contract: Contract) => { const chainId = useChainId(); const addresses = CONTRACT_ADDRESSES[ diff --git a/app/hooks/useVaultItems.ts b/app/hooks/useVaultItems.ts new file mode 100644 index 0000000..0d42117 --- /dev/null +++ b/app/hooks/useVaultItems.ts @@ -0,0 +1,83 @@ +import { useFetcher } from "@remix-run/react"; +import { useEffect, useState } from "react"; + +import type { FetchVaultItems } from "~/routes/resources.vaults.$id.items"; +import type { TroveToken } from "~/types"; + +type Props = { + id: string; + type: "reserves" | "inventory"; + address?: string; + itemsPerPage?: number; + enabled?: boolean; +}; + +const DEFAULT_STATE = { + items: [], + page: 1, + hasNextPage: false, + isLoading: false, + error: undefined, +}; + +export const useVaultItems = ({ + id, + type, + address, + itemsPerPage = 25, + enabled = true, +}: Props) => { + const { load, state, data } = useFetcher(); + const [{ items, page, hasNextPage, isLoading, error }, setState] = useState<{ + items: TroveToken[]; + page: number; + hasNextPage: boolean; + isLoading: boolean; + error: string | undefined; + }>(DEFAULT_STATE); + + useEffect(() => { + if (data?.ok) { + setState((curr) => ({ + ...curr, + items: + curr.page === 1 ? data.results : [...curr.items, ...data.results], + hasNextPage: data.results.length === itemsPerPage, + isLoading: false, + error: undefined, + })); + } else if (data?.error) { + setState({ ...DEFAULT_STATE, error: data.error }); + } + }, [data, page, itemsPerPage]); + + useEffect(() => { + if (enabled) { + const params = new URLSearchParams({ + type, + page: page.toString(), + itemsPerPage: itemsPerPage.toString(), + }); + + if (type === "inventory" && address) { + params.set("address", address); + } + + setState((curr) => ({ ...curr, isLoading: true })); + load(`/resources/vaults/${id}/items?${params.toString()}`); + } else { + setState(DEFAULT_STATE); + } + }, [enabled, id, type, address, page, itemsPerPage, load]); + + return { + isLoading: isLoading || state === "loading", + items, + page, + hasNextPage, + loadNextPage: () => + setState((curr) => ({ ...curr, isLoading: true, page: curr.page + 1 })), + refetch: () => setState({ ...DEFAULT_STATE, isLoading: true }), + error, + }; +}; diff --git a/app/lib/og.server.tsx b/app/lib/og.server.tsx index 11d0f43..3883d10 100644 --- a/app/lib/og.server.tsx +++ b/app/lib/og.server.tsx @@ -18,12 +18,7 @@ const loadFont = (baseUrl: string, name: string, weight: 500 | 600 | 700) => }) as const ); -export const HONEY_25 = "#FFFDF7"; - export const NIGHT_100 = "#E7E8E9"; - -export const NIGHT_200 = "#CFD1D4"; - export const NIGHT_400 = "#9FA3A9"; export const TokenDisplay = ({ diff --git a/app/lib/tokens.server.ts b/app/lib/tokens.server.ts index 9b39db9..f526d68 100644 --- a/app/lib/tokens.server.ts +++ b/app/lib/tokens.server.ts @@ -35,8 +35,6 @@ export const itemToTroveTokenItem = ( }; }; -export type TroveTokenItem = ReturnType; - const createTokenMetadata = ( token: Token, collectionMapping: TroveCollectionMapping, diff --git a/app/routes/404.tsx b/app/routes/404.tsx index 3932398..1323368 100644 --- a/app/routes/404.tsx +++ b/app/routes/404.tsx @@ -1,5 +1,5 @@ +import { Link } from "@remix-run/react"; import { ChevronRightIcon } from "lucide-react"; -import { Link } from "react-router-dom"; import { documentation } from "../consts"; import { Grid, LearnIcon, PoolsIcon, SwapIcon } from "~/assets/Svgs"; diff --git a/app/routes/faq.tsx b/app/routes/faq.tsx index debe53b..b7fe541 100644 --- a/app/routes/faq.tsx +++ b/app/routes/faq.tsx @@ -1,5 +1,5 @@ +import { Link } from "@remix-run/react"; import { ChevronRightIcon } from "lucide-react"; -import { Link } from "react-router-dom"; import { DiscordSupportBar } from "~/components/FooterBars"; import { media } from "~/consts"; diff --git a/app/routes/pools_.$id.tsx b/app/routes/pools_.$id.tsx index c49b5a2..8dddb8e 100644 --- a/app/routes/pools_.$id.tsx +++ b/app/routes/pools_.$id.tsx @@ -39,8 +39,8 @@ import type { } from "~/api/pools.server"; import { fetchPool, fetchTransactions } from "~/api/pools.server"; import { - fetchCollectionOwnedByAddress, fetchPoolTokenBalance, + fetchVaultReserveItems, } from "~/api/tokens.server"; import { LoaderIcon } from "~/components/Icons"; import { SettingsDropdownMenu } from "~/components/SettingsDropdownMenu"; @@ -125,7 +125,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } const address = session.get("address"); - if (!address || (!pool.token0.isNFT && !pool.token1.isNFT)) { + if (!address || !pool.hasNFT) { return defer({ pool, transactions: fetchTransactions(pool), @@ -139,24 +139,16 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return defer({ pool, transactions: fetchTransactions(pool), - vaultItems0: fetchCollectionOwnedByAddress( - pool.token0.id, - pool.token0.urlSlug, - [], - pool.token0.collectionTokenIds, - null, - null, - 0 - ), - vaultItems1: fetchCollectionOwnedByAddress( - pool.token1.id, - pool.token1.urlSlug, - [], - pool.token1.collectionTokenIds, - null, - null, - 0 - ), + vaultItems0: pool.token0.isNFT + ? fetchVaultReserveItems({ + id: pool.token0.id, + }) + : undefined, + vaultItems1: pool.token1.isNFT + ? fetchVaultReserveItems({ + id: pool.token1.id, + }) + : undefined, nftBalance0: fetchPoolTokenBalance(pool.token0, address), nftBalance1: fetchPoolTokenBalance(pool.token1, address), }); @@ -419,25 +411,25 @@ export default function PoolDetailsPage() { {pool.hasNFT ? (
- {pool.token0.isNFT && vaultItems0 ? ( + {vaultItems0 ? ( {(vaultItems0) => ( )} ) : null} - {pool.token1.isNFT && vaultItems1 ? ( + {vaultItems1 ? ( {(vaultItems1) => ( )} diff --git a/app/routes/privacy.tsx b/app/routes/privacy.tsx index 770bdfa..5a3583a 100644 --- a/app/routes/privacy.tsx +++ b/app/routes/privacy.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { Link } from "@remix-run/react"; import { DiscordSupportBar } from "~/components/FooterBars"; diff --git a/app/routes/resources.collections.$slug.filters.ts b/app/routes/resources.collections.$slug.filters.ts deleted file mode 100644 index 02882de..0000000 --- a/app/routes/resources.collections.$slug.filters.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { json } from "@remix-run/node"; -import type { ShouldRevalidateFunction } from "@remix-run/react"; -import invariant from "tiny-invariant"; - -import { fetchFilters } from "~/api/tokens.server"; - -export const loader = async ({ params }: LoaderFunctionArgs) => { - const { slug } = params; - invariant(slug, "Missing slug"); - - try { - const filterList = await fetchFilters(slug.toLowerCase()); - - return json( - { - ok: true, - filterList, - } as const, - { - headers: { - "Cache-Control": "public, max-age=86400", - }, - } - ); - } catch (err) { - return json({ ok: false, error: (err as Error).message } as const); - } -}; - -export const shouldRevalidate: ShouldRevalidateFunction = () => { - return false; -}; - -export type CollectionFiltersLoader = typeof loader; diff --git a/app/routes/resources.collections.$slug.ts b/app/routes/resources.collections.$slug.ts deleted file mode 100644 index 2daf804..0000000 --- a/app/routes/resources.collections.$slug.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { json } from "@remix-run/node"; -import type { ShouldRevalidateFunction } from "@remix-run/react"; -import invariant from "tiny-invariant"; - -import { fetchCollectionOwnedByAddress } from "~/api/tokens.server"; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const { slug } = params; - invariant(slug, "Missing slug"); - - const url = new URL(request.url); - const address = url.searchParams.get("address"); - invariant(address, "Missing address"); - - const traits = url.searchParams.get("traits"); - const query = url.searchParams.get("query"); - const nextPageKey = url.searchParams.get("nextPageKey"); - const offset = url.searchParams.get("offset"); - const tokenIds = url.searchParams.get("tokenIds"); - const tokenIdsArray = tokenIds ? tokenIds.split(",") : []; - const traitsArray = traits ? traits.split(",") : []; - - try { - const tokens = await fetchCollectionOwnedByAddress( - address, - slug.toLowerCase(), - traitsArray, - tokenIdsArray, - query, - nextPageKey, - Number(offset ?? 0) - ); - - return json({ ok: true, tokens, traits: traitsArray, query } as const); - } catch (err) { - return json({ ok: false, error: (err as Error).message } as const); - } -}; - -export const shouldRevalidate: ShouldRevalidateFunction = () => { - return false; -}; - -export type CollectionLoader = typeof loader; diff --git a/app/routes/resources.vaults.$id.items.ts b/app/routes/resources.vaults.$id.items.ts new file mode 100644 index 0000000..4040b5e --- /dev/null +++ b/app/routes/resources.vaults.$id.items.ts @@ -0,0 +1,48 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +import { + fetchVaultReserveItems, + fetchVaultUserInventory, +} from "~/api/tokens.server"; + +const createErrorResponse = (error: string) => + json({ ok: false, error } as const); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { id } = params; + invariant(id, "Token ID required"); + + const url = new URL(request.url); + const type = url.searchParams.get("type") ?? "reserves"; + const address = url.searchParams.get("address"); + const page = url.searchParams.get("page"); + const itemsPerPage = url.searchParams.get("itemsPerPage"); + + if (type === "inventory") { + if (!address) { + return createErrorResponse("Address required to fetch inventory"); + } + + try { + const results = await fetchVaultUserInventory({ id, address }); + return json({ ok: true, results } as const); + } catch (err) { + return createErrorResponse((err as Error).message); + } + } + + try { + const results = await fetchVaultReserveItems({ + id, + page: page ? Number(page) : undefined, + itemsPerPage: itemsPerPage ? Number(itemsPerPage) : undefined, + }); + return json({ ok: true, results } as const); + } catch (err) { + return createErrorResponse((err as Error).message); + } +}; + +export type FetchVaultItems = typeof loader; diff --git a/app/types.ts b/app/types.ts index 119002e..50afd43 100644 --- a/app/types.ts +++ b/app/types.ts @@ -51,52 +51,6 @@ export type TroveTokenWithQuantity = TroveToken & { export type TroveTokenMapping = Record>; -export type TroveApiResponse = { - tokens: TroveToken[]; - nextPageKey: string | null; -}; - -type BaseTraitMetadata = { - traitCount: number; - valuesMap: Record< - string | number, - { valueCount: number; valuePriority?: number } - >; - display_order?: string; - superTrait?: string; - subTrait?: string; -}; - -type DefaultTraitMetadata = { - display_type?: "default"; -} & BaseTraitMetadata; - -type NumericTraitMetadata = { - display_type?: "numeric"; - valueMin: number; - valueMax: number; - traitCount: number; - valueStep: number; -} & BaseTraitMetadata; - -type PercentageTraitMetadata = { - display_type?: "percentage"; -} & BaseTraitMetadata; - -export type TraitMetadata = - | DefaultTraitMetadata - | NumericTraitMetadata - | PercentageTraitMetadata; - -export type TraitsResponse = { - traitsMap: Record; -}; - -export type Traits = TraitMetadata & { - traitName: string; - values: { valueName: string; count: number }[]; -}; - type DomainType = "ens" | "smol" | "treasuretag" | "address"; type DomainInfo = { diff --git a/package.json b/package.json index 2533385..9b86e37 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,11 @@ }, "dependencies": { "@epic-web/cachified": "^5.2.0", - "@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-checkbox": "^1.0.3", "@radix-ui/react-dialog": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-label": "^2.0.1", "@radix-ui/react-popover": "^1.0.5", - "@radix-ui/react-scroll-area": "^1.0.3", "@react-aria/i18n": "^3.7.1", "@react-aria/numberfield": "^3.5.0", "@react-stately/numberfield": "^3.4.1",