diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..64373a0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": [], + "unwantedRecommendations": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b8ad668 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit" + }, + "editor.defaultFormatter": "biomejs.biome", + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.formatOnSave": true, + "editor.formatOnType": false, + "editor.foldingImportsByDefault": true, + "editor.foldingHighlight": true, + "editor.foldingStrategy": "auto", + "files.eol": "\n" +} diff --git a/bun.lockb b/bun.lockb index b48ba0f..cd38838 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/merkl.config.ts b/merkl.config.ts index afba16c..da2eb19 100644 --- a/merkl.config.ts +++ b/merkl.config.ts @@ -1,7 +1,9 @@ import { createColoring } from "dappkit"; import { createConfig } from "src/config/type"; import hero from "src/customer/assets/images/hero.jpg?url"; +import { v4 as uuidv4 } from "uuid"; import { http, createClient, custom } from "viem"; + import { arbitrum, astar, @@ -44,9 +46,11 @@ import { coinbaseWallet, walletConnect } from "wagmi/connectors"; export default createConfig({ appName: "Puffer", modes: ["light"], - defaultTheme: "puffer", - deposit: false, + defaultTheme: "ignite", tags: ["puffer"], + opportunityNavigationMode: "direct", + rewardsNavigationMode: "chain", + deposit: true, themes: { ignite: { base: createColoring(["#2A35BD", "#BFFF37", "#FFFFFF"], ["#2A35BD", "#BFFF37", "#FFFFFF"]), @@ -89,43 +93,43 @@ export default createConfig({ home: { icon: "RiHomeFill", route: "/", - key: crypto.randomUUID(), + key: uuidv4(), }, opportunities: { icon: "RiPlanetFill", route: "/opportunities", - key: crypto.randomUUID(), + key: uuidv4(), }, // protocols: { // icon: "RiVipCrown2Fill", // route: "/protocols", - // key: crypto.randomUUID(), + // key: uuidv4(), // }, // bridge: { // icon: "RiCompassesLine", // route: "/bridge", - // key: crypto.randomUUID(), + // key: uuidv4(), // }, docs: { icon: "RiFile4Fill", external: true, route: "https://docs.merkl.xyz/", - key: crypto.randomUUID(), + key: uuidv4(), }, faq: { icon: "RiQuestionFill", route: "/faq", - key: crypto.randomUUID(), + key: uuidv4(), }, // terms: { // icon: "RiCompassesLine", // route: "/terms", - // key: crypto.randomUUID(), + // key: uuidv4(), // }, // privacy: { // icon: "RiInformationFill", // route: "/privacy", - // key: crypto.randomUUID(), + // key: uuidv4(), // }, }, socials: { diff --git a/package.json b/package.json index a544342..0487924 100644 --- a/package.json +++ b/package.json @@ -12,15 +12,13 @@ "serve": "remix-serve ./build/server/index.js", "typecheck": "tsc" }, - "workspaces": [ - "packages/*" - ], + "workspaces": ["packages/*"], "dependencies": { "@acab/ecsstatic": "^0.8.0", "@ariakit/react": "^0.4.12", "@elysiajs/eden": "^1.1.3", "@emotion/css": "^11.13.4", - "@merkl/api": "0.10.156", + "@merkl/api": "0.10.188", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.0", "@remix-run/dev": "^2.11.2", @@ -49,6 +47,7 @@ "tailwindcss": "^3.4.12", "tailwindcss-animate": "^1.0.7", "typedoc": "^0.26.7", + "uuid": "^11.0.3", "viem": "2.21.54", "vite-plugin-dts": "^4.2.1", "wagmi": "^2.12.29", diff --git a/packages/dappkit b/packages/dappkit index 826ff4b..3c7c7f5 160000 --- a/packages/dappkit +++ b/packages/dappkit @@ -1 +1 @@ -Subproject commit 826ff4b818716cb9af015adc06c1341a3638c58f +Subproject commit 3c7c7f53ece8c9dc245be7cd12ee7e03dc847a0a diff --git a/src/api/opportunity/opportunity.ts b/src/api/opportunity/opportunity.ts deleted file mode 100644 index 5917d41..0000000 --- a/src/api/opportunity/opportunity.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { api } from "../index.server"; - -function getQueryParams( - request: Request, - overrideQuery?: Parameters[0]["query"], -) { - const status = new URL(request.url).searchParams.get("status"); - const action = new URL(request.url).searchParams.get("action"); - const chainId = new URL(request.url).searchParams.get("chain"); - const page = new URL(request.url).searchParams.get("page"); - - const items = new URL(request.url).searchParams.get("items"); - const search = new URL(request.url).searchParams.get("search"); - const [sort, order] = new URL(request.url).searchParams.get("sort")?.split("-") ?? []; - - const filters = Object.assign( - { status, action, chainId, items, sort, order, name: search, page }, - overrideQuery ?? {}, - page !== null && { page: Number(page) - 1 }, - ); - - const query = Object.entries(filters).reduce( - (_query, [key, filter]) => Object.assign(_query, filter == null ? {} : { [key]: filter }), - {}, - ); - - return query; -} - -export async function fetchOpportunities( - request: Request, - overrideQuery?: Parameters[0]["query"], -) { - const query = getQueryParams(request, overrideQuery); - - const { data: count } = await api.v4.opportunities.count.get({ query }); - const { data: opportunities } = await api.v4.opportunities.index.get({ - query, - }); - - if (count === null || !opportunities) throw "Cannot fetch opportunities"; - return { opportunities, count }; -} diff --git a/src/api/services/campaigns/campaign.service.ts b/src/api/services/campaigns/campaign.service.ts index 7ebaf94..3868862 100644 --- a/src/api/services/campaigns/campaign.service.ts +++ b/src/api/services/campaigns/campaign.service.ts @@ -29,13 +29,13 @@ export abstract class CampaignService { const action = new URL(request.url).searchParams.get("action"); const chainId = new URL(request.url).searchParams.get("chain"); const page = new URL(request.url).searchParams.get("page"); - + const test = new URL(request.url).searchParams.get("test") ?? undefined; const items = new URL(request.url).searchParams.get("items"); const search = new URL(request.url).searchParams.get("search"); const [sort, order] = new URL(request.url).searchParams.get("sort")?.split("-") ?? []; const filters = Object.assign( - { status, action, chainId, items, sort, order, name: search, page }, + { status, action, chainId, items, sort, order, name: search, page, test }, override ?? {}, page !== null && { page: Number(page) - 1 }, ); diff --git a/src/api/services/claims.service.ts b/src/api/services/claims.service.ts new file mode 100644 index 0000000..de8fcfc --- /dev/null +++ b/src/api/services/claims.service.ts @@ -0,0 +1,20 @@ +import { api } from "../index.server"; +import { fetchWithLogs } from "../utils"; + +export abstract class ClaimsService { + static async #fetch( + call: () => Promise, + resource = "Claims", + ): Promise> { + const { data, status } = await fetchWithLogs(call); + + if (status === 404) throw new Response(`${resource} not found`, { status }); + if (status === 500) throw new Response(`${resource} unavailable`, { status }); + if (data == null) throw new Response(`${resource} unavailable`, { status }); + return data; + } + + static async getForUser(address: string) { + return await ClaimsService.#fetch(async () => api.v4.claims({ address }).get()); + } +} diff --git a/src/api/services/interaction.service.ts b/src/api/services/interaction.service.ts new file mode 100644 index 0000000..3734bdc --- /dev/null +++ b/src/api/services/interaction.service.ts @@ -0,0 +1,56 @@ +import { api as clientApi } from "src/api/index.client"; +import { fetchWithLogs } from "../utils"; + +export abstract class InteractionService { + static async #fetch( + call: () => Promise, + resource = "Chain", + ): Promise> { + const { data, status } = await fetchWithLogs(call); + + if (status === 404) throw new Response(`${resource} not found`, { status }); + if (status === 500) throw new Response(`${resource} unavailable`, { status }); + if (data == null) throw new Response(`${resource} unavailable`, { status }); + return data; + } + + /** + * Client side + * @param chainId + * @param protocolId + * @param identifier + */ + static async getTarget(chainId: number, protocolId: string, identifier: string) { + const targets = await InteractionService.#fetch(() => + clientApi.v4.interaction.targets.get({ + query: { chainId, protocolId, identifier }, + }), + ); + + //TODO: opportunity/:id/target instead of taking the first result and expecting unique + return targets?.[0]; + } + + /** + * Client side + */ + static async getTransaction(payload: Parameters[0]["query"]) { + const transaction = await InteractionService.#fetch(() => + clientApi.v4.interaction.transaction.get({ + query: payload, + }), + ); + + return transaction; + } + + static async getBalances(chainId: number, address: string) { + const tokens = await InteractionService.#fetch(() => + clientApi.v4.tokens.balances.get({ + query: { chainId: chainId, userAddress: address }, + }), + ); + + return tokens; + } +} diff --git a/src/api/services/liquidity.service.ts b/src/api/services/liquidity.service.ts new file mode 100644 index 0000000..4eabd27 --- /dev/null +++ b/src/api/services/liquidity.service.ts @@ -0,0 +1,20 @@ +import { api } from "../index.server"; +import { fetchWithLogs } from "../utils"; + +export abstract class LiquidityService { + static async #fetch( + call: () => Promise, + resource = "Positions", + ): Promise> { + const { data, status } = await fetchWithLogs(call); + + if (status === 404) throw new Response(`${resource} not found`, { status }); + if (status === 500) throw new Response(`${resource} unavailable`, { status }); + if (data == null) throw new Response(`${resource} unavailable`, { status }); + return data; + } + + static async getForUser(query: Parameters["0"]["query"]) { + return await LiquidityService.#fetch(async () => api.v4.liquidity.index.get({ query })); + } +} diff --git a/src/api/services/opportunity/opportunity.service.ts b/src/api/services/opportunity/opportunity.service.ts index 14516a0..1ca78e0 100644 --- a/src/api/services/opportunity/opportunity.service.ts +++ b/src/api/services/opportunity/opportunity.service.ts @@ -40,7 +40,7 @@ export abstract class OpportunityService { //TODO: updates tags to take an array if (config.tags && !opportunityWithCampaigns.tags.includes(config.tags?.[0])) - throw new Response("Opportunity inacessible", { status: 403 }); + throw new Response("Opportunity inaccessible", { status: 403 }); return opportunityWithCampaigns; } @@ -96,6 +96,7 @@ export abstract class OpportunityService { sort: url.searchParams.get("sort")?.split("-")[0], order: url.searchParams.get("sort")?.split("-")[1], name: url.searchParams.get("search") ?? undefined, + test: url.searchParams.get("test") ?? undefined, page: url.searchParams.get("page") ? Math.max(Number(url.searchParams.get("page")) - 1, 0) : undefined, ...override, }; diff --git a/src/api/services/protocol.service.ts b/src/api/services/protocol.service.ts index 61d613a..20c901a 100644 --- a/src/api/services/protocol.service.ts +++ b/src/api/services/protocol.service.ts @@ -1,3 +1,4 @@ +import config from "merkl.config"; import { api } from "../index.server"; import { fetchWithLogs } from "../utils"; @@ -5,15 +6,28 @@ export abstract class ProtocolService { // ─── Get Many Protocols ────────────────────────────────────────────── static async get(query: Parameters[0]["query"]) { - return await ProtocolService.#fetch(async () => api.v4.protocols.index.get({ query })); + return await ProtocolService.#fetch(async () => + api.v4.protocols.index.get({ + query: Object.assign({ ...query }, config.tags?.[0] ? { tags: config.tags?.[0] } : {}), + }), + ); } // ─── Get Many Protocols from request ────────────────────────────────── static async getManyFromRequest(request: Request) { - const query = ProtocolService.#getQueryFromRequest(request); - const protocols = await ProtocolService.#fetch(async () => api.v4.protocols.index.get({ query })); - const count = await ProtocolService.#fetch(async () => api.v4.protocols.count.get({ query })); + const query: Parameters[0]["query"] = + ProtocolService.#getQueryFromRequest(request); + const protocols = await ProtocolService.#fetch(async () => + api.v4.protocols.index.get({ + query: Object.assign({ ...query }, config.tags?.[0] ? { tags: config.tags?.[0] } : {}), + }), + ); + const count = await ProtocolService.#fetch(async () => + api.v4.protocols.count.get({ + query: Object.assign({ ...query }, config.tags?.[0] ? { tags: config.tags?.[0] } : {}), + }), + ); return { protocols, count }; } diff --git a/src/api/services/reward.service.ts b/src/api/services/reward.service.ts index d72d2e2..8e0d796 100644 --- a/src/api/services/reward.service.ts +++ b/src/api/services/reward.service.ts @@ -1,42 +1,6 @@ -import type { Reward } from "@merkl/api"; import { api } from "../index.server"; import { fetchWithLogs } from "../utils"; -// Todo: Check how we should type Raw query -export type IRewards = { - amount: string; - recipient: string; - campaignId: string; - reason: string; - Token: { - id: string; - name: string; - chainId: number; - address: string; - decimals: number; - symbol: string; - icon: string; - verified: boolean; - price: number; - }; -}; -// Todo: Check how we should type Raw query -export type ITotalRewards = { - campaignId: string; - totalAmount: string; - Token: { - id: string; - name: string; - chainId: number; - address: string; - decimals: number; - symbol: string; - icon: string; - verified: boolean; - price: number; - }; -}[]; - export abstract class RewardService { static async #fetch( call: () => Promise, @@ -79,18 +43,21 @@ export abstract class RewardService { return query; } - static async getForUser(address: string): Promise { - const rewards = await RewardService.#fetch(async () => api.v4.users({ address }).rewards.full.get()); - - //TODO: add some cache here - return rewards; + static async getForUser(address: string, chainId: number) { + return await RewardService.#fetch(async () => + api.v4.users({ address }).rewards.breakdowns.get({ + query: { chainId }, + }), + ); } static async getManyFromRequest( request: Request, overrides?: Parameters[0]["query"], ) { - return RewardService.getByParams(Object.assign(RewardService.#getQueryFromRequest(request), overrides ?? {})); + return RewardService.getByParams( + Object.assign(RewardService.#getQueryFromRequest(request), overrides ?? undefined), + ); } static async getByParams(query: Parameters[0]["query"]) { @@ -106,10 +73,7 @@ export abstract class RewardService { return { count, rewards, total: amount }; } - static async total(query: { - chainId: number; - campaignId: string; - }): Promise { + static async total(query: { chainId: number; campaignId: string }) { const total = await RewardService.#fetch(async () => api.v4.rewards.total.get({ query: { @@ -119,6 +83,6 @@ export abstract class RewardService { }), ); - return total as ITotalRewards; + return total; } } diff --git a/src/components/composite/Hero.tsx b/src/components/composite/Hero.tsx index c9665e3..14aae0f 100644 --- a/src/components/composite/Hero.tsx +++ b/src/components/composite/Hero.tsx @@ -15,6 +15,7 @@ import { } from "dappkit"; import config from "merkl.config"; import type { PropsWithChildren, ReactNode } from "react"; +import { v4 as uuidv4 } from "uuid"; export type HeroProps = PropsWithChildren<{ icons?: IconProps[]; @@ -47,32 +48,71 @@ export default function Hero({ const location = useLocation(); return ( <> - - - - - - - {breadcrumbs?.map(breadcrumb => { - if (breadcrumb.component) return <>{breadcrumb.component}; - return ( - - ); - })} + {/* TODO: Align lines & descriptions on all pages */} + {/* TODO: On sub-pages (all pages except Opportunities): Replace the banner by a color */} + + + + + + {breadcrumbs?.map(breadcrumb => { + if (breadcrumb.component) return <>{breadcrumb.component}; + return ( + + ); + })} + + + + + + {!!icons && ( + + {icons?.length > 1 + ? icons?.map(icon => ( + + )) + : icons?.map(icon => ( + + ))} + + )} + + {title} + + + + {!!description && ( + <> + + + {description} + + + )} + {!!tags && {tags}} @@ -146,7 +186,7 @@ export function defaultHeroSideDatas(count: number, maxApr: number, dailyRewards ), label: "Live opportunities", - key: crypto.randomUUID(), + key: uuidv4(), }, !!dailyRewards && { data: ( @@ -155,7 +195,7 @@ export function defaultHeroSideDatas(count: number, maxApr: number, dailyRewards ), label: "Daily rewards", - key: crypto.randomUUID(), + key: uuidv4(), }, !!maxApr && { data: ( @@ -164,7 +204,7 @@ export function defaultHeroSideDatas(count: number, maxApr: number, dailyRewards ), label: "Max APR", - key: crypto.randomUUID(), + key: uuidv4(), }, ].filter(data => !!data); } diff --git a/src/components/element/apr/AprModal.tsx b/src/components/element/apr/AprModal.tsx index 96e4fb8..8511cb3 100644 --- a/src/components/element/apr/AprModal.tsx +++ b/src/components/element/apr/AprModal.tsx @@ -1,5 +1,5 @@ import type { Opportunity } from "@merkl/api"; -import { Divider, Group, PrimitiveTag, Title, Value } from "dappkit"; +import { Divider, Group, PrimitiveTag, Title, Value } from "packages/dappkit/src"; import TvlRowAllocation from "../tvl/TvlRowAllocation"; import TvlSection from "../tvl/TvlSection"; import AprSection from "./AprSection"; diff --git a/src/components/element/campaign/CampaignTableRow.tsx b/src/components/element/campaign/CampaignTableRow.tsx index d37e35a..3dce7a7 100644 --- a/src/components/element/campaign/CampaignTableRow.tsx +++ b/src/components/element/campaign/CampaignTableRow.tsx @@ -20,6 +20,7 @@ import Tooltip from "packages/dappkit/src/components/primitives/Tooltip"; import { type ReactNode, useCallback, useMemo, useState } from "react"; import type { Opportunity } from "src/api/services/opportunity/opportunity.model"; import useCampaign from "src/hooks/resources/useCampaign"; +import { v4 as uuidv4 } from "uuid"; import Chain from "../chain/Chain"; import Token from "../token/Token"; import { CampaignRow } from "./CampaignTable"; @@ -97,6 +98,7 @@ export default function CampaignTableRow({ , ], + ["Compute Chain", ], ] as const satisfies [string, ReactNode][]; return columns.map(([label, content]) => { @@ -118,7 +120,7 @@ export default function CampaignTableRow({ {...props} className={mergeClass("cursor-pointer py-4", className)} onClick={toggleIsOpen} - chainColumn={} + chainColumn={} dailyRewardsColumn={ @@ -157,7 +159,7 @@ export default function CampaignTableRow({ {rules?.map(rule => ( - + ))} diff --git a/src/components/element/historicalClaimsLibrary/HistoricalClaimsLibrary.tsx b/src/components/element/historicalClaimsLibrary/HistoricalClaimsLibrary.tsx new file mode 100644 index 0000000..980499f --- /dev/null +++ b/src/components/element/historicalClaimsLibrary/HistoricalClaimsLibrary.tsx @@ -0,0 +1,30 @@ +import { Text, Title } from "dappkit"; +import { useMemo } from "react"; +import type { ClaimsService } from "src/api/services/claims.service"; +import { v4 as uuidv4 } from "uuid"; +import LeaderboardTableRow from "./HistoricalClaimsRow"; +import { HistoricalClaimsTable } from "./HistoricalClaimsTable"; + +export type IProps = { + claims: Awaited>; +}; + +export default function HistoricalClaimsLibrary(props: IProps) { + const { claims } = props; + + const rows = useMemo(() => { + return claims?.map(claim => ); + }, [claims]); + + return ( + (index < 2 ? "bg-accent-8" : "bg-main-8")} + header={ + + Past Claims + + }> + {!!rows.length ? rows : No claim transaction found} + + ); +} diff --git a/src/components/element/historicalClaimsLibrary/HistoricalClaimsRow.tsx b/src/components/element/historicalClaimsLibrary/HistoricalClaimsRow.tsx new file mode 100644 index 0000000..eab2582 --- /dev/null +++ b/src/components/element/historicalClaimsLibrary/HistoricalClaimsRow.tsx @@ -0,0 +1,51 @@ +import { Button, type Component, Icon, mergeClass } from "dappkit"; +import Time from "packages/dappkit/src/components/primitives/Time"; +import { useWalletContext } from "packages/dappkit/src/context/Wallet.context"; +import { useMemo } from "react"; +import type { ClaimsService } from "src/api/services/claims.service"; +import Chain from "../chain/Chain"; +import Token from "../token/Token"; +import { HistoricalClaimsRow } from "./HistoricalClaimsTable"; + +export type HistoricalClaimsRowProps = Component<{ + claim: Awaited>[0]; +}>; + +export default function HistoricalClaimsTableRow({ claim, className, ...props }: HistoricalClaimsRowProps) { + const { chains } = useWalletContext(); + + const chain = useMemo(() => { + return chains?.find(c => c.id === claim.token.chainId); + }, [chains, claim]); + + const value = useMemo(() => { + return Number(claim.amount) * (claim.token.price ?? 0); + }, [claim]); + + return ( + } + tokenColumn={ + + } + dateColumn={ + }> + {children} + + ); +} diff --git a/src/components/element/opportunity/OpportunityShortCard.tsx b/src/components/element/opportunity/OpportunityShortCard.tsx new file mode 100644 index 0000000..db83592 --- /dev/null +++ b/src/components/element/opportunity/OpportunityShortCard.tsx @@ -0,0 +1,42 @@ +import { Box, Group, Icons, Title, Value } from "packages/dappkit/src"; +import type { Opportunity } from "src/api/services/opportunity/opportunity.model"; +import useOpportunity from "src/hooks/resources/useOpportunity"; +import Tag from "../Tag"; + +export type OpportunityShortCardProps = { opportunity: Opportunity }; + +export default function OpportunityShortCard({ opportunity }: OpportunityShortCardProps) { + const { dailyRewards, icons, rewardIcons, tags } = useOpportunity(opportunity); + + return ( + + + + + {dailyRewards} + + + <Icons>{rewardIcons}</Icons> + + {tags + .filter(({ type }) => type === "protocol") + .map(tag => ( + + ))} + + + {icons} + {opportunity.name} + + + + ); +} diff --git a/src/components/element/opportunity/OpportunityTableRow.tsx b/src/components/element/opportunity/OpportunityTableRow.tsx index 9bdbbda..238684b 100644 --- a/src/components/element/opportunity/OpportunityTableRow.tsx +++ b/src/components/element/opportunity/OpportunityTableRow.tsx @@ -4,75 +4,101 @@ import type { BoxProps } from "dappkit"; import { Dropdown, Group, Icon, Icons, PrimitiveTag, Text, Title, Value } from "dappkit"; import { mergeClass } from "dappkit"; import { useOverflowingRef } from "packages/dappkit/src/hooks/events/useOverflowing"; +import { useMemo } from "react"; +import type { OpportunityNavigationMode } from "src/config/opportunity"; import useOpportunity from "src/hooks/resources/useOpportunity"; import Tag, { type TagTypes } from "../Tag"; import AprModal from "../apr/AprModal"; import TokenAmountModal from "../token/TokenAmountModal"; +import OpportunityParticipateModal from "./OpportunityParticipateModal"; import { OpportunityRow } from "./OpportunityTable"; export type OpportunityTableRowProps = { hideTags?: (keyof TagTypes)[]; opportunity: Opportunity; + navigationMode?: OpportunityNavigationMode; } & BoxProps; -export default function OpportunityTableRow({ hideTags, opportunity, className, ...props }: OpportunityTableRowProps) { +export default function OpportunityTableRow({ + hideTags, + opportunity, + className, + navigationMode, + ...props +}: OpportunityTableRowProps) { const { tags, link, icons, rewardsBreakdown } = useOpportunity(opportunity); const { ref, overflowing } = useOverflowingRef(); - return ( - + const aprColumn = useMemo( + () => ( + }> + + + {opportunity.apr / 100} + + + + ), + [opportunity], + ); + + const tvlColumn = useMemo( + () => ( + }> + + + {opportunity.tvl ?? 0} + + + + ), + [opportunity], + ); + + const rewardsColumn = useMemo( + () => ( + + + + Daily Rewards + + + } + /> + }> + + + {opportunity.dailyRewards ?? 0} + + + {rewardsBreakdown.map(({ token: { icon } }) => ( + + ))} + + + + ), + [opportunity, rewardsBreakdown], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: cannot include props + const row = useMemo( + () => ( }> - - - {opportunity.apr / 100} - - - - } - tvlColumn={ - }> - - - {opportunity.tvl ?? 0} - - - - } - rewardsColumn={ - - - - Daily Rewards - - - } - /> - }> - - - {opportunity.dailyRewards ?? 0} - - - {rewardsBreakdown.map(({ token: { icon } }) => ( - - ))} - - - - } + aprColumn={aprColumn} + tvlColumn={tvlColumn} + rewardsColumn={rewardsColumn} opportunityColumn={ @@ -104,6 +130,15 @@ export default function OpportunityTableRow({ hideTags, opportunity, className, } /> + ), + [opportunity, aprColumn, tvlColumn, hideTags, className, rewardsColumn, icons, overflowing, ref], + ); + + if (navigationMode === "supply") + return {row}; + return ( + + {row} ); } diff --git a/src/components/element/participate/Interact.client.tsx b/src/components/element/participate/Interact.client.tsx new file mode 100644 index 0000000..5deed36 --- /dev/null +++ b/src/components/element/participate/Interact.client.tsx @@ -0,0 +1,67 @@ +import type { Opportunity, Token } from "@merkl/api"; +import type { InteractionTarget } from "@merkl/api/dist/src/modules/v4/interaction/interaction.model"; +import { Button, type ButtonProps } 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 useInteractionTransaction from "src/hooks/useInteractionTransaction"; + +export type InteractProps = { + opportunity: Opportunity; + target?: InteractionTarget; + inputToken?: Token; + tokenAddress?: string; + amount?: bigint; + disabled?: boolean; +}; + +export default function Interact({ opportunity, inputToken, amount, target, disabled }: InteractProps) { + const { chainId, switchChain } = useWalletContext(); + const { transaction } = useInteractionTransaction( + opportunity.chainId, + opportunity.protocol?.id, + target, + inputToken, + amount, + ); + const [approvalHash, setApprovalHash] = useState(); + + const currentInteraction = useMemo(() => { + const buttonProps: ButtonProps = { size: "lg", look: "hype", className: "justify-center w-full" }; + + if (disabled) + return ( + + ); + if (!transaction) + return ( + + ); + if (chainId !== opportunity.chainId) + return ( + + ); + + if (!transaction.approved || approvalHash) + return ( + + Approve + + ); + + if (transaction.transaction) + return ( + + Participate + + ); + }, [chainId, opportunity.chainId, transaction, disabled, approvalHash, switchChain]); + + return currentInteraction; +} diff --git a/src/components/element/position/PositionLibrary.tsx b/src/components/element/position/PositionLibrary.tsx new file mode 100644 index 0000000..819f683 --- /dev/null +++ b/src/components/element/position/PositionLibrary.tsx @@ -0,0 +1,32 @@ +import type { PositionT } from "@merkl/api/dist/src/modules/v4/liquidity"; +import { Text, Title } from "dappkit"; +import { useMemo } from "react"; +import OpportunityPagination from "../opportunity/OpportunityPagination"; +import { PositionTable } from "./PositionTable"; +import PositionTableRow from "./PositionTableRow"; + +export type IProps = { + positions: PositionT[]; + count?: number; +}; + +export default function PositionLibrary(props: IProps) { + const { positions, count } = props; + + const rows = useMemo(() => { + return positions?.map(row => ); + }, [positions]); + + return ( + (index < 2 ? "bg-accent-8" : "bg-main-8")} + header={ + + Your Liquidity + + } + footer={count !== undefined && }> + {!!rows.length ? rows : No positions detected} + + ); +} diff --git a/src/components/element/position/PositionTable.tsx b/src/components/element/position/PositionTable.tsx new file mode 100644 index 0000000..8e28a06 --- /dev/null +++ b/src/components/element/position/PositionTable.tsx @@ -0,0 +1,23 @@ +import { createTable } from "dappkit"; + +export const [PositionTable, PositionRow, PositionColumns] = createTable({ + source: { + name: "Source", + size: "minmax(120px,150px)", + compact: "1fr", + className: "justify-start", + main: true, + }, + flags: { + name: "Flags", + size: "minmax(170px,1fr)", + compactSize: "1fr", + className: "justify-start", + }, + tokens: { + name: "Tokens", + size: "minmax(30px,1fr)", + compactSize: "minmax(20px,1fr)", + className: "justify-start", + }, +}); diff --git a/src/components/element/position/PositionTableRow.tsx b/src/components/element/position/PositionTableRow.tsx new file mode 100644 index 0000000..79d78d6 --- /dev/null +++ b/src/components/element/position/PositionTableRow.tsx @@ -0,0 +1,43 @@ +import type { PositionT } from "@merkl/api/dist/src/modules/v4/liquidity"; +import { type Component, Group, PrimitiveTag, Text, mergeClass } from "dappkit"; +import React from "react"; +import Token from "../token/Token"; +import { PositionRow } from "./PositionTable"; + +export type PositionRowProps = Component<{ + row: PositionT; +}>; + +export default function PositionTableRow({ row, className, ...props }: PositionRowProps) { + return ( + {row.opportunity.name}} + flagsColumn={ + + + {row.flags?.id} + + + {row.flags?.range} + + + } + tokensColumn={ + + {row.tokens.map((token, index) => ( + <> + + {token.breakdown.map((breakdown, index) => ( + + {breakdown.type} {breakdown.value} + + ))} + + ))} + + } + /> + ); +} diff --git a/src/components/element/rewards/ClaimRewardsLibrary.tsx b/src/components/element/rewards/ClaimRewardsLibrary.tsx index 61a82f3..cf9d2cd 100644 --- a/src/components/element/rewards/ClaimRewardsLibrary.tsx +++ b/src/components/element/rewards/ClaimRewardsLibrary.tsx @@ -1,7 +1,10 @@ import type { Reward } from "@merkl/api"; import { Group } from "dappkit"; +import config from "merkl.config"; +import { useMemo } from "react"; import { ClaimRewardsChainTable } from "./ClaimRewardsChainTable"; import ClaimRewardsChainTableRow from "./ClaimRewardsChainTableRow"; +import ClaimRewardsByOpportunity from "./byOpportunity/ClaimRewardsByOpportunity"; export type ClaimRewardsLibraryProps = { rewards: Reward[]; @@ -9,13 +12,30 @@ export type ClaimRewardsLibraryProps = { }; export default function ClaimRewardsLibrary({ from, rewards }: ClaimRewardsLibraryProps) { - return ( - - (index === 1 ? "bg-accent-10" : "bg-main-7")}> - {rewards?.map((reward, index) => ( - - ))} - - + const flatenedRewards = useMemo( + () => + rewards.flatMap(({ chain, rewards, distributor }) => + rewards.flatMap(reward => + reward.breakdowns.flatMap(breakdown => ({ chain, distributor, breakdown, token: reward.token })), + ), + ), + [rewards], ); + + const renderRewards = useMemo(() => { + switch (config.rewardsNavigationMode) { + case "opportunity": + return ; + default: + return ( + (index === 1 ? "bg-accent-10" : "bg-main-7")}> + {rewards?.map((reward, index) => ( + + ))} + + ); + } + }, [rewards, flatenedRewards, from]); + + return {renderRewards}; } diff --git a/src/components/element/rewards/byOpportunity/ClaimRewardsByOpportunity.tsx b/src/components/element/rewards/byOpportunity/ClaimRewardsByOpportunity.tsx new file mode 100644 index 0000000..f7919fa --- /dev/null +++ b/src/components/element/rewards/byOpportunity/ClaimRewardsByOpportunity.tsx @@ -0,0 +1,62 @@ +import type { Reward } from "@merkl/api"; +import type { Component } from "dappkit"; +import { Fmt } from "packages/dappkit/src/utils/formatter.service"; +import { useMemo, useState } from "react"; + +import { ClaimRewardsTableByOpportunity } from "./ClaimRewardsTableByOpportunity"; +import ClaimRewardsTokenTableRowByOpportunity from "./ClaimRewardsTokenTableRowByOpportunity"; + +type LocalReward = { + chain: Reward["chain"]; + distributor: Reward["distributor"]; + breakdown: Reward["rewards"][number]["breakdowns"][number]; + token: Reward["rewards"][number]["token"]; +}; + +export type claimRewardsByOpportunityProps = Component<{ + from: string; + rewards: LocalReward[]; +}>; + +export default function ClaimRewardsByOpportunity({ from, rewards }: claimRewardsByOpportunityProps) { + const [selectedTokens, setSelectedTokens] = useState>(new Set()); + + const renderTokenRewards = useMemo(() => { + return rewards + ?.sort((a, b) => { + const priceA = Fmt.toPrice(a.breakdown.amount - a.breakdown.claimed, a.token); + const priceB = Fmt.toPrice(b.breakdown.amount - b.breakdown.claimed, b.token); + + if (b.breakdown.amount === b.breakdown.claimed && a.breakdown.amount === a.breakdown.claimed) + return Fmt.toPrice(b.breakdown.amount, b.token) - Fmt.toPrice(a.breakdown.amount, a.token); + return priceB - priceA; + }) + .map(_reward => ( + { + setSelectedTokens(t => { + if (!t.has(_reward.token.address)) t.add(_reward.token.address); + else t.delete(_reward.token.address); + + return new Set(t); + }); + }, + ]} + breakdown={_reward.breakdown} + token={_reward.token} + distributor={_reward.distributor} + from={from} + /> + )); + }, [rewards, selectedTokens.size, selectedTokens, from]); + + return ( + + {renderTokenRewards} + + ); +} diff --git a/src/components/element/rewards/byOpportunity/ClaimRewardsTableByOpportunity.tsx b/src/components/element/rewards/byOpportunity/ClaimRewardsTableByOpportunity.tsx new file mode 100644 index 0000000..9a23e6a --- /dev/null +++ b/src/components/element/rewards/byOpportunity/ClaimRewardsTableByOpportunity.tsx @@ -0,0 +1,36 @@ +import { createTable } from "dappkit"; + +export const [ClaimRewardsTableByOpportunity, ClaimRewardsByOpportunityRow, claimRewardsByOpportunityColumns] = + createTable({ + positions: { + name: "Positions", + size: "minmax(100px,1fr)", + compact: "1fr", + className: "justify-start", + main: true, + }, + // action: { + // name: "Actions", + // size: "minmax(min-content,200px)", + // compactSize: "minmax(min-content,200px)", + // className: "justify-end", + // }, + claimed: { + name: "Claimed", + size: "minmax(min-content,200px)", + compactSize: "minmax(min-content,200px)", + className: "justify-end", + }, + unclaimed: { + name: "Unclaimed", + size: "minmax(min-content,200px)", + compactSize: "minmax(min-content,200px)", + className: "justify-end", + }, + button: { + name: "", + size: "minmax(min-content,200px)", + compactSize: "minmax(min-content,200px)", + className: "justify-end", + }, + }); diff --git a/src/components/element/rewards/byOpportunity/ClaimRewardsTokenTableRowByOpportunity.tsx b/src/components/element/rewards/byOpportunity/ClaimRewardsTokenTableRowByOpportunity.tsx new file mode 100644 index 0000000..bbdd8f9 --- /dev/null +++ b/src/components/element/rewards/byOpportunity/ClaimRewardsTokenTableRowByOpportunity.tsx @@ -0,0 +1,55 @@ +import type { Reward } from "@merkl/api"; +import { Button, type Component, Divider, type GetSet, Group } from "dappkit"; +import OpportuntiyButton from "../../opportunity/OpportunityButton"; +import Token from "../../token/Token"; +import { ClaimRewardsByOpportunityRow } from "./ClaimRewardsTableByOpportunity"; + +export type ClaimRewardsTokenTableRowProps = Component<{ + breakdown: Reward["rewards"][number]["breakdowns"][number]; + token: Reward["rewards"][number]["token"]; + checkedState?: GetSet; + showCheckbox?: boolean; + from: string; + distributor: string; +}>; + +export default function ClaimRewardsTokenTableRowByOpportunity({ + breakdown, + token, + checkedState, + showCheckbox, + from, + distributor, + ...props +}: ClaimRewardsTokenTableRowProps) { + const unclaimed = breakdown.amount - breakdown.claimed; + return ( + <> + + } + // actionColumn={ + // !!breakdown.opportunity?.action && + // } + claimedColumn={} + unclaimedColumn={ + !!unclaimed && ( + + ) + } + buttonColumn={ + + + + + } + /> + + ); +} diff --git a/src/components/element/token/TokenSelect.tsx b/src/components/element/token/TokenSelect.tsx new file mode 100644 index 0000000..7257925 --- /dev/null +++ b/src/components/element/token/TokenSelect.tsx @@ -0,0 +1,75 @@ +import { Group, Icon, Select, type SelectProps, Text, Title, Value } from "packages/dappkit/src"; +import { Fmt } from "packages/dappkit/src/utils/formatter.service"; +import { useMemo } from "react"; +import Token from "./Token"; + +export type TokenSelectProps = { + tokens: (Token & { balance: bigint })[]; + balances?: boolean; +} & SelectProps; + +export default function TokenSelect({ tokens, balances, ...props }: TokenSelectProps) { + const options = useMemo( + () => + tokens?.reduce( + (obj, token) => + Object.assign(obj, { + [token.address]: , + }), + {}, + ) ?? {}, + [tokens], + ); + + const searchOptions = useMemo( + () => + tokens?.reduce( + (obj, token) => + Object.assign(obj, { + [token.address]: `${token.symbol}-${token.name}-${token.address}`, + }), + {}, + ) ?? {}, + [tokens], + ); + + const displayOptions = useMemo( + () => + tokens?.reduce( + (obj, token) => + Object.assign(obj, { + [token.address]: ( + + + + <Icon size="lg" src={token.icon} /> + + + + {token.name} + + + {token.symbol} - {Fmt.toPrice(token.balance, token)} -{" "} + {Fmt.toNumber(token.balance, token.decimals)}{" "} + + + + + ), + }), + {}, + ) ?? {}, + [tokens], + ); + + return ( +