diff --git a/bun.lockb b/bun.lockb index c62d379d..b48ba0fe 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/merkl.config.ts b/merkl.config.ts index a58c552a..2689207f 100644 --- a/merkl.config.ts +++ b/merkl.config.ts @@ -1,51 +1,75 @@ import { createColoring } from "dappkit"; import { createConfig } from "src/config/type"; -import { createClient, custom, http } from "viem"; +import hero from "src/customer/assets/images/hero.jpg?url"; +import { http, createClient, custom } from "viem"; import { - mainnet, - optimism, - rootstock, - bsc, - gnosis, - thunderCore, - fuse, - polygon, - manta, - xLayer, - fantom, - fraxtal, - filecoin, - zksync, - worldchain, + arbitrum, astar, - polygonZkEvm, - coreDao, - moonbeam, - sei, astarZkEVM, - mantle, + avalanche, base, + blast, + bob, + bsc, + coreDao, + etherlink, + fantom, + filecoin, + fraxtal, + fuse, + gnosis, immutableZkEvm, - mode, - arbitrum, - avalanche, linea, - bob, - blast, - taiko, + lisk, + mainnet, + manta, + mantle, + mode, + moonbeam, + optimism, + polygon, + polygonZkEvm, + rootstock, scroll, + sei, + taiko, + thunderCore, + worldchain, + xLayer, + zksync, } from "viem/chains"; -import { coinbaseWallet, walletConnect } from "wagmi/connectors"; -import hero from "src/customer/assets/images/hero.jpg?url"; import { eip712WalletActions } from "viem/zksync"; +import { coinbaseWallet, walletConnect } from "wagmi/connectors"; export default createConfig({ appName: "Merkl", - modes: ["dark"], - defaultTheme: "merkl", + modes: ["dark", "light"], + defaultTheme: "ignite", + deposit: false, themes: { + ignite: { + base: createColoring(["#1755F4", "#FF7900", "#0D1530"], ["#1755F4", "#FF7900", "#FFFFFF"]), + info: createColoring(["#2ABDFF", "#2ABDFF", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + good: createColoring(["#40B66B", "#40B66B", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + warn: createColoring(["#ff9600", "#ff9600", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + harm: createColoring(["#d22e14", "#d22e14", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + }, merkl: { - base: createColoring(["#1F2333", "#B8AAFD", "#131620"], ["#FCF8F5", "#B8AAFD", "white"]), + base: createColoring(["#1755F4", "#FF7900", "#0D1530"], ["#1755F4", "#FF7900", "#FFFFFF"]), + info: createColoring(["#2ABDFF", "#2ABDFF", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + good: createColoring(["#40B66B", "#40B66B", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + warn: createColoring(["#ff9600", "#ff9600", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + harm: createColoring(["#d22e14", "#d22e14", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + }, + backoffice: { + base: createColoring(["#8B8D98", "#9984D2", "#000000"], ["#8B8D98", "#9984D2", "#FFFFFF"]), + info: createColoring(["#2ABDFF", "#2ABDFF", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + good: createColoring(["#40B66B", "#40B66B", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + warn: createColoring(["#ff9600", "#ff9600", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + harm: createColoring(["#d22e14", "#d22e14", "#131620"], ["#FFFFFF", "#40B66B", "white"]), + }, + puffer: { + base: createColoring(["#2A35BD", "#3D3D3D", "#0E1035"], ["#2A35BD", "#F5F9FF", "#FFFFFF"]), info: createColoring(["#2ABDFF", "#2ABDFF", "#131620"], ["#FFFFFF", "#40B66B", "white"]), good: createColoring(["#40B66B", "#40B66B", "#131620"], ["#FFFFFF", "#40B66B", "white"]), warn: createColoring(["#ff9600", "#ff9600", "#131620"], ["#FFFFFF", "#40B66B", "white"]), @@ -76,16 +100,16 @@ export default createConfig({ // route: "/protocols", // key: crypto.randomUUID(), // }, - terms: { - icon: "RiCompassesLine", - route: "/terms", - key: crypto.randomUUID(), - }, - privacy: { - icon: "RiInformationFill", - route: "/privacy", - key: crypto.randomUUID(), - }, + // terms: { + // icon: "RiCompassesLine", + // route: "/terms", + // key: crypto.randomUUID(), + // }, + // privacy: { + // icon: "RiInformationFill", + // route: "/privacy", + // key: crypto.randomUUID(), + // }, }, socials: { discord: "", @@ -95,13 +119,17 @@ export default createConfig({ }, links: { merkl: "https://merkl.xyz/", + merklTermsConditions: "https://app.merkl.xyz/merklTerms.pdf", + merklPrivacy: "https://privacy.angle.money", }, + footerLinks: [], wagmi: { chains: [ mainnet, optimism, rootstock, bsc, + lisk, gnosis, thunderCore, fuse, @@ -111,6 +139,7 @@ export default createConfig({ fantom, fraxtal, filecoin, + etherlink, zksync, worldchain, astar, @@ -133,7 +162,10 @@ export default createConfig({ ], client({ chain }) { if (chain.id === zksync.id) - return createClient({ chain, transport: custom(window.ethereum!) }).extend(eip712WalletActions()); + return createClient({ + chain, + transport: custom(window.ethereum!), + }).extend(eip712WalletActions()); return createClient({ chain, transport: http() }); }, ssr: true, diff --git a/package.json b/package.json index dd25aa41..a544342b 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "start": "bun run scripts/start.ts", "build": "remix vite:build", "dev": "remix vite:dev --host", - "lint": "biome check --fix ./src", - "lint:ci": "biome check --diagnostic-level error ./src", + "lint": "biome check --fix ./src ./*.ts", + "lint:ci": "biome check --diagnostic-level error ./src ./*.ts", "serve": "remix-serve ./build/server/index.js", "typecheck": "tsc" }, @@ -17,10 +17,10 @@ ], "dependencies": { "@acab/ecsstatic": "^0.8.0", - "@merkl/api": "0.10.96", "@ariakit/react": "^0.4.12", "@elysiajs/eden": "^1.1.3", "@emotion/css": "^11.13.4", + "@merkl/api": "0.10.156", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.0", "@remix-run/dev": "^2.11.2", @@ -39,6 +39,7 @@ "lucide-react": "^0.439.0", "match-sorter": "^6.3.4", "moment": "^2.30.1", + "node-cache": "^5.1.2", "qs": "^6.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -48,7 +49,7 @@ "tailwindcss": "^3.4.12", "tailwindcss-animate": "^1.0.7", "typedoc": "^0.26.7", - "viem": "2.x", + "viem": "2.21.54", "vite-plugin-dts": "^4.2.1", "wagmi": "^2.12.29", "zustand": "^5.0.0-rc.2" diff --git a/src/api/services/cache.service.ts b/src/api/services/cache.service.ts new file mode 100644 index 00000000..5688135b --- /dev/null +++ b/src/api/services/cache.service.ts @@ -0,0 +1,42 @@ +import type { ClientLoaderFunction, ClientLoaderFunctionArgs } from "@remix-run/react"; +import NodeCache from "node-cache"; + +export class CacheService { + cache = new NodeCache(); + + set(key: string, ttl: number, value: T) { + this.cache.set(key, value, ttl); + + return value; + } + + get(key: string): T | undefined { + const cached = this.cache.get(key); + + return cached; + } + + reset(key: string) { + this.cache.del(key); + } + + wrap(resource: string, ttl: number): ClientLoaderFunction { + const loader = async ({ request, serverLoader }: ClientLoaderFunctionArgs) => { + const key = `${resource}:${request.url}`; + const cache = Cache.get(key); + + if (cache) return cache; + + const data = await serverLoader(); + + Cache.set(key, ttl, data); + + return data; + }; + + loader.hydrate = true; + return loader; + } +} + +export const Cache = new CacheService(); diff --git a/src/api/services/campaigns/campaign.model.ts b/src/api/services/campaigns/campaign.model.ts new file mode 100644 index 00000000..16db1222 --- /dev/null +++ b/src/api/services/campaigns/campaign.model.ts @@ -0,0 +1,6 @@ +import type { Campaign as CampaignFromApi } from "@merkl/api"; +import type { Fetched } from "src/api/types"; + +export type Campaign = Fetched> & { + params: CampaignFromApi["params"]; +}; diff --git a/src/api/services/campaign.service.ts b/src/api/services/campaigns/campaign.service.ts similarity index 65% rename from src/api/services/campaign.service.ts rename to src/api/services/campaigns/campaign.service.ts index 00604e36..7ebaf94c 100644 --- a/src/api/services/campaign.service.ts +++ b/src/api/services/campaigns/campaign.service.ts @@ -1,7 +1,20 @@ -import type { Campaign } from "@angleprotocol/merkl-api"; -import { api } from "../index.server"; +import type { Campaign } from "@merkl/api"; +import { fetchWithLogs } from "src/api/utils"; +import { api } from "../../index.server"; export abstract class CampaignService { + static async #fetch( + call: () => Promise, + resource = "Opportunity", + ): 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; + } + /** * Retrieves opportunities query params from page request * @param request request containing query params such as chains, status, pagination... @@ -36,19 +49,18 @@ export abstract class CampaignService { } // ------ Fetch all campaigns - static async get(): Promise { + static async get() { const { data } = await api.v4.campaigns.index.get({ query: {} }); return data; } - static async getByParams(query: Parameters[0]["query"]): Promise { - const { data } = await api.v4.campaigns.index.get({ query }); - return data; + static async getByParams(query: Parameters[0]["query"]) { + return await CampaignService.#fetch(async () => api.v4.campaigns.index.get({ query })); } // ------ Fetch a campaign by ID - static async getByID(Id: string): Promise { + static async getByID(_Id: string): Promise { return null; } } diff --git a/src/api/services/opportunity/opportunity.model.ts b/src/api/services/opportunity/opportunity.model.ts new file mode 100644 index 00000000..84e4bef7 --- /dev/null +++ b/src/api/services/opportunity/opportunity.model.ts @@ -0,0 +1,6 @@ +import type { Opportunity as OpportunityFromApi } from "@merkl/api"; +import type { Fetched } from "src/api/types"; +import type { Campaign } from "../campaigns/campaign.model"; + +export type Opportunity = Fetched; +export type OpportunityWithCampaigns = Fetched; diff --git a/src/api/services/opportunity.service.ts b/src/api/services/opportunity/opportunity.service.ts similarity index 54% rename from src/api/services/opportunity.service.ts rename to src/api/services/opportunity/opportunity.service.ts index 974f5623..14516a00 100644 --- a/src/api/services/opportunity.service.ts +++ b/src/api/services/opportunity/opportunity.service.ts @@ -1,69 +1,16 @@ import type { Opportunity } from "@merkl/api"; import config from "merkl.config"; -import { api } from "../index.server"; -import { fetchWithLogs } from "../utils"; +import { api } from "../../index.server"; +import { fetchWithLogs } from "../../utils"; export abstract class OpportunityService { - static async #fetch( - call: () => Promise, - resource = "Opportunity", - ): 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; - } - - /** - * Retrieves opportunities 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"], - ) { - 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: chainId !== "" ? chainId : undefined, - items: items ?? 50, - sort, - order, - name: search, - 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 getManyFromRequest( request: Request, overrides?: Parameters[0]["query"], ) { return OpportunityService.getMany(Object.assign(OpportunityService.#getQueryFromRequest(request), overrides ?? {})); } + // ─── Get Many Opportunities ────────────────────────────────────────────── static async getMany( query: Parameters[0]["query"], @@ -79,22 +26,7 @@ export abstract class OpportunityService { return { opportunities: opportunities.filter(o => o !== null), count }; } - static async get(query: { - chainId: number; - type: string; - identifier: string; - }): Promise { - const { chainId, type, identifier } = query; - const opportunity = await OpportunityService.#fetch(async () => - api.v4.opportunities({ id: `${chainId}-${type}-${identifier}` }).get(), - ); - - //TODO: updates tags to take an array - if (config.tags && !opportunity.tags.includes(config.tags?.[0])) - throw new Response("Opportunity inacessible", { status: 403 }); - - return opportunity; - } + // ─── Get Opportunities with campaign ────────────────────────────────────────────── static async getCampaignsByParams(query: { chainId: number; @@ -112,4 +44,65 @@ export abstract class OpportunityService { return opportunityWithCampaigns; } + + // ─── Get Aggregate ────────────────────────────────────────────── + + static async getAggregate( + query: Parameters[0]["query"], + params: "dailyRewards", + ) { + return await OpportunityService.#fetch(async () => + api.v4.opportunities.aggregate({ field: params }).get({ query }), + ); + } + + static async #fetch( + call: () => Promise, + resource = "Opportunity", + ): 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; + } + + /** + * Retrieves opportunities 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 + */ + /** + * Retrieves opportunities 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"], + ) { + const url = new URL(request.url); + + const filters = { + status: url.searchParams.get("status") ?? undefined, + mainProtocolId: url.searchParams.get("protocol") ?? url.searchParams.get("mainProtocolId") ?? undefined, + action: url.searchParams.get("action") ?? undefined, + chainId: url.searchParams.get("chain") ?? undefined, + minimumTvl: url.searchParams.get("tvl") ?? undefined, + items: url.searchParams.get("items") ? Number(url.searchParams.get("items")) : 50, + sort: url.searchParams.get("sort")?.split("-")[0], + order: url.searchParams.get("sort")?.split("-")[1], + name: url.searchParams.get("search") ?? undefined, + page: url.searchParams.get("page") ? Math.max(Number(url.searchParams.get("page")) - 1, 0) : undefined, + ...override, + }; + + // Remove null/undefined values + const query = Object.fromEntries(Object.entries(filters).filter(([, value]) => value != null)); + + return query; + } } diff --git a/src/api/services/protocol.service.ts b/src/api/services/protocol.service.ts index 8d405643..61d613a4 100644 --- a/src/api/services/protocol.service.ts +++ b/src/api/services/protocol.service.ts @@ -1,8 +1,23 @@ -import type { Protocol } from "@merkl/api"; import { api } from "../index.server"; import { fetchWithLogs } from "../utils"; 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 })); + } + + // ─── 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 })); + + return { protocols, count }; + } + static async #fetch( call: () => Promise, resource = "Protocol", @@ -45,17 +60,10 @@ export abstract class ProtocolService { return query; } - static async get(query: { id: string }): Promise { - const protocol = await ProtocolService.#fetch(async () => api.v4.protocols(query).get({ query })); + static async getAll() { + const protocols = await ProtocolService.#fetch(async () => api.v4.protocols.index.get({ query: { items: 10000 } })); - return protocol; - } - - static async getManyFromRequest(request: Request): Promise<{ protocols: Protocol[]; count: number }> { - 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 })); - - return { protocols, count }; + //TODO: add some cache here + return protocols; } } diff --git a/src/api/services/reward.service.ts b/src/api/services/reward.service.ts index d671bacc..d72d2e2b 100644 --- a/src/api/services/reward.service.ts +++ b/src/api/services/reward.service.ts @@ -50,6 +50,35 @@ export abstract class RewardService { return data; } + /** + * Retrieves opportunities 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"]) { + 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"); + + const filters = Object.assign( + { + campaignId, + items: items ?? 50, + 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 getForUser(address: string): Promise { const rewards = await RewardService.#fetch(async () => api.v4.users({ address }).rewards.full.get()); @@ -57,33 +86,35 @@ export abstract class RewardService { return rewards; } - static async getByParams(query: { - items?: number; - page?: number; - chainId: number; - campaignIds: string[]; - }) { + static async getManyFromRequest( + request: Request, + overrides?: Parameters[0]["query"], + ) { + return RewardService.getByParams(Object.assign(RewardService.#getQueryFromRequest(request), overrides ?? {})); + } + + static async getByParams(query: Parameters[0]["query"]) { const rewards = await RewardService.#fetch(async () => api.v4.rewards.index.get({ - query: { - ...query, - campaignIds: query.campaignIds.join(","), - }, + query, }), ); - return rewards as unknown as IRewards[]; + const count = await RewardService.#fetch(async () => api.v4.rewards.count.get({ query })); + const { amount } = await RewardService.#fetch(async () => api.v4.rewards.total.get({ query })); + + return { count, rewards, total: amount }; } static async total(query: { chainId: number; - campaignIds: string[]; + campaignId: string; }): Promise { const total = await RewardService.#fetch(async () => api.v4.rewards.total.get({ query: { ...query, - campaignIds: query.campaignIds.join(","), + campaignId: query.campaignId, }, }), ); diff --git a/src/api/types.ts b/src/api/types.ts index 6b369f0a..61557367 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,3 +1,10 @@ import type { MerklApi } from "@merkl/api"; +import type { SerializeFrom, TypedResponse } from "@remix-run/node"; export type Api = ReturnType; + +/** + * Type returned through a loader + * @description helps with inconsistencies of the implicit type when returned through the remix json() loader + */ +export type Fetched = SerializeFrom<() => TypedResponse>; diff --git a/src/api/utils.ts b/src/api/utils.ts index 44e02cff..d0d93984 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -26,8 +26,10 @@ export async function fetchWithLogs; -export default function Hero({ navigation, breadcrumbs, icons, title, description, tags, tabs, children }: HeroProps) { - const location = useLocation(); +export type HeroInformations = { + data: React.ReactNode; + label: string; + key: string; +}; +export default function Hero({ + navigation, + breadcrumbs, + icons, + title, + description, + tags, + sideDatas, + tabs, + children, +}: HeroProps) { + const location = useLocation(); return ( <> + {/* TODO: Align lines & descriptions on all pages */} + {/* TODO: On sub-pages (all pages except Opportunities): Replace the banner by a color */} + className={`${ + location?.pathname === "/" || location?.pathname === "/opportunities" ? "bg-cover" : "bg-main-6" + } flex-row justify-between bg-no-repeat xl:aspect-auto xl:min-h-[350px] aspect-[1440/300]`} + style={{ + backgroundImage: + location?.pathname === "/" || location?.pathname === "/opportunities" + ? `url('${config.images.hero}')` + : "none", + }}> - - {/* TODO: Build dynamic breadcrumbs */} - {/** Disabled and set invisible when undefined to preserve layout height */} - {breadcrumbs?.map(breadcrumb => { - if (breadcrumb.component) return breadcrumb.component; + if (breadcrumb.component) return <>{breadcrumb.component}; return ( - ))} - - )} - + + {!!tabs && } +
{children}
); } + +export function defaultHeroSideDatas(count: number, maxApr: number, dailyRewards: number) { + return [ + !!count && { + data: ( + + {count} + + ), + label: "Live opportunities", + key: crypto.randomUUID(), + }, + !!dailyRewards && { + data: ( + + {dailyRewards} + + ), + label: "Daily rewards", + key: crypto.randomUUID(), + }, + !!maxApr && { + data: ( + + {maxApr / 100} + + ), + label: "Max APR", + key: crypto.randomUUID(), + }, + ].filter(data => !!data); +} diff --git a/src/components/element/AddressEdit.tsx b/src/components/element/AddressEdit.tsx new file mode 100644 index 00000000..90af014e --- /dev/null +++ b/src/components/element/AddressEdit.tsx @@ -0,0 +1,22 @@ +import { useNavigate } from "@remix-run/react"; +import { Button, Group, Icon, Input } from "packages/dappkit/src"; +import { useState } from "react"; + +export default function AddressEdit() { + const navigate = useNavigate(); + const [inputAddress, setInputAddress] = useState(); + const [_isEditingAddress, setIsEditingAddress] = useState(false); + + return ( + + + + + ); +} diff --git a/src/components/element/Socials.tsx b/src/components/element/Socials.tsx index 2ea6204c..b045d7d5 100644 --- a/src/components/element/Socials.tsx +++ b/src/components/element/Socials.tsx @@ -24,6 +24,11 @@ export default function Socials() { )} + {!!config.socials.medium && ( + + )}
); } diff --git a/src/components/element/SwitchMode.tsx b/src/components/element/SwitchMode.tsx index 1379ae9d..2ad8e7a3 100644 --- a/src/components/element/SwitchMode.tsx +++ b/src/components/element/SwitchMode.tsx @@ -1,16 +1,31 @@ +import { Button, Icon, Select, useTheme } from "dappkit"; import config from "merkl.config"; -import { Button, Icon, useTheme } from "packages/dappkit/src"; import { useMemo } from "react"; export default function SwitchMode() { - const { mode, toggleMode } = useTheme(); + const { mode, toggleMode, themes, theme, setTheme } = useTheme(); const canSwitchModes = useMemo(() => !(!config.modes || config.modes?.length === 1), []); + const themeOptions = useMemo(() => { + return Object.keys(themes).reduce( + (obj, name) => + Object.assign(obj, { + [name]: name, + }), + {}, + ); + }, [themes]); + return ( - canSwitchModes && ( - - ) + <> + {process.env.NODE_ENV !== "production" && Object.keys(themeOptions)?.length > 1 && ( + setInnerSearch(v)]} suffix={} onClick={onSearchSubmit} + size="sm" placeholder="Search" /> )} {fields.includes("action") && ( setStatus(s as string[])]} + state={[statusInput, setStatusInput]} allOption={"All status"} multiple options={statusOptions} - look="bold" + look="tint" placeholder="Status" /> )} {fields.includes("chain") && ( setProtocolInput(n)]} + allOption={"All protocols"} + multiple + search + options={protocolOptions} + look="tint" + placeholder="Protocols" + /> + )} + {fields.includes("tvl") && ( +
+ (/^\d+$/.test(n) || !n) && setTvlInput(n)]} + look="soft" + name="tvl" + value={tvlInput} + className="min-w-[4ch]" + suffix={} + placeholder="Minimum TVL" + /> + + )} + {((canApply && !clearing && navigation.state === "idle") || + (applying && !clearing && navigation.state === "loading")) && ( + + )} + ); } diff --git a/src/components/element/opportunity/OpportunityLibrary.tsx b/src/components/element/opportunity/OpportunityLibrary.tsx index 72eef6e6..9e6c484b 100644 --- a/src/components/element/opportunity/OpportunityLibrary.tsx +++ b/src/components/element/opportunity/OpportunityLibrary.tsx @@ -1,6 +1,7 @@ -import type { Chain, Opportunity } from "@merkl/api"; -import { Group, type Order } from "dappkit"; +import type { Chain } from "@merkl/api"; +import { Box, Group, type Order, Title } from "dappkit"; import { useMemo } from "react"; +import type { Opportunity } from "src/api/services/opportunity/opportunity.model"; import useSearchParamState from "src/hooks/filtering/useSearchParamState"; import OpportunityFilters, { type OpportunityFilterProps } from "./OpportunityFilters"; import OpportunityPagination from "./OpportunityPagination"; @@ -13,12 +14,17 @@ export type OpportunityLibrary = { chains?: Chain[]; } & OpportunityFilterProps; -export default function OpportunityLibrary({ opportunities, count, only, exclude, chains }: OpportunityLibrary) { +export default function OpportunityLibrary({ + opportunities, + count, + only, + exclude, + chains, + protocols, +}: OpportunityLibrary) { const rows = useMemo( () => - opportunities?.map(o => ( - - )), + opportunities?.map(o => ), [opportunities], ); @@ -37,18 +43,24 @@ export default function OpportunityLibrary({ opportunities, count, only, exclude } return ( - } - header={ - - - - }> - {rows} - + + + + + + Opportunities + + } + dividerClassName={index => (index < 2 ? "bg-accent-8" : "bg-main-8")} + sortable={sortable} + order={(sortIdAndOrder ?? [])?.[1]} + sort={(sortIdAndOrder ?? [])?.[0] ?? "rewards"} + onSort={onSort} + footer={count !== undefined && }> + {rows} + + ); } diff --git a/src/components/element/opportunity/OpportunityPagination.tsx b/src/components/element/opportunity/OpportunityPagination.tsx index dfd6f471..d5a39e3b 100644 --- a/src/components/element/opportunity/OpportunityPagination.tsx +++ b/src/components/element/opportunity/OpportunityPagination.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Icon, List, Select } from "dappkit/src"; +import { Button, Group, Icon, Select } from "dappkit/src"; import { useMemo } from "react"; import useSearchParamState from "src/hooks/filtering/useSearchParamState"; @@ -19,7 +19,7 @@ export default function OpportunityPagination({ count }: OpportunityPaginationPr v => Number.parseInt(v), ); - const pages = useMemo(() => Math.round((count ?? 0) / (itemsFilter ?? 20)) - 1, [count, itemsFilter]); + const pages = useMemo(() => Math.ceil((count ?? 0) / (itemsFilter ?? 20)), [count, itemsFilter]); const pageOptions = useMemo(() => { return [...Array(Math.max(Math.round(pages ?? 0), 1)).fill(0)] .map((_, index) => index + 1) @@ -28,30 +28,42 @@ export default function OpportunityPagination({ count }: OpportunityPaginationPr return ( - + + + + - - + ); } diff --git a/src/components/element/opportunity/OpportunityTable.tsx b/src/components/element/opportunity/OpportunityTable.tsx index 704d99c3..93a5cf0d 100644 --- a/src/components/element/opportunity/OpportunityTable.tsx +++ b/src/components/element/opportunity/OpportunityTable.tsx @@ -1,20 +1,18 @@ -import { createTable } from "dappkit"; +import { Title, createTable } from "dappkit"; export const [OpportunityTable, OpportunityRow, opportunityColumns] = createTable({ opportunity: { - name: "Opportunities", + name: ( + + Opportunities + + ), size: "minmax(350px,1fr)", compact: "1fr", className: "justify-start", main: true, }, - actions: { - name: "Actions", - size: "minmax(min-content,150px)", - compactSize: "minmax(min-content,1fr)", - className: "justify-center", - }, - apy: { + apr: { name: "APR", size: "minmax(min-content,150px)", compactSize: "minmax(min-content,1fr)", diff --git a/src/components/element/opportunity/OpportunityTableRow.tsx b/src/components/element/opportunity/OpportunityTableRow.tsx index 1b6960d0..44a874ef 100644 --- a/src/components/element/opportunity/OpportunityTableRow.tsx +++ b/src/components/element/opportunity/OpportunityTableRow.tsx @@ -1,14 +1,13 @@ import type { Opportunity } from "@merkl/api"; import { Link } from "@remix-run/react"; -import { Group } from "dappkit"; -import { Icons } from "dappkit"; import type { BoxProps } from "dappkit"; -import { Title } from "dappkit"; -import { Value } from "dappkit"; -import { Button } 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 useOpportunity from "src/hooks/resources/useOpportunity"; import Tag, { type TagTypes } from "../Tag"; +import AprModal from "../apr/AprModal"; +import TokenAmountModal from "../token/TokenAmountModal"; import { OpportunityRow } from "./OpportunityTable"; export type OpportunityTableRowProps = { @@ -17,67 +16,89 @@ export type OpportunityTableRowProps = { } & BoxProps; export default function OpportunityTableRow({ hideTags, opportunity, className, ...props }: OpportunityTableRowProps) { - const { tags, link, icons } = useOpportunity(opportunity); + const { tags, link, icons, rewardsBreakdown } = useOpportunity(opportunity); + + const { ref, overflowing } = useOverflowingRef(); return ( - + - {tags - ?.filter(({ type }) => !hideTags || hideTags.includes(type)) - .map(tag => { - return ; - })} - - } - apyColumn={ - - - + + } tvlColumn={ - - - + + } rewardsColumn={ - - - + + {rewardsBreakdown.map(({ token: { icon } }) => ( + + ))} + + + } opportunityColumn={ - - - {icons} - - {opportunity.name} - + + + + {icons} + + {/* TODO: embed the ellipsis scroll behavior in the Text component as an ellipsis prop */} + + + <span className="overflow-visible">{opportunity.name}</span> + + {tags + ?.filter(a => a !== undefined) ?.filter(({ type }) => !hideTags || !hideTags.includes(type)) .map(tag => ( - + ))} diff --git a/src/components/element/participate/Participate.client.tsx b/src/components/element/participate/Participate.client.tsx new file mode 100644 index 00000000..7113912b --- /dev/null +++ b/src/components/element/participate/Participate.client.tsx @@ -0,0 +1,74 @@ +import type { Opportunity } from "@merkl/api"; +import { Box, Button, Group, Input, Select, Space, Text } from "dappkit"; +import TransactionButton from "packages/dappkit/src/components/dapp/TransactionButton"; +import { useMemo, useState } from "react"; +import useParticipate from "src/hooks/useParticipate"; +import { formatUnits } from "viem"; +import Token from "../token/Token"; + +export type ParticipateProps = { + opportunity: Opportunity; +}; + +export default function Participate({ opportunity }: ParticipateProps) { + const [tokenAddress, setTokenAddress] = useState(); + const [amount, setAmount] = useState(0n); + const { + target, + targets, + balance, + token: inputToken, + approve, + deposit, + transaction, + } = useParticipate(opportunity.chainId, opportunity.protocol?.id, opportunity.identifier, tokenAddress, amount); + + console.log("tx", transaction, opportunity.identifier, target); + + const balanceOptions = useMemo( + () => + balance?.reduce( + (obj, token) => + Object.assign(obj, { + [token.address]: ( + <> + ({formatUnits(token?.balance, token?.decimals)}) + + ), + }), + {}, + ) ?? {}, + [balance], + ); + return ( + <> + + + + + + + + token0} /> + token1} /> + Approve + Participate + + + + Warning about the fiability of the feature + + + ); +} diff --git a/src/components/element/participate/Participate.tsx b/src/components/element/participate/Participate.tsx deleted file mode 100644 index 68740daf..00000000 --- a/src/components/element/participate/Participate.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Opportunity } from "@merkl/api"; -import { Box, Button, Input, Space, Text } from "dappkit"; - -export type ParticipateProps = { - opportunity: Opportunity; -}; - -export default function Participate(_props: ParticipateProps) { - return ( - <> - - - - - - token0} /> - token1} /> - - - - - Warning about the fiability of the feature - - - ); -} diff --git a/src/components/element/participate/ParticipateTester.client.tsx b/src/components/element/participate/ParticipateTester.client.tsx index eb6ebb53..9ec55b20 100644 --- a/src/components/element/participate/ParticipateTester.client.tsx +++ b/src/components/element/participate/ParticipateTester.client.tsx @@ -75,7 +75,7 @@ export default function ParticipateTester({ chains }: ParticipateTesterProps) { {/* */} {/* {target.name} */} {target?.tokens.map(tkn => ( - + ))} ), diff --git a/src/components/element/protocol/ProtocolTable.tsx b/src/components/element/protocol/ProtocolTable.tsx index 400c39b5..6df71742 100644 --- a/src/components/element/protocol/ProtocolTable.tsx +++ b/src/components/element/protocol/ProtocolTable.tsx @@ -2,7 +2,7 @@ import { createTable } from "dappkit"; export const [ProtocolTable, ProtocolRow, protocolColumns] = createTable({ protocol: { - name: "PROTOCOL", + name: "Protocol", size: "minmax(350px,1fr)", compact: "1fr", className: "justify-start", diff --git a/src/components/element/protocol/ProtocolTableRow.tsx b/src/components/element/protocol/ProtocolTableRow.tsx index 84560370..0ec45eb2 100644 --- a/src/components/element/protocol/ProtocolTableRow.tsx +++ b/src/components/element/protocol/ProtocolTableRow.tsx @@ -12,9 +12,9 @@ export type ProtocolTableRowProps = { protocol: Protocol; } & BoxProps; -export default function ProtocolTableRow({ hideTags, protocol, className, ...props }: OpportunityTableRowProps) { +export default function ProtocolTableRow({ hideTags, protocol, className, ...props }: ProtocolTableRowProps) { return ( - + reward.chain.id === chainId, [reward, chainId]); - const claimTransaction = useMemo(() => { - const abi = parseAbi(["function claim(address[],address[],uint256[],bytes32[][]) view returns (uint256)"]); - - const tokenAddresses = reward.rewards.map(({ token }) => token.address as `0x${string}`); - const accumulatedRewards = reward.rewards.map(({ amount }) => amount); - const proofs = reward.rewards.map(({ proofs }) => proofs as `0x${string}`[]); + const { claimTransaction } = useReward( + reward, + !isUserRewards ? undefined : user, + selectedTokens?.size > 0 ? selectedTokens : undefined, + ); - if (!reward || !user || !isUserRewards) return; - return { - to: reward.distributor, - data: encodeFunctionData({ - abi, - functionName: "claim", - args: [tokenAddresses.map(() => user as `0x${string}`), tokenAddresses, accumulatedRewards, proofs], - }), - }; - }, [user, reward, isUserRewards]); + const unclaimed = useMemo( + () => reward.rewards.reduce((sum, { amount, claimed, token }) => sum + Fmt.toPrice(amount - claimed, token), 0), + [reward], + ); - const unclaimed = useMemo(() => { - return reward.rewards.reduce( - (sum, { amount, claimed, token: { decimals, price } }) => - sum + Number.parseFloat(formatUnits(amount - claimed, decimals)) * price, - 0, - ); - }, [reward]); + const renderTokenRewards = useMemo(() => { + return reward.rewards + .sort((a, b) => { + const priceA = Fmt.toPrice(a.amount - a.claimed, a.token); + const priceB = Fmt.toPrice(b.amount - b.claimed, b.token); - const claimed = useMemo(() => { - return reward.rewards.reduce( - (sum, { claimed, token: { decimals, price } }) => sum + Number.parseFloat(formatUnits(claimed, decimals)) * price, - 0, - ); - }, [reward]); + if (b.amount === b.claimed && a.amount === a.claimed) + return Fmt.toPrice(b.amount, b.token) - Fmt.toPrice(a.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); - const renderTokenRewards = useMemo( - () => - reward.rewards - .sort((a, b) => Number(b.amount - b.claimed - (a.amount - a.claimed))) - .map(_reward => ( - { - setSelectedTokens(t => { - if (checked) t.add(_reward.token.address); - else t.delete(_reward.token.address); - - return new Set(t); - }); - }, - ]} - key={_reward.token.address} - reward={_reward} - /> - )), - [reward, selectedTokens], - ); + return new Set(t); + }); + }, + ]} + reward={_reward} + /> + )); + }, [reward, selectedTokens.size, selectedTokens, isOnCorrectChain, isAbleToClaim]); return ( ) : ( - + ))} } unclaimedColumn={ - - {unclaimed} - - } - claimedColumn={ - - {claimed} - + unclaimed === 0 ? undefined : ( + + {unclaimed} + + ) }> - - TOKEN - - } - size="sm" - look="soft"> + "!bg-main-8"} className="[&>*]:bg-main-4" look="soft"> {renderTokenRewards} diff --git a/src/components/element/rewards/ClaimRewardsLibrary.tsx b/src/components/element/rewards/ClaimRewardsLibrary.tsx index 17af919a..61a82f3d 100644 --- a/src/components/element/rewards/ClaimRewardsLibrary.tsx +++ b/src/components/element/rewards/ClaimRewardsLibrary.tsx @@ -11,7 +11,7 @@ export type ClaimRewardsLibraryProps = { export default function ClaimRewardsLibrary({ from, rewards }: ClaimRewardsLibraryProps) { return ( - + (index === 1 ? "bg-accent-10" : "bg-main-7")}> {rewards?.map((reward, index) => ( ))} diff --git a/src/components/element/rewards/ClaimRewardsTokenTable.tsx b/src/components/element/rewards/ClaimRewardsTokenTable.tsx index 9d2cbdea..2ecedfe8 100644 --- a/src/components/element/rewards/ClaimRewardsTokenTable.tsx +++ b/src/components/element/rewards/ClaimRewardsTokenTable.tsx @@ -2,27 +2,27 @@ import { createTable } from "dappkit"; export const [ClaimRewardsTokenTable, ClaimRewardsTokenRow, claimRewardsTokenColumns] = createTable({ token: { - name: "TOKEN", + name: "Token", size: "minmax(100px,1fr)", compact: "1fr", className: "justify-start", main: true, }, - pending: { - name: "PENDING", - size: "minmax(min-content,150px)", + amount: { + name: "Claimable Now", + size: "minmax(min-content,200px)", compactSize: "minmax(min-content,200px)", className: "justify-end", }, - amount: { - name: "UNCLAIMED", - size: "minmax(min-content,150px)", + pending: { + name: "Claimable Soon", + size: "minmax(min-content,200px)", compactSize: "minmax(min-content,200px)", className: "justify-end", }, claimed: { - name: "CLAIMED", - size: "minmax(min-content,150px)", + name: "Claimed", + size: "minmax(min-content,200px)", compactSize: "minmax(min-content,200px)", className: "justify-end", }, diff --git a/src/components/element/rewards/ClaimRewardsTokenTablePrice.tsx b/src/components/element/rewards/ClaimRewardsTokenTablePrice.tsx index 9a0e8fa3..89fb87bc 100644 --- a/src/components/element/rewards/ClaimRewardsTokenTablePrice.tsx +++ b/src/components/element/rewards/ClaimRewardsTokenTablePrice.tsx @@ -1,9 +1,9 @@ -import { Group, Value } from "dappkit"; +import { Group, PrimitiveTag, Value } from "dappkit"; import type { PropsWithChildren } from "react"; import { formatUnits } from "viem"; export type ClaimRewardsTokenTablePriceProps = PropsWithChildren & { - price: number; + price: number | null; amount: bigint; decimals: number; }; @@ -11,25 +11,29 @@ export type ClaimRewardsTokenTablePriceProps = PropsWithChildren & { export default function ClaimRewardsTokenTablePrice({ amount, price, decimals }: ClaimRewardsTokenTablePriceProps) { const value = formatUnits(amount, decimals); + if (value === "0") return; return ( - + (v as string).includes("0.000") && "<0.001"} - className="text-right" + fallback={v => (v as string).includes("0.000") && "< 0.001"} + className="text-right items-center flex font-title" look={"bold"} format="0,0.###"> {value} - { - if (price === 0) return "-"; - return (v.toString() as string).includes("0.0") && "$<0.1"; - }} - className="text-right" - look={"soft"} - format="$0,0.#"> - {Number.parseFloat(value) * price} - + + { + if (price === 0) return "-"; + return (v.toString() as string).includes("0.0") && "< $0.1"; + }} + size="xs" + className="text-right items-center flex font-title" + look={"bold"} + format="$0,0.#a"> + {Number.parseFloat(value) * (price ?? 0)} + + ); } diff --git a/src/components/element/rewards/ClaimRewardsTokenTableRow.tsx b/src/components/element/rewards/ClaimRewardsTokenTableRow.tsx index cf53310c..e9bfa2e5 100644 --- a/src/components/element/rewards/ClaimRewardsTokenTableRow.tsx +++ b/src/components/element/rewards/ClaimRewardsTokenTableRow.tsx @@ -1,29 +1,36 @@ import type { Reward } from "@merkl/api"; -import { Checkbox, Divider, type GetSet, Group, Icon, Space } from "dappkit"; +import { Checkbox, type Component, Divider, type GetSet, Group, Icon, Space } from "dappkit"; import Collapsible from "packages/dappkit/src/components/primitives/Collapsible"; -import { type PropsWithChildren, useMemo, useState } from "react"; +import { Fmt } from "packages/dappkit/src/utils/formatter.service"; +import { useMemo, useState } from "react"; import Tag from "../Tag"; import OpportuntiyButton from "../opportunity/OpportunityButton"; import { ClaimRewardsTokenRow } from "./ClaimRewardsTokenTable"; import ClaimRewardsTokenTablePrice from "./ClaimRewardsTokenTablePrice"; -export type ClaimRewardsTokenTableRowProps = PropsWithChildren & { +export type ClaimRewardsTokenTableRowProps = Component<{ reward: Reward["rewards"][number]; checkedState?: GetSet; -}; + showCheckbox?: boolean; +}>; -export default function ClaimRewardsTokenTableRow({ reward, checkedState, ...props }: ClaimRewardsTokenTableRowProps) { +export default function ClaimRewardsTokenTableRow({ + reward, + checkedState, + showCheckbox, + ...props +}: ClaimRewardsTokenTableRowProps) { const [open, setOpen] = useState(false); const unclaimed = useMemo(() => BigInt(reward.amount) - BigInt(reward.claimed), [reward]); return ( setOpen(o => !o)} tokenColumn={ - + + {showCheckbox && } - } - claimColumn={ - - - }> {reward.breakdowns - .sort((a, b) => Number(b.amount - b.claimed - (a.amount - a.claimed))) + .sort( + (a, b) => Fmt.toPrice(b.amount - b.claimed, reward.token) - Fmt.toPrice(a.amount - a.claimed, reward.token), + ) + .filter(b => b.opportunity !== null) .map(b => { return ( <> @@ -66,7 +71,7 @@ export default function ClaimRewardsTokenTableRow({ reward, checkedState, ...pro {...props} key={b.opportunity.identifier} data-look={props?.look ?? "none"} - className="!px-0 !m-0 border-none" + className="!px-0 py-xl !m-0 border-none bg-main-0" onClick={() => setOpen(o => !o)} tokenColumn={ @@ -94,7 +99,6 @@ export default function ClaimRewardsTokenTableRow({ reward, checkedState, ...pro decimals={reward.token.decimals} /> } - claimColumn={null} /> ); diff --git a/src/components/element/token/Token.tsx b/src/components/element/token/Token.tsx index 7bcfc26e..7c996585 100644 --- a/src/components/element/token/Token.tsx +++ b/src/components/element/token/Token.tsx @@ -1,29 +1,75 @@ -import type { Token as TokenType } from "@merkl/api"; -import { Button, Dropdown, Icon, Value } from "packages/dappkit/src"; +import type { Chain, Token as TokenType } from "@merkl/api"; +import { Button, Dropdown, Group, Icon, type IconProps, PrimitiveTag, Value, sizeScale } from "packages/dappkit/src"; import { useMemo } from "react"; +import { formatUnits } from "viem"; import TokenTooltip from "./TokenTooltip"; export type TokenProps = { token: TokenType; + format?: "amount" | "price" | "amount_price"; + amount?: bigint; value?: boolean; - amount?: number; + symbol?: boolean; + icon?: boolean; + size?: IconProps["size"]; + chain?: Chain; }; -export default function Token({ token, amount, value }: TokenProps) { +export default function Token({ + size, + token, + amount, + format = "amount", + value, + icon = true, + symbol = true, + chain, +}: TokenProps) { + const amoutFormated = amount ? formatUnits(amount, token.decimals) : undefined; + const amountUSD = !amount ? 0 : (token.price ?? 0) * Number.parseFloat(amoutFormated ?? "0"); + const display = useMemo( () => ( <> - {amount && {amount}} - {token.symbol} + {format === "amount" || + (format === "amount_price" && !!amount && ( + (v as string).includes("0.000") && "< 0.001"} + className="text-right items-center flex font-title" + look={"bold"} + size={size} + format="0,0.###a"> + {amoutFormated} + + ))}{" "} + {icon && } + {symbol && token.symbol} + {format === "price" || + (format === "amount_price" && !!amount && ( + + + + {amountUSD} + + + + ))} ), - [token, amount], + [token, format, amoutFormated, amountUSD, amount, symbol, icon, size], ); if (value) return display; + return ( - }> - + }> + ); } diff --git a/src/components/element/token/TokenAmountModal.tsx b/src/components/element/token/TokenAmountModal.tsx new file mode 100644 index 00000000..7cba27f2 --- /dev/null +++ b/src/components/element/token/TokenAmountModal.tsx @@ -0,0 +1,20 @@ +import type { Token as TokenType } from "@merkl/api"; +import { Divider, Group } from "packages/dappkit/src"; +import type { ReactNode } from "react"; +import Token from "./Token"; + +export type TokenAmountModalProps = { tokens: { token: TokenType; amount: bigint }[]; label: ReactNode }; + +export default function TokenAmountModal({ label, tokens }: TokenAmountModalProps) { + return ( + + + {label} + + + {tokens.map(({ token, amount }) => ( + + ))} + + ); +} diff --git a/src/components/element/token/TokenTable.tsx b/src/components/element/token/TokenTable.tsx index c79a5dc2..421c32ec 100644 --- a/src/components/element/token/TokenTable.tsx +++ b/src/components/element/token/TokenTable.tsx @@ -2,14 +2,14 @@ import { createTable } from "dappkit"; export const [TokenTable, TokenRow, tokenColumns] = createTable({ token: { - name: "TOKEN", + name: "Token", size: "minmax(350px,1fr)", compact: "1fr", className: "justify-start", main: true, }, price: { - name: "PRICE", + name: "Price", size: "minmax(min-content,150px)", compactSize: "minmax(min-content,1fr)", className: "justify-end", diff --git a/src/components/element/token/TokenTooltip.tsx b/src/components/element/token/TokenTooltip.tsx index b688b087..103401e4 100644 --- a/src/components/element/token/TokenTooltip.tsx +++ b/src/components/element/token/TokenTooltip.tsx @@ -1,38 +1,50 @@ -import type { Token } from "@merkl/api"; -import { Button, Divider, Group, Hash, Icon, Text, Title } from "packages/dappkit/src"; +import type { Chain, Explorer, Token } from "@merkl/api"; +import { Button, Divider, Group, Hash, Icon, Text } from "packages/dappkit/src"; export type TokenTooltipProps = { token: Token; - amount?: number; + size?: "sm" | "md" | "lg" | "xl" | "xs"; + chain?: Chain & { explorers: Explorer[] }; }; -export default function TokenTooltip({ token }: TokenTooltipProps) { +export default function TokenTooltip({ token, size, chain }: TokenTooltipProps) { return ( <> - - - Token - - {token.address} - + + + + + + {token?.name} + + + + + {token.address} + + - - - - {token?.name} - + + + + {chain?.explorers?.map(explorer => { + return ( + + ); + })} - - {/* {token?.description} */} - - - - ); } diff --git a/src/components/element/tvl/TvlLibrary.tsx b/src/components/element/tvl/TvlLibrary.tsx new file mode 100644 index 00000000..989a1e05 --- /dev/null +++ b/src/components/element/tvl/TvlLibrary.tsx @@ -0,0 +1,50 @@ +import type { Opportunity } from "@merkl/api"; +import { Button, Icon } from "packages/dappkit/src"; +import { useMemo, useState } from "react"; +import { TvlTable } from "./TvlTable"; +import TvlTableRow from "./TvlTableRow"; + +type IProps = { + opportunity: Opportunity; +}; + +const DEFAULT_ARRAY_SIZE = 3; + +export default function TvlLibrary({ opportunity }: IProps) { + const [isShowingMore, setIsShowingMore] = useState(false); + const aprFiltered = useMemo(() => { + return opportunity.aprRecord.breakdowns.filter(breakdown => breakdown.type === "PROTOCOL"); + }, [opportunity]); + + const tvlFiltered = useMemo(() => { + return opportunity.tvlRecord.breakdowns + .filter(breakdown => breakdown.type === "PROTOCOL") + .sort((a, b) => b.value - a.value) + .slice(0, isShowingMore ? opportunity.tvlRecord.breakdowns.length : DEFAULT_ARRAY_SIZE); + }, [opportunity, isShowingMore]); + + const rows = useMemo( + () => + tvlFiltered?.map(breakdown => { + const aprBreakdown = aprFiltered.find(b => b.identifier === breakdown.identifier); + + return ; + }), + [aprFiltered, tvlFiltered], + ); + + const toggleShowMore = () => setIsShowingMore(prev => !prev); + + if (!rows.length) return null; + return ( + + {rows} + {rows.length >= DEFAULT_ARRAY_SIZE && ( + + )} + + ); +} diff --git a/src/components/element/tvl/TvlRowAllocation.tsx b/src/components/element/tvl/TvlRowAllocation.tsx new file mode 100644 index 00000000..2e018d53 --- /dev/null +++ b/src/components/element/tvl/TvlRowAllocation.tsx @@ -0,0 +1,80 @@ +import type { Opportunity } from "@merkl/api"; +import { Divider, Group, Icon, Text, Value } from "packages/dappkit/src"; + +type IProps = { + opportunity: Opportunity; +}; + +export default function TvlRowAllocation({ opportunity }: IProps) { + let content: React.ReactNode = null; + switch (opportunity.type) { + case "CLAMM": { + const tokenTvl = opportunity.tvlRecord.breakdowns.filter(b => b.type === "TOKEN"); + const token0 = opportunity.tokens[0]; + const token1 = opportunity.tokens[1]; + const tvlBreakdownToken0 = tokenTvl?.find(b => b.identifier === opportunity?.tokens[0]?.address); + const tvlBreakdownToken1 = tokenTvl?.find(b => b.identifier === opportunity?.tokens[1]?.address); + + content = ( + + + + + {tvlBreakdownToken0?.value} + + + {token0.name} + + + {!!tvlBreakdownToken0?.value && !!token0?.price && ( + + + {tvlBreakdownToken0.value * token0.price} + + {" ~ "} + + {(tvlBreakdownToken0?.value * token0.price) / opportunity.tvlRecord.total} + + {" of TVL"} + + )} + + + + + {tvlBreakdownToken1?.value} + + + {token1.name} + + + {!!tvlBreakdownToken1?.value && !!token1?.price && ( + + + {tvlBreakdownToken1.value * token1.price} + + {" ~ "} + + {(tvlBreakdownToken1?.value * token1.price) / opportunity.tvlRecord.total} + + {" of TVL"} + + )} + + + ); + break; + } + default: + content = null; + } + if (!content) return null; + return ( + <> + + TVL allocation + + {content} + + ); +} diff --git a/src/components/element/tvl/TvlSection.tsx b/src/components/element/tvl/TvlSection.tsx new file mode 100644 index 00000000..12ef148e --- /dev/null +++ b/src/components/element/tvl/TvlSection.tsx @@ -0,0 +1,114 @@ +import type { Opportunity } from "@merkl/api"; +import { Button, Divider, Group, Hash, Icon, PrimitiveTag, Text, Value } from "packages/dappkit/src"; +import { Fragment, useMemo, useState } from "react"; + +interface TvlSectionProps { + opportunity: Opportunity; +} + +const DEFAULT_ARRAY_SIZE = 3; + +export default function TvlSection({ opportunity }: TvlSectionProps) { + const [isShowingMore, setIsShowingMore] = useState(false); + + const tvlFiltered = useMemo(() => { + return opportunity.tvlRecord.breakdowns + .filter(breakdown => breakdown.type === "PROTOCOL") + .sort((a, b) => b.value - a.value) + .slice(0, isShowingMore ? opportunity.tvlRecord.breakdowns.length : 3); + }, [opportunity, isShowingMore]); + + const aprFiltered = useMemo(() => { + return opportunity.aprRecord.breakdowns.filter(breakdown => breakdown.type === "PROTOCOL"); + }, [opportunity]); + + const getTvlName = (breakdown: Opportunity["tvlRecord"]["breakdowns"][number]) => { + if (!breakdown?.identifier) return null; + + switch (breakdown?.type) { + case "PROTOCOL": + return ( + + + {breakdown.identifier.split(" ")[0]} + + + {breakdown.identifier.split(" ")[1]} + + + ); + default: + return ( + + {breakdown.identifier} + + ); + } + }; + + const hasForwarders = tvlFiltered.length > 0; + + return ( + <> + {hasForwarders && ( + <> + + + Forwarder details + + APR + + + TVL + + + + + )} + + {tvlFiltered.map(breakdown => { + const aprBreakdown = aprFiltered.find(b => b.identifier === breakdown.identifier); + return ( + + + + {getTvlName(breakdown)} + + + {aprBreakdown && ( + + + {aprBreakdown.value / 100} + + + )} + + + {breakdown.value} + + + + + + ); + })} + + + {tvlFiltered.length >= DEFAULT_ARRAY_SIZE && ( + + )} + + ); +} diff --git a/src/components/element/tvl/TvlTable.tsx b/src/components/element/tvl/TvlTable.tsx new file mode 100644 index 00000000..b9867407 --- /dev/null +++ b/src/components/element/tvl/TvlTable.tsx @@ -0,0 +1,23 @@ +import { createTable } from "dappkit"; + +export const [TvlTable, TvlRow, tvlColumns] = createTable({ + name: { + name: "TVL DETAILS", + size: "minmax(350px,1fr)", + compact: "1fr", + className: "justify-start", + main: true, + }, + apr: { + name: "APR", + size: "minmax(min-content,150px)", + compactSize: "minmax(min-content,1fr)", + className: "justify-center", + }, + tvl: { + name: "TVL", + size: "minmax(min-content,150px)", + compactSize: "minmax(min-content,1fr)", + className: "justify-center", + }, +}); diff --git a/src/components/element/tvl/TvlTableRow.tsx b/src/components/element/tvl/TvlTableRow.tsx new file mode 100644 index 00000000..f59a2c5c --- /dev/null +++ b/src/components/element/tvl/TvlTableRow.tsx @@ -0,0 +1,61 @@ +import type { Opportunity } from "@merkl/api"; +import { Button, Group, Hash, Value } from "packages/dappkit/src"; +import { useMemo } from "react"; +import { TvlRow } from "./TvlTable"; + +type IProps = { + aprBreakdown?: Opportunity["aprRecord"]["breakdowns"][number]; + tvlBreakdown: Opportunity["tvlRecord"]["breakdowns"][number]; +}; + +export default function TvlTableRow({ aprBreakdown, tvlBreakdown }: IProps) { + const breakdownName = useMemo(() => { + switch (aprBreakdown?.type) { + case "CAMPAIGN": + return ( + + Campaign{" "} + + {aprBreakdown?.identifier} + + + ); + case "PROTOCOL": + return ( + + {aprBreakdown?.identifier.split(" ")[0]} + + {aprBreakdown?.identifier.split(" ")[1]} + + + ); + case "TOKEN": + return aprBreakdown?.identifier; + default: + return ( + + {aprBreakdown?.identifier} + + ); + } + }, [aprBreakdown]); + + return ( + + + {(aprBreakdown?.value ?? 0) / 100} + + + } + tvlColumn={ + + {tvlBreakdown?.value} + + } + /> + ); +} diff --git a/src/components/element/user/User.tsx b/src/components/element/user/User.tsx new file mode 100644 index 00000000..410a81b3 --- /dev/null +++ b/src/components/element/user/User.tsx @@ -0,0 +1,55 @@ +import type { Chain } from "@merkl/api"; +import { Button, Divider, Dropdown, Group, Hash, Icon, PrimitiveTag } from "packages/dappkit/src"; +import { useWalletContext } from "packages/dappkit/src/context/Wallet.context"; +import { useMemo } from "react"; + +export type UserProps = { address: string; chain?: Chain }; + +export default function User({ address, chain: raw }: UserProps) { + const { chains } = useWalletContext(); + + const chain = useMemo(() => { + return chains?.find(c => c.id === raw?.id) ?? raw; + }, [raw, chains]); + + return ( + + + + + {address} + + + + {/* {token?.description} */} + + + {chain?.explorers?.map(explorer => { + return ( + + ); + })} + + + }> + + {address} + + + ); +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index c6d18a47..f1de1c29 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -31,16 +31,23 @@ export default function Footer() { ©{new Date().getFullYear()} Merkl. All rights reserved. - - + {config.footerLinks.length > 0 && + config.footerLinks.map(link => ( + + ))} + diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index e8f854ad..773e26b8 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -31,16 +31,20 @@ const item = { export default function Header() { const { mode } = useTheme(); - const { address: user } = useWalletContext(); + const { chainId, address: user, chains } = useWalletContext(); const [open, setOpen] = useState(false); + const chain = useMemo(() => { + return chains?.find(c => c.id === chainId); + }, [chains, chainId]); + const routes = useMemo(() => { const { homepage, ...rest } = config.routes; return Object.assign( { homepage }, { - dashboard: { + claims: { icon: "RiPlanetFill", route: user ? `/users/${user}` : "/users", key: crypto.randomUUID(), @@ -86,7 +90,7 @@ export default function Header() { {Object.entries(routes) - .filter(([key]) => !["homepage", "privacy", "terms"].includes(key)) + .filter(([key]) => !["homepage"].includes(key)) .map(([key, { route }]) => { return ( + {chain?.explorers?.map(explorer => ( + + ))} + diff --git a/src/components/layout/LayerMenu.tsx b/src/components/layout/LayerMenu.tsx index 9d2581ac..af905ed9 100644 --- a/src/components/layout/LayerMenu.tsx +++ b/src/components/layout/LayerMenu.tsx @@ -14,21 +14,16 @@ export const LayerMenu: FC<{
    - {Object.entries(nav) - .filter(([key]) => !["privacy", "terms"].includes(key)) - .map(([key, value]) => ( -
  • - setOpen(false)} - to={value.route} - className="flex items-center gap-md capitalize"> - - - {key} - - -
  • - ))} + {Object.entries(nav).map(([key, value]) => ( +
  • + setOpen(false)} to={value.route} className="flex items-center gap-md capitalize"> + + + {key} + + +
  • + ))}