diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..64373a06 --- /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 00000000..b8ad6680 --- /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 b48ba0fe..cd38838d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/merkl.config.ts b/merkl.config.ts index 8692a1c8..e23d601f 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, @@ -45,7 +47,10 @@ export default createConfig({ appName: "Merkl", modes: ["dark", "light"], defaultTheme: "ignite", - deposit: false, + tags: [], + opportunityNavigationMode: "direct", + rewardsNavigationMode: "chain", + deposit: true, themes: { ignite: { base: createColoring(["#1755F4", "#FF7900", "#0D1530"], ["#1755F4", "#FF7900", "#FFFFFF"]), @@ -88,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 a544342b..04879243 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 f0ae6d45..3c7c7f53 160000 --- a/packages/dappkit +++ b/packages/dappkit @@ -1 +1 @@ -Subproject commit f0ae6d458929807631f6e02a44279f4cb6d8c515 +Subproject commit 3c7c7f53ece8c9dc245be7cd12ee7e03dc847a0a diff --git a/src/api/opportunity/opportunity.ts b/src/api/opportunity/opportunity.ts deleted file mode 100644 index 5917d41b..00000000 --- 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 7ebaf94c..3868862c 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 00000000..de8fcfc0 --- /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 00000000..3734bdcc --- /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 00000000..4eabd27f --- /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 14516a00..1ca78e0e 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 61d613a4..20c901ab 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 d72d2e2b..8e0d796f 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 91b6a908..59641313 100644 --- a/src/components/composite/Hero.tsx +++ b/src/components/composite/Hero.tsx @@ -3,6 +3,7 @@ import { Container, Divider, Group, Icon, type IconProps, Icons, Tabs, Text, Tit import { Button } 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[]; @@ -91,11 +92,13 @@ export default function Hero({ - {!!description && ( - - {description} - + <> + + + {description} + + )} {!!tags && {tags}} @@ -135,7 +138,7 @@ export function defaultHeroSideDatas(count: number, maxApr: number, dailyRewards ), label: "Live opportunities", - key: crypto.randomUUID(), + key: uuidv4(), }, !!dailyRewards && { data: ( @@ -144,7 +147,7 @@ export function defaultHeroSideDatas(count: number, maxApr: number, dailyRewards ), label: "Daily rewards", - key: crypto.randomUUID(), + key: uuidv4(), }, !!maxApr && { data: ( @@ -153,7 +156,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 96e4fb8f..8511cb3e 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 d37e35a2..3dce7a79 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 00000000..980499fc --- /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 00000000..eab2582c --- /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={