diff --git a/bun.lockb b/bun.lockb index 83596798..953f9684 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 97b99aec..fb728470 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@elysiajs/eden": "^1.1.3", "@emotion/css": "^11.13.4", "@lifi/widget": "^3.13.1", - "@merkl/api": "0.10.277", + "@merkl/api": "0.10.307", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.0", "@remix-run/dev": "^2.11.2", @@ -62,18 +62,10 @@ "@types/bun": "latest", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", - "@typescript-eslint/eslint-plugin": "^6.7.4", - "@typescript-eslint/parser": "^6.7.4", "autoprefixer": "^10.4.19", "elysia": "^1.1.19", - "eslint": "^8.38.0", - "eslint-import-resolver-typescript": "^3.6.1", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0", "postcss": "^8.4.38", - "typescript": "^5.6.2", + "typescript": "^5.7.2", "vite": "^5.1.0", "vite-tsconfig-paths": "^4.2.1" }, diff --git a/packages/dappkit b/packages/dappkit index 6156edc5..6472d21b 160000 --- a/packages/dappkit +++ b/packages/dappkit @@ -1 +1 @@ -Subproject commit 6156edc5bdbdf95d34ac57147d4c3db26ae03f4a +Subproject commit 6472d21b7454197bd5609f9d5fbd0a710728f9bb diff --git a/src/api/services/reward.service.ts b/src/api/services/reward.service.ts index 7a5e211a..559f8772 100644 --- a/src/api/services/reward.service.ts +++ b/src/api/services/reward.service.ts @@ -17,12 +17,15 @@ export abstract class RewardService { } /** - * Retrieves opportunities query params from page request + * Retrieves query params from page request * @param request request containing query params such as chains, status, pagination... * @param override params for which to override value * @returns query */ - static #getQueryFromRequest(request: Request, override?: Parameters[0]["query"]) { + static #getCampaignLeaderboardQueryFromRequest( + request: Request, + override?: Parameters[0]["query"], + ) { const campaignId = new URL(request.url).searchParams.get("campaignId"); const page = new URL(request.url).searchParams.get("page"); const items = new URL(request.url).searchParams.get("items"); @@ -63,12 +66,58 @@ export abstract class RewardService { ); } + static #getTokenLeaderboardQueryFromRequest( + request: Request, + override?: Parameters[0]["query"], + ) { + const page = new URL(request.url).searchParams.get("page"); + const items = new URL(request.url).searchParams.get("items"); + + const filters = Object.assign( + { + items: items ?? DEFAULT_ITEMS_PER_PAGE, + page, + }, + override ?? {}, + page !== null && { page: Number(page) - 1 }, + ); + + const query = Object.entries(filters).reduce( + (_query, [key, filter]) => Object.assign(_query, filter == null ? {} : { [key]: filter }), + {}, + ); + + return query; + } + + static async getTokenLeaderboard( + request: Request, + overrides?: Parameters[0]["query"], + ) { + return RewardService.getByToken( + Object.assign(RewardService.#getTokenLeaderboardQueryFromRequest(request), overrides ?? undefined), + ); + } + + static async getByToken(query: Parameters[0]["query"]) { + const rewards = await RewardService.#fetch(async () => + api.v4.rewards.token.get({ + query, + }), + ); + + const count = await RewardService.#fetch(async () => api.v4.rewards.token.count.get({ query })); + const { amount } = await RewardService.#fetch(async () => api.v4.rewards.token.total.get({ query })); + + return { count, rewards, total: amount }; + } + static async getManyFromRequest( request: Request, overrides?: Parameters[0]["query"], ) { return RewardService.getByParams( - Object.assign(RewardService.#getQueryFromRequest(request), overrides ?? undefined), + Object.assign(RewardService.#getCampaignLeaderboardQueryFromRequest(request), overrides ?? undefined), ); } diff --git a/src/api/services/token.service.ts b/src/api/services/token.service.ts index f99a1e58..b2edd5f9 100644 --- a/src/api/services/token.service.ts +++ b/src/api/services/token.service.ts @@ -59,6 +59,10 @@ export abstract class TokenService { return tokens; } + static async findUniqueOrThrow(chainId: number, address: string) { + return await TokenService.#fetch(async () => api.v4.tokens({ id: `${chainId}-${address}` }).get()); + } + static async getSymbol(symbol: string | undefined): Promise { if (!symbol) throw new Response("Token not found"); diff --git a/src/components/element/leaderboard/LeaderboardLibrary.tsx b/src/components/element/leaderboard/LeaderboardLibrary.tsx index c3c7a6c9..d331dc7e 100644 --- a/src/components/element/leaderboard/LeaderboardLibrary.tsx +++ b/src/components/element/leaderboard/LeaderboardLibrary.tsx @@ -1,43 +1,49 @@ -import type { Campaign } from "@merkl/api"; +import type { Token } from "@merkl/api"; import { useSearchParams } from "@remix-run/react"; import { Group, Text, Title } from "dappkit"; import { useMemo } from "react"; import type { RewardService } from "src/api/services/reward.service"; import { v4 as uuidv4 } from "uuid"; import Pagination from "../opportunity/Pagination"; -import { LeaderboardTable } from "./LeaderboardTable"; +import { LeaderboardTable, LeaderboardTableWithoutReason } from "./LeaderboardTable"; import LeaderboardTableRow from "./LeaderboardTableRow"; export type IProps = { leaderboard: Awaited>["rewards"]; count?: number; total?: bigint; - campaign: Campaign; + withReason: boolean; + token: Token; + chain: number; }; export default function LeaderboardLibrary(props: IProps) { - const { leaderboard, count, total, campaign } = props; + const { leaderboard, count, total, token, chain, withReason } = props; const [searchParams] = useSearchParams(); const items = searchParams.get("items"); const page = searchParams.get("page"); + const Table = withReason ? LeaderboardTable : LeaderboardTableWithoutReason; + const rows = useMemo(() => { return leaderboard?.map((row, index) => ( )); - }, [leaderboard, page, items, total, campaign]); + }, [leaderboard, page, items, total, token, chain, withReason]); return ( {!!rows?.length ? ( - (index < 2 ? "bg-accent-8" : "bg-main-8")} header={ @@ -46,7 +52,7 @@ export default function LeaderboardLibrary(props: IProps) { } footer={count !== undefined && <Pagination count={count} />}> {rows} - </LeaderboardTable> + </Table> ) : ( <Text className="p-xl">No rewarded users</Text> )} diff --git a/src/components/element/leaderboard/LeaderboardTable.tsx b/src/components/element/leaderboard/LeaderboardTable.tsx index 877283c6..8b5be8b1 100644 --- a/src/components/element/leaderboard/LeaderboardTable.tsx +++ b/src/components/element/leaderboard/LeaderboardTable.tsx @@ -27,3 +27,26 @@ export const [LeaderboardTable, LeaderboardRow, LeaderboardColumns] = createTabl className: "justify-end", }, }); + +export const [LeaderboardTableWithoutReason, LeaderboardRowWithoutReason, LeaderboardColumnsWithoutReason] = + createTable({ + rank: { + name: "Rank", + size: "minmax(120px,150px)", + compact: "1fr", + className: "justify-start", + main: true, + }, + address: { + name: "Address", + size: "minmax(170px,1fr)", + compactSize: "1fr", + className: "justify-start", + }, + rewards: { + name: "Rewards earned", + size: "minmax(30px,1fr)", + compactSize: "minmax(20px,1fr)", + className: "justify-start", + }, + }); diff --git a/src/components/element/leaderboard/LeaderboardTableRow.tsx b/src/components/element/leaderboard/LeaderboardTableRow.tsx index d23e177d..5d6b920c 100644 --- a/src/components/element/leaderboard/LeaderboardTableRow.tsx +++ b/src/components/element/leaderboard/LeaderboardTableRow.tsx @@ -1,37 +1,41 @@ -import type { Campaign } from "@merkl/api"; +import type { Token as TokenType } from "@merkl/api"; import { type Component, Group, PrimitiveTag, Text, Value, mergeClass } from "dappkit"; import { useWalletContext } from "packages/dappkit/src/context/Wallet.context"; import { useMemo } from "react"; import type { RewardService } from "src/api/services/reward.service"; -import { formatUnits, parseUnits } from "viem"; +import { formatUnits } from "viem"; import Token from "../token/Token"; import User from "../user/User"; -import { LeaderboardRow } from "./LeaderboardTable"; +import { LeaderboardRow, LeaderboardRowWithoutReason } from "./LeaderboardTable"; -export type CampaignTableRowProps = Component<{ +export type LeaderboardTableRowProps = Component<{ row: Awaited<ReturnType<typeof RewardService.getManyFromRequest>>["rewards"][0]; total: bigint; rank: number; - campaign: Campaign; + token: TokenType; + chain: number; + withReason: boolean; }>; -export default function LeaderboardTableRow({ row, rank, total, className, ...props }: CampaignTableRowProps) { - const { campaign } = props; +export default function LeaderboardTableRow({ row, rank, total, className, ...props }: LeaderboardTableRowProps) { + const { token, chain: chainId, withReason } = props; const { chains } = useWalletContext(); + const Row = withReason ? LeaderboardRow : LeaderboardRowWithoutReason; + const share = useMemo(() => { - const amount = formatUnits(BigInt(row?.amount) + BigInt(row?.pending ?? 0), campaign.rewardToken.decimals); - const all = formatUnits(total, campaign.rewardToken.decimals); + const amount = formatUnits(BigInt(row?.amount) + BigInt(row?.pending ?? 0), token.decimals); + const all = formatUnits(total, token.decimals); return Number.parseFloat(amount) / Number.parseFloat(all); - }, [row, total, campaign]); + }, [row, total, token]); const chain = useMemo(() => { - return chains?.find(c => c.id === campaign.computeChainId); - }, [chains, campaign]); + return chains?.find(c => c.id === chainId); + }, [chains, chainId]); return ( - <LeaderboardRow + <Row {...props} className={mergeClass("cursor-pointer", className)} rankColumn={ @@ -45,10 +49,8 @@ export default function LeaderboardTableRow({ row, rank, total, className, ...pr </Group> } addressColumn={<User chain={chain} address={row.recipient} />} - rewardsColumn={ - <Token token={campaign.rewardToken} format="amount_price" amount={parseUnits(row?.amount + row?.pending, 0)} /> - } - protocolColumn={<Text>{row?.reason?.split("_")[0]}</Text>} + rewardsColumn={<Token token={token} format="amount_price" amount={BigInt(row?.amount) + BigInt(row?.pending)} />} + protocolColumn={withReason ? <Text>{row?.reason?.split("_")[0]}</Text> : undefined} /> ); } diff --git a/src/components/element/opportunity/OpportunityFilters.tsx b/src/components/element/opportunity/OpportunityFilters.tsx index 2ce2ef01..0e968ebd 100644 --- a/src/components/element/opportunity/OpportunityFilters.tsx +++ b/src/components/element/opportunity/OpportunityFilters.tsx @@ -8,7 +8,7 @@ import type { OpportunityView } from "src/config/opportunity"; import useSearchParamState from "src/hooks/filtering/useSearchParamState"; import useChains from "src/hooks/resources/useChains"; import useProtocols from "src/hooks/resources/useProtocols"; -const filters = ["search", "action", "status", "chain", "protocol", "tvl"] as const; +const filters = ["search", "action", "status", "chain", "protocol", "tvl", "sort"] as const; export type OpportunityFilter = (typeof filters)[number]; export type OpportunityFilterProps = { @@ -51,6 +51,46 @@ export default function OpportunityFilters({ }), {}, ); + + const sortOptions = { + "apr-asc": ( + <Group> + By APR + <Icon remix="RiArrowUpLine" /> + </Group> + ), + "apr-desc": ( + <Group> + By APR + <Icon remix="RiArrowDownLine" /> + </Group> + ), + "tvl-asc": ( + <Group> + By TVL + <Icon remix="RiArrowUpLine" /> + </Group> + ), + "tvl-desc": ( + <Group> + By TVL + <Icon remix="RiArrowDownLine" /> + </Group> + ), + "rewards-asc": ( + <Group> + By rewards + <Icon remix="RiArrowUpLine" /> + </Group> + ), + "rewards-desc": ( + <Group> + By rewards + <Icon remix="RiArrowDownLine" /> + </Group> + ), + }; + const statusOptions = { LIVE: ( <> @@ -77,8 +117,16 @@ export default function OpportunityFilters({ v => v?.join(","), v => v?.split(","), ); + const [actionsInput, setActionsInput] = useState<string[]>(actionsFilter ?? []); + const [sortFilter] = useSearchParamState<string>( + "sort", + v => v, + v => v, + ); + const [sortInput, setSortInput] = useState<string>(sortFilter ?? ""); + const [statusFilter] = useSearchParamState<string[]>( "status", v => v?.join(","), @@ -147,9 +195,10 @@ export default function OpportunityFilters({ const sameStatus = isSameArray(statusInput, statusFilter); const sameProtocols = isSameArray(protocolInput, protocolFilter); const sameTvl = tvlFilter === tvlInput || tvlInput === ""; + const sameSort = sortFilter === sortInput || sortInput === ""; const sameSearch = (search ?? "") === innerSearch; - return [sameChains, sameActions, sameTvl, sameStatus, sameSearch, sameProtocols].some(v => v === false); + return [sameChains, sameActions, sameTvl, sameStatus, sameSearch, sameProtocols, sameSort].some(v => v === false); }, [ chainIdsInput, chainIdsFilter, @@ -163,6 +212,8 @@ export default function OpportunityFilters({ statusInput, search, innerSearch, + sortFilter, + sortInput, ]); // biome-ignore lint/correctness/useExhaustiveDependencies: needed fo sync @@ -174,13 +225,13 @@ export default function OpportunityFilters({ function onApplyFilters() { setApplying(true); setClearing(false); - setSearchParams(params => { updateParams("chain", chainIdsInput, params); updateParams("action", actionsInput, params); updateParams("status", statusInput, params); updateParams("protocol", protocolInput, params); tvlInput && updateParams("tvl", [tvlInput], params); + sortInput && updateParams("sort", [sortInput], params); return params; }); @@ -197,6 +248,7 @@ export default function OpportunityFilters({ setActionsInput([]); setTvlInput(""); setInnerSearch(""); + setSortInput(""); } useEffect(() => { @@ -283,6 +335,9 @@ export default function OpportunityFilters({ /> </Form> )} + {view === "cells" && ( + <Select state={[sortInput, setSortInput]} options={sortOptions} look="tint" placeholder="Sort by" /> + )} </Group> <Group className={`${config.opportunityLibrary?.views?.length === 1 ? "flex-row-reverse flex-wrap" : ""} items-center`}> diff --git a/src/components/element/opportunity/OpportunityLibrary.tsx b/src/components/element/opportunity/OpportunityLibrary.tsx index 76804196..69e05b6f 100644 --- a/src/components/element/opportunity/OpportunityLibrary.tsx +++ b/src/components/element/opportunity/OpportunityLibrary.tsx @@ -65,6 +65,7 @@ export default function OpportunityLibrary({ case "table": return ( <OpportunityTable + exclude={["tvl"]} opportunityHeader={ <Title className="!text-main-11" h={5}> Opportunities @@ -78,7 +79,7 @@ export default function OpportunityLibrary({ footer={count !== undefined && <Pagination count={count} />}> {opportunities?.map(o => ( <OpportunityTableRow - hideTags={merklConfig.opportunityLibrary.cells.hideTags} + hideTags={merklConfig.opportunityLibrary.cells?.hideTags} navigationMode={merklConfig.opportunityNavigationMode} key={`${o.chainId}_${o.type}_${o.identifier}`} opportunity={o} @@ -93,7 +94,7 @@ export default function OpportunityLibrary({ {opportunities?.map(o => ( <OpportunityCell navigationMode={merklConfig.opportunityNavigationMode} - hideTags={merklConfig.opportunityLibrary.cells.hideTags} + hideTags={merklConfig.opportunityLibrary.cells?.hideTags} key={`${o.chainId}_${o.type}_${o.identifier}`} opportunity={o} /> diff --git a/src/components/element/opportunity/OpportunityParticipateModal.tsx b/src/components/element/opportunity/OpportunityParticipateModal.tsx index 072969f8..55efcd19 100644 --- a/src/components/element/opportunity/OpportunityParticipateModal.tsx +++ b/src/components/element/opportunity/OpportunityParticipateModal.tsx @@ -4,6 +4,7 @@ import { Button, Divider, Group, Image, Modal, Text, Title } from "packages/dapp import type { PropsWithChildren } from "react"; import React from "react"; +import merklConfig from "merkl.config"; import Participate from "../participate/Participate"; export type OpportunityParticipateModalProps = { opportunity: Opportunity; @@ -21,9 +22,9 @@ export default function OpportunityParticipateModal({ opportunity, children }: O displayLinks displayOpportunity displayMode="deposit" - hideInteractor={config?.hideInteractor ? config.hideInteractor : true} + hideInteractor={!config?.deposit} /> - {!!config.supplyCredits && config.supplyCredits.length > 0 && ( + {merklConfig.deposit && !!config.supplyCredits && config.supplyCredits.length > 0 && ( <Text look="bold" className="flex gap-md items-center mx-auto"> Powered by{" "} {config.supplyCredits.map(credit => ( diff --git a/src/components/element/participate/Interact.client.tsx b/src/components/element/participate/Interact.client.tsx index 69d9efe8..9105aa08 100644 --- a/src/components/element/participate/Interact.client.tsx +++ b/src/components/element/participate/Interact.client.tsx @@ -1,11 +1,24 @@ -import type { Opportunity, Token } from "@merkl/api"; +import type { Opportunity } from "@merkl/api"; import type { InteractionTarget } from "@merkl/api/dist/src/modules/v4/interaction/interaction.model"; -import { Button, type ButtonProps, Checkbox, Group, Text, WalletButton } from "dappkit"; +import { + Box, + Button, + type ButtonProps, + Checkbox, + Collapsible, + Group, + Icon, + PrimitiveTag, + Space, + Text, + WalletButton, +} from "dappkit"; import TransactionButton from "packages/dappkit/src/components/dapp/TransactionButton"; import { useWalletContext } from "packages/dappkit/src/context/Wallet.context"; import { useMemo, useState } from "react"; import useBalances from "src/hooks/useBalances"; import useInteractionTransaction from "src/hooks/useInteractionTransaction"; +import Token from "../token/Token"; export type InteractProps = { opportunity: Opportunity; @@ -13,17 +26,37 @@ export type InteractProps = { inputToken?: Token; tokenAddress?: string; amount?: bigint; + slippage?: bigint; disabled?: boolean; + settings?: ReactNode; + onSuccess?: (hash: string) => void; }; -export default function Interact({ opportunity, inputToken, amount, target, disabled }: InteractProps) { +export default function Interact({ + opportunity, + onSuccess, + settings, + inputToken, + slippage, + amount, + target, + disabled, +}: InteractProps) { const { chainId, switchChain, address: user, sponsorTransactions, setSponsorTransactions } = useWalletContext(); const { transaction, reload, loading: txLoading, - } = useInteractionTransaction(opportunity.chainId, opportunity.protocol?.id, target, inputToken, amount); - const [approvalHash, setApprovalHash] = useState<string>(); + } = useInteractionTransaction( + opportunity.chainId, + opportunity.protocol?.id, + target, + inputToken, + amount, + user, + slippage, + ); + const [_approvalHash, setApprovalHash] = useState<string>(); const { reload: reloadBalances } = useBalances(); const currentInteraction = useMemo(() => { @@ -38,15 +71,33 @@ export default function Interact({ opportunity, inputToken, amount, target, disa else if (chainId !== opportunity.chainId) createProps({ children: `Switch to ${opportunity.chain.name}`, onClick: () => switchChain(opportunity.chainId) }); else if (!amount || amount === 0n) createProps({ disabled: true, children: "Enter an amount" }); - else if (!transaction && !txLoading) createProps({ disabled: true, children: "Cannot interact" }); - else if (!transaction || txLoading) createProps({ disabled: true, children: "Loading..." }); + else if (amount > inputToken.balance) createProps({ disabled: true, children: "Exceeds balance" }); + else if (!transaction && !txLoading) + createProps({ + disabled: true, + children: ( + <> + <Icon remix="RiProhibitedLine" /> No route found, try with another token + </> + ), + }); + else if (!transaction || txLoading) + createProps({ + disabled: true, + children: ( + <> + <Icon className="animate-spin" remix="RiLoader2Fill" /> Loading... + </> + ), + }); // biome-ignore lint/suspicious/noExplicitAny: <explanation> if (buttonProps) return <Button {...(buttonProps as any)} />; - if (!transaction.approved && !approvalHash) + if (!transaction.approved) return ( <TransactionButton + iconProps={{ remix: "RiFingerprintLine" }} onExecute={h => { setApprovalHash(h); reload(); @@ -62,7 +113,12 @@ export default function Interact({ opportunity, inputToken, amount, target, disa if (transaction.transaction) return ( <TransactionButton - onSuccess={() => reloadBalances()} + iconProps={{ remix: "RiTokenSwapLine" }} + onSuccess={hash => { + reloadBalances(); + reload(); + onSuccess?.(hash); + }} {...commonProps} name={`Supply ${inputToken?.symbol} on ${opportunity.protocol?.name}`} tx={transaction?.transaction}> @@ -77,23 +133,71 @@ export default function Interact({ opportunity, inputToken, amount, target, disa amount, transaction, disabled, - approvalHash, switchChain, user, txLoading, reload, + onSuccess, ]); + const providerIcon = useMemo(() => { + if (target.provider === "enso") + return ( + <> + <Icon src="https://framerusercontent.com/images/19ye5oms8sG6XHF1K8p03vLNkg.png" /> Enso + </> + ); + if (target.provider === "zap") + return ( + <> + <Icon src="https://docs.kyberswap.com/~gitbook/image?url=https%3A%2F%2F1368568567-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252Fw1XgQJc40kVeGUIxgI7c%252Ficon%252FYl1TDE5MQwDPbEsfCerK%252Fimage%2520%281%29.png%3Falt%3Dmedia%26token%3D3f984a53-8b11-4d1b-b550-193d82610e7b&width=32&dpr=1&quality=100&sign=a7af3e95&sv=2" />{" "} + Zap + </> + ); + }, [target]); + const canTransactionBeSponsored = opportunity.chainId === 324; + const [settingsCollapsed, setSettingsCollapsed] = useState<boolean>(false); return ( <> - {canTransactionBeSponsored && ( - <Group className="justify-between w-full items-center"> - <Text>Gasless</Text> - <Checkbox size="sm" state={[sponsorTransactions, setSponsorTransactions]} /> + <Space size="sm" /> + <Box content="sm" className="w-full !gap-0 !bg-main-2" look="base"> + <Group className="w-full flex-nowrap"> + <Group className="grow items-center"> + {amount && inputToken && ( + <Text className="flex animate-drop grow flex-nowrap items-center gap-md" size={6}> + Supply + <Token key={amount} className="animate-drop" token={inputToken} amount={amount} format="price" /> with{" "} + {providerIcon} + </Text> + )} + </Group> + <PrimitiveTag + onClick={() => setSettingsCollapsed(o => !o)} + size="sm" + look="base" + className="flex flex-nowrap gap-md"> + <Icon remix="RiSettings3Line" /> + <Icon + data-state={!settingsCollapsed ? "closed" : "opened"} + className={"transition duration-150 ease-out data-[state=opened]:rotate-180"} + remix="RiArrowDownSLine" + /> + </PrimitiveTag> </Group> - )} + <Collapsible state={[settingsCollapsed]}> + <Space size="md" /> + {canTransactionBeSponsored && ( + <Group className="justify-between w-full items-center"> + <Text>Gasless</Text> + <Checkbox size="sm" state={[sponsorTransactions, setSponsorTransactions]} /> + </Group> + )} + {settings} + </Collapsible> + </Box> + <Space size="xl" /> {currentInteraction} </> ); diff --git a/src/components/element/participate/Participate.tsx b/src/components/element/participate/Participate.tsx index 3595742a..c5b95f6b 100644 --- a/src/components/element/participate/Participate.tsx +++ b/src/components/element/participate/Participate.tsx @@ -1,6 +1,7 @@ import type { Opportunity } from "@merkl/api"; import { Button, Group, Icon, Input, PrimitiveTag, Text, Value } from "dappkit"; import config from "merkl.config"; +import Collapsible from "packages/dappkit/src/components/primitives/Collapsible"; import { useWalletContext } from "packages/dappkit/src/context/Wallet.context"; import { Fmt } from "packages/dappkit/src/utils/formatter.service"; import { Suspense, useMemo, useState } from "react"; @@ -19,6 +20,8 @@ export type ParticipateProps = { hideInteractor?: boolean; }; +const DEFAULT_SLIPPAGE = 200n; + export default function Participate({ opportunity, displayOpportunity, @@ -56,19 +59,14 @@ export default function Participate({ // </Button> // ); // } - // }, [mode]); + // }, [mode]); if (hideInteractor) return; - const interactor = useMemo(() => { - if (loading) - return ( - <Group className="w-full justify-center"> - <Icon remix="RiLoader2Line" className="animate-spin" /> - </Group> - ); - if (!targets?.length) return; + const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE); + const interactor = useMemo(() => { + if (hideInteractor || loading || !targets?.length) return; return ( - <Group className="mt-lg"> + <Group className="mt-lg !gap-0"> <Input.BigInt className="w-full gap-xs" inputClassName="font-title font-bold italic text-[clamp(38px,0.667vw+1.125rem,46px)] !leading-none" @@ -95,7 +93,7 @@ export default function Participate({ size="sm" look="bold" format="0,0.###a"> - {Fmt.toNumber(inputToken?.balance, inputToken.decimals)} + {Fmt.toNumber(inputToken?.balance, inputToken.decimals).toString()} </Value>{" "} {inputToken?.symbol} </PrimitiveTag> @@ -116,16 +114,56 @@ export default function Participate({ /> <Suspense> <Interact + onSuccess={() => setAmount(undefined)} disabled={!loading && !targets?.length} target={targets?.[0]} + slippage={slippage} inputToken={inputToken} amount={amount} opportunity={opportunity} + settings={ + <Group className="justify-between w-full items-center"> + <Text>Slippage</Text> + <Input.BigInt + base={2} + state={[slippage, setSlippage]} + size="sm" + className="max-w-[20ch] !rounded-sm+sm" + prefix={ + <Group size="xs"> + {[50n, 100n, 200n].map(_slippage => ( + <PrimitiveTag + key={_slippage} + onClick={() => setSlippage(_slippage)} + look={_slippage === slippage ? "hype" : "base"} + size="xs"> + <Value value format="0.#%"> + {Fmt.toNumber(_slippage, 4)} + </Value> + </PrimitiveTag> + ))} + </Group> + } + /> + </Group> + } /> </Suspense> </Group> ); - }, [opportunity, mode, inputToken, loading, amount, tokenAddress, balance, targets, connected]); + }, [ + opportunity, + hideInteractor, + mode, + inputToken, + slippage, + loading, + amount, + tokenAddress, + balance, + targets, + connected, + ]); return ( <> @@ -146,7 +184,12 @@ export default function Participate({ </Text> </Group> )} - {!hideInteractor && interactor} + {loading && ( + <Group className="w-full justify-center"> + <Icon remix="RiLoader2Line" className="animate-spin" /> + </Group> + )} + <Collapsible state={[!!interactor]}>{interactor}</Collapsible> </> ); } diff --git a/src/components/element/rewards/ClaimRewardsChainTableRow.tsx b/src/components/element/rewards/ClaimRewardsChainTableRow.tsx index 04f3f902..eba1be63 100644 --- a/src/components/element/rewards/ClaimRewardsChainTableRow.tsx +++ b/src/components/element/rewards/ClaimRewardsChainTableRow.tsx @@ -89,7 +89,7 @@ export default function ClaimRewardsChainTableRow({ <Tag type="chain" value={reward.chain} /> <Icon data-state={!open ? "closed" : "opened"} - className="transition duration-150 ease-out data-[state=opened]:rotate-180" + className=" data-[state=opened]:rotate-180" remix={"RiArrowDropDownLine"} /> <EventBlocker> diff --git a/src/components/element/token/Token.tsx b/src/components/element/token/Token.tsx index 3beb9304..d9e68b9d 100644 --- a/src/components/element/token/Token.tsx +++ b/src/components/element/token/Token.tsx @@ -27,6 +27,7 @@ export default function Token({ icon = true, symbol = true, chain, + ...props }: TokenProps) { const amountFormatted = amount ? formatUnits(amount, token.decimals) : "0"; const amountUSD = !amount ? 0 : (token.price ?? 0) * Number.parseFloat(amountFormatted ?? "0"); @@ -69,7 +70,7 @@ export default function Token({ return ( <Dropdown content={<TokenTooltip {...{ token, amount, chain, size }} />}> - <Button size={size} look="soft"> + <Button {...props} size={size} look="soft"> {display} </Button> </Dropdown> diff --git a/src/components/element/token/TokenSelect.tsx b/src/components/element/token/TokenSelect.tsx index 64706427..33acb086 100644 --- a/src/components/element/token/TokenSelect.tsx +++ b/src/components/element/token/TokenSelect.tsx @@ -12,6 +12,7 @@ export type TokenSelectProps = { export default function TokenSelect({ tokens, balances, ...props }: TokenSelectProps) { const sortedTokens = useMemo(() => { + if (!tokens) return []; const tokensWithBalance = tokens .filter(({ balance }) => balance > 0) .sort((a, b) => { diff --git a/src/hooks/useInteractionTransaction.tsx b/src/hooks/useInteractionTransaction.tsx index dcde6103..e6e909f0 100644 --- a/src/hooks/useInteractionTransaction.tsx +++ b/src/hooks/useInteractionTransaction.tsx @@ -16,6 +16,7 @@ export default function useInteractionTransaction( tokenIn?: Token, amount?: bigint, userAddress?: string, + slippage?: bigint, ) { const { address: connectedAddress, sponsorTransactions } = useWalletContext(); const address = useMemo(() => userAddress ?? connectedAddress, [userAddress, connectedAddress]); @@ -29,10 +30,11 @@ export default function useInteractionTransaction( protocolId, identifier: target?.identifier, userAddress: address, + slippage: slippage ? slippage.toString() : undefined, fromAmount: amount?.toString(), fromTokenAddress: tokenIn?.address, }; - }, [chainId, protocolId, target, address, tokenIn, amount]); + }, [chainId, protocolId, target, address, tokenIn, slippage, amount]); const transaction = useMemo(() => { if (!payload) return; diff --git a/src/routes/_merkl.(home).(opportunities).tsx b/src/routes/_merkl.(home).(opportunities).tsx index 48128fff..4f454098 100644 --- a/src/routes/_merkl.(home).(opportunities).tsx +++ b/src/routes/_merkl.(home).(opportunities).tsx @@ -41,7 +41,6 @@ export default function Index() { )} - diff --git a/src/routes/_merkl.leaderboard.$chain.$address.tsx b/src/routes/_merkl.leaderboard.$chain.$address.tsx new file mode 100644 index 00000000..f8cfabae --- /dev/null +++ b/src/routes/_merkl.leaderboard.$chain.$address.tsx @@ -0,0 +1,108 @@ +import { type LoaderFunctionArgs, json } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { Box, Container, Group, Space, Title, Value } from "dappkit"; +import config from "merkl.config"; +import { useMemo } from "react"; +import { ChainService } from "src/api/services/chain.service"; +import { RewardService } from "src/api/services/reward.service"; +import { TokenService } from "src/api/services/token.service"; +import Hero from "src/components/composite/Hero"; +import LeaderboardLibrary from "src/components/element/leaderboard/LeaderboardLibrary"; +import { formatUnits } from "viem"; + +export async function loader({ params: { address, chain: chainId }, request }: LoaderFunctionArgs) { + if (!chainId || !address) throw ""; + + const chain = await ChainService.get({ name: chainId }); + const token = await TokenService.findUniqueOrThrow(chain.id, address); + + const { rewards, count, total } = await RewardService.getTokenLeaderboard(request, { + chainId: chain.id, + address, + }); + + return json({ + rewards, + token, + chain, + count, + total, + }); +} + +export default function Index() { + const { rewards, token, chain, count, total } = useLoaderData(); + + const totalRewardsInUsd = useMemo(() => { + const amountUSD = formatUnits(total, token.decimals); + return Number.parseFloat(amountUSD) * (token?.price ?? 0); + }, [total, token]); + + const metrics = useMemo( + () => + ( + [ + [ + "Total Rewarded Users", + + {count?.count} + , + ], + [ + "Total Rewards Distributed", + + {totalRewardsInUsd} + , + ], + ] as const + ).map(([label, value]) => ( + + + {label} + + {/* Probably a count from api */} + {value} + + )), + [totalRewardsInUsd, count], + ); + + return ( + + {token.name} ({token.symbol}) + + } + description={`Leaderboard of all ${token.symbol} rewards earned through Merkl`} + // sideDatas={defaultHeroSideDatas(count, maxApr, Number.parseFloat(dailyRewards))} + // tags={tags.map(tag => )} + > + + + {metrics} + + + + + + ); +} diff --git a/src/routes/_merkl.opportunities.$chain.$type.$id.leaderboard.tsx b/src/routes/_merkl.opportunities.$chain.$type.$id.leaderboard.tsx index f61503c8..d19e73b5 100644 --- a/src/routes/_merkl.opportunities.$chain.$type.$id.leaderboard.tsx +++ b/src/routes/_merkl.opportunities.$chain.$type.$id.leaderboard.tsx @@ -1,7 +1,7 @@ import type { Campaign } from "@merkl/api"; import { type LoaderFunctionArgs, json } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; -import { Box, Container, Group, Icon, OverrideTheme, PrimitiveTag, Select, Space, Title, Value } from "dappkit"; +import { Box, Container, Group, Icon, OverrideTheme, PrimitiveTag, Select, Space, Text, Title, Value } from "dappkit"; import config from "merkl.config"; import moment from "moment"; import Time from "packages/dappkit/src/components/primitives/Time"; @@ -15,13 +15,6 @@ import Token from "src/components/element/token/Token"; import useSearchParamState from "src/hooks/filtering/useSearchParamState"; import { formatUnits, parseUnits } from "viem"; -export type DummyLeaderboard = { - rank: number; - address: string; - rewards: number; - protocol: string; -}; - export async function loader({ params: { id, type, chain: chainId }, request }: LoaderFunctionArgs) { if (!chainId || !id || !type) throw ""; @@ -124,6 +117,17 @@ export default function Index() { {totalRewardsInUsd} , ], + [ + "Last Update", + + + {selectedCampaign?.campaignStatus?.computedUntil ? ( + , + ], ] as const ).map(([label, value]) => ( {value} )), - [totalRewardsInUsd, count], + [totalRewardsInUsd, count, selectedCampaign], ); return ( @@ -157,7 +161,14 @@ export default function Index() { {metrics} {selectedCampaign && ( - + )} ); diff --git a/src/routes/_merkl.opportunities.$chain.$type.$id.tsx b/src/routes/_merkl.opportunities.$chain.$type.$id.tsx index 420fa123..0dae21e6 100644 --- a/src/routes/_merkl.opportunities.$chain.$type.$id.tsx +++ b/src/routes/_merkl.opportunities.$chain.$type.$id.tsx @@ -104,7 +104,7 @@ export default function Index() { )} - {!(merklConfig.hideInteractor ?? true) && ( + {merklConfig.deposit && (