diff --git a/bun.lockb b/bun.lockb index 796bc6ad..5be51465 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 43905758..092347ce 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ ], "dependencies": { "@acab/ecsstatic": "^0.8.0", - "@merkl/api": "0.10.107", + "@merkl/api": "0.10.114", "@ariakit/react": "^0.4.12", "@elysiajs/eden": "^1.1.3", "@emotion/css": "^11.13.4", diff --git a/src/api/services/campaigns/campaign.service.ts b/src/api/services/campaigns/campaign.service.ts index bd563d02..7ebaf94c 100644 --- a/src/api/services/campaigns/campaign.service.ts +++ b/src/api/services/campaigns/campaign.service.ts @@ -1,7 +1,20 @@ 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... @@ -43,8 +56,7 @@ export abstract class CampaignService { } static async getByParams(query: Parameters[0]["query"]) { - const { data } = await api.v4.campaigns.index.get({ query }); - return data; + return await CampaignService.#fetch(async () => api.v4.campaigns.index.get({ query })); } // ------ Fetch a campaign by ID diff --git a/src/api/services/reward.service.ts b/src/api/services/reward.service.ts index d671bacc..3f2c5c52 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,34 @@ 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 })); + + return { count, rewards }; } 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/components/composite/Hero.tsx b/src/components/composite/Hero.tsx index ba2d45c1..ef5c5e1d 100644 --- a/src/components/composite/Hero.tsx +++ b/src/components/composite/Hero.tsx @@ -3,7 +3,6 @@ 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 type { Opportunity } from "src/api/services/opportunity/opportunity.model"; export type HeroProps = PropsWithChildren<{ icons?: IconProps[]; @@ -14,7 +13,6 @@ export type HeroProps = PropsWithChildren<{ tags?: ReactNode[]; sideDatas?: { data: ReactNode; label: string; key: string }[]; tabs?: { label: ReactNode; link: string; key: string }[]; - opportunity?: Opportunity; }>; export default function Hero({ diff --git a/src/components/element/campaign/CampaignTable.tsx b/src/components/element/campaign/CampaignTable.tsx index a268cf8d..84050bed 100644 --- a/src/components/element/campaign/CampaignTable.tsx +++ b/src/components/element/campaign/CampaignTable.tsx @@ -9,19 +9,19 @@ export const [CampaignTable, CampaignRow, CampaignColumns] = createTable({ main: true, }, restrictions: { - name: "Conditions", + name: "", size: "minmax(170px,1fr)", compactSize: "1fr", className: "justify-start", }, chain: { - name: "chain", + name: "Chain", size: "minmax(30px,150px)", compactSize: "minmax(20px,1fr)", className: "justify-start", }, timeRemaining: { - name: "Time Left", + name: "End", size: "minmax(30px,150px)", compactSize: "minmax(20px,1fr)", className: "justify-center", diff --git a/src/components/element/campaign/CampaignTableRow.tsx b/src/components/element/campaign/CampaignTableRow.tsx index 3c047776..9620404d 100644 --- a/src/components/element/campaign/CampaignTableRow.tsx +++ b/src/components/element/campaign/CampaignTableRow.tsx @@ -1,13 +1,23 @@ -import { type Component, Group, Hash, Icon, OverrideTheme, Text, Value, mergeClass } from "dappkit"; +import type { Campaign } from "@merkl/api"; +import { + type Component, + Divider, + Dropdown, + Group, + Hash, + Icon, + OverrideTheme, + PrimitiveTag, + Text, + mergeClass, +} from "dappkit"; import moment from "moment"; -import Tooltip from "packages/dappkit/src/components/primitives/Tooltip"; -import { useCallback, useMemo, useState } from "react"; -import type { Campaign } from "src/api/services/campaigns/campaign.model"; +import { useCallback, useState } from "react"; import useCampaign from "src/hooks/resources/useCampaign"; -import { formatUnits, parseUnits } from "viem"; import Chain from "../chain/Chain"; import Token from "../token/Token"; import { CampaignRow } from "./CampaignTable"; +import CampaignTooltipDates from "./CampaignTooltipDates"; import RestrictionsCollumn from "./tableCollumns/RestrictionsCollumn"; export type CampaignTableRowProps = Component<{ @@ -16,55 +26,51 @@ export type CampaignTableRowProps = Component<{ }>; export default function CampaignTableRow({ campaign, startsOpen, className, ...props }: CampaignTableRowProps) { - const { time, profile, dailyRewards, active } = useCampaign(campaign); + const { time, profile, dailyRewards, active, amount } = useCampaign(campaign); const [isOpen, setIsOpen] = useState(startsOpen); const toggleIsOpen = useCallback(() => setIsOpen(o => !o), []); - const campaignAmount = useMemo( - () => formatUnits(parseUnits(campaign.amount, 0), campaign.rewardToken.decimals), - [campaign], - ); - return ( } restrictionsColumn={} dailyRewardsColumn={ - + - + } timeRemainingColumn={ - - {time} - + }> + {time} + } arrowColumn={}> {isOpen && (
- + Campaign information
Total - - {campaignAmount} - +
Dates - - {moment.unix(Number(campaign.startTimestamp)).format("DD MMMM YYYY")}- - {moment.unix(Number(campaign.endTimestamp)).format("DD MMMM YYYY")} - + }> + + {moment.unix(Number(campaign.startTimestamp)).format("DD MMMM YYYY")} + + {moment.unix(Number(campaign.endTimestamp)).format("DD MMMM YYYY")} + +
{/*
@@ -73,49 +79,44 @@ export default function CampaignTableRow({ campaign, startsOpen, className, ...p
*/}
Campaign creator - + {campaign.creatorAddress}
Campaign id - + {campaign.campaignId}
+ - Conditions Incentivized Liquidity {profile} - - Blacklisted for - - {campaign.params.blacklist.length > 0 - ? campaign.params.blacklist.map((blacklist: string) => blacklist) - : "No address"} -
- }> - {campaign.params.blacklist.length} address - - - - Whitelisted for - - {campaign.params.whitelist.length > 0 - ? campaign.params.whitelist.map((blacklist: string) => blacklist) - : "No address"} - - }> - {campaign.params.whitelist.length} address - - + + {/* Todo: Need to be refacto @Clement */} + + {campaign.params.blacklist.length > 0 && ( + {address})}> + + Blacklist ({campaign.params.blacklist.length} address) + + + )} + + {campaign.params.whitelist.length > 0 && ( + {address})}> + + Whitelist ({campaign.params.whitelist.length} address) + + + )} + diff --git a/src/components/element/campaign/CampaignTooltipDates.tsx b/src/components/element/campaign/CampaignTooltipDates.tsx new file mode 100644 index 00000000..dda85695 --- /dev/null +++ b/src/components/element/campaign/CampaignTooltipDates.tsx @@ -0,0 +1,35 @@ +import type { Campaign } from "@merkl/api"; +import moment from "moment"; +import { Divider, Group, Icon, Text } from "packages/dappkit/src"; + +export type IProps = { + campaign: Campaign; +}; + +export default function CampaignTooltipDates({ campaign }: IProps) { + return ( + <> + + + Campaign dates + + + + + Start + + + {moment.unix(Number(campaign.startTimestamp)).format("DD MMMM YYYY ha (UTC Z)").replace("+", "+ ")} + + + + + End + + {moment.unix(Number(campaign.endTimestamp)).format("DD MMMM YYYY ha (UTC Z)")} + + + + + ); +} diff --git a/src/components/element/campaign/CampaignTooltipToken.tsx b/src/components/element/campaign/CampaignTooltipToken.tsx new file mode 100644 index 00000000..3a2d66b6 --- /dev/null +++ b/src/components/element/campaign/CampaignTooltipToken.tsx @@ -0,0 +1,40 @@ +import type { Campaign } from "@merkl/api"; +import { Button, Divider, Group, Icon, Text, Value } from "packages/dappkit/src"; +import useCampaign from "src/hooks/resources/useCampaign"; + +export type IProps = { + campaign: Campaign; +}; + +export default function CampaignTooltipToken({ campaign }: IProps) { + const { amount, amountUsd } = useCampaign(campaign); + + return ( + <> + + + + Total rewards + + + + {amount} + + + {amountUsd} + + + + + + + + + + + ); +} diff --git a/src/components/element/campaign/tableCollumns/RestrictionsCollumn.tsx b/src/components/element/campaign/tableCollumns/RestrictionsCollumn.tsx index d939198c..2b965022 100644 --- a/src/components/element/campaign/tableCollumns/RestrictionsCollumn.tsx +++ b/src/components/element/campaign/tableCollumns/RestrictionsCollumn.tsx @@ -1,4 +1,4 @@ -import type { Campaign } from "@angleprotocol/merkl-api"; +import type { Campaign } from "@merkl/api"; import { Button, Dropdown } from "packages/dappkit/src"; type IProps = { diff --git a/src/components/element/leaderboard/LeaderboardLibrary.tsx b/src/components/element/leaderboard/LeaderboardLibrary.tsx index 17eaea50..637b3412 100644 --- a/src/components/element/leaderboard/LeaderboardLibrary.tsx +++ b/src/components/element/leaderboard/LeaderboardLibrary.tsx @@ -1,3 +1,5 @@ +import type { Campaign } from "@merkl/api"; +import { useSearchParams } from "@remix-run/react"; import { Text } from "dappkit"; import { useMemo } from "react"; import type { IRewards } from "src/api/services/reward.service"; @@ -8,14 +10,26 @@ import LeaderboardTableRow from "./LeaderboardTableRow"; export type IProps = { leaderboard: IRewards[]; count?: number; + campaign: Campaign; }; export default function LeaderboardLibrary(props: IProps) { - const { leaderboard, count } = props; + const { leaderboard, count, campaign } = props; + const [searchParams] = useSearchParams(); + + const items = searchParams.get("items"); + const page = searchParams.get("page"); const rows = useMemo(() => { - return leaderboard?.map((row, index) => ); - }, [leaderboard]); + return leaderboard?.map((row, index) => ( + + )); + }, [leaderboard, page, items, campaign]); return ( ; export default function LeaderboardTableRow({ row, rank, className, ...props }: CampaignTableRowProps) { - const rewardAmount = useMemo(() => formatUnits(parseUnits(row?.amount, 0), row?.Token?.decimals), [row]); + const { campaign } = props; return ( #{rank}} - addressColumn={{row?.recipient}} - rewardsColumn={ - - {rewardAmount} - + addressColumn={ + + {row?.recipient} + } - protocolColumn={{row?.reason.split("_")[0]}} + rewardsColumn={} + protocolColumn={{row?.reason?.split("_")[0]}} /> ); } diff --git a/src/components/element/opportunity/OpportunityPagination.tsx b/src/components/element/opportunity/OpportunityPagination.tsx index dfd6f471..078805b5 100644 --- a/src/components/element/opportunity/OpportunityPagination.tsx +++ b/src/components/element/opportunity/OpportunityPagination.tsx @@ -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) 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/token/Token.tsx b/src/components/element/token/Token.tsx index 7bcfc26e..1635636b 100644 --- a/src/components/element/token/Token.tsx +++ b/src/components/element/token/Token.tsx @@ -1,26 +1,43 @@ import type { Token as TokenType } from "@merkl/api"; import { Button, Dropdown, Icon, Value } from "packages/dappkit/src"; import { useMemo } from "react"; +import { formatUnits, parseUnits } from "viem"; import TokenTooltip from "./TokenTooltip"; export type TokenProps = { token: TokenType; + format?: "amount" | "price" | "amount_price"; + amount?: bigint; value?: boolean; - amount?: number; }; -export default function Token({ token, amount, value }: TokenProps) { +export default function Token({ token, amount, format = "amount", value }: TokenProps) { + const amoutFormated = amount ? formatUnits(amount, token.decimals) : undefined; + + const price = parseUnits(token.price?.toString() ?? "0", 0); + + const amountUSD = price * (amount ?? 0n); + const display = useMemo( () => ( <> - {amount && {amount}} + {format === "amount" || + (format === "amount_price" && !!amount && {amoutFormated})}{" "} + {token.symbol} + {format === "price" || + (format === "amount_price" && !!amount && ( + + {formatUnits(amountUSD, token.decimals)} + + ))} ), - [token, amount], + [token, format, amoutFormated, amountUSD, amount], ); if (value) return display; + return ( }> diff --git a/src/components/element/token/TokenTooltip.tsx b/src/components/element/token/TokenTooltip.tsx index b688b087..d2ad1998 100644 --- a/src/components/element/token/TokenTooltip.tsx +++ b/src/components/element/token/TokenTooltip.tsx @@ -3,7 +3,6 @@ import { Button, Divider, Group, Hash, Icon, Text, Title } from "packages/dappki export type TokenTooltipProps = { token: Token; - amount?: number; }; export default function TokenTooltip({ token }: TokenTooltipProps) { diff --git a/src/hooks/resources/useCampaign.tsx b/src/hooks/resources/useCampaign.tsx index 97e30241..be92b12f 100644 --- a/src/hooks/resources/useCampaign.tsx +++ b/src/hooks/resources/useCampaign.tsx @@ -1,23 +1,55 @@ +import type { Campaign, Opportunity } from "@merkl/api"; import { Bar } from "dappkit"; import moment from "moment"; import { Group, Text, Value } from "packages/dappkit/src"; import Time from "packages/dappkit/src/components/primitives/Time"; import { type ReactNode, useMemo } from "react"; -import type { Campaign } from "src/api/services/campaigns/campaign.model"; -import type { Opportunity } from "src/api/services/opportunity/opportunity.model"; -import { formatUnits } from "viem"; +import { formatUnits, parseUnits } from "viem"; + +export default function useCampaign(campaign?: Campaign) { + if (!campaign) + return { + amount: undefined, + time: undefined, + profile: undefined, + dailyRewards: undefined, + progressBar: undefined, + active: undefined, + }; + + // ─── Campaign Amount Prices ────────────────────────────────── -export default function useCampaign(campaign: Campaign) { const amount = useMemo(() => { - return Number.parseFloat(formatUnits(BigInt(campaign.amount), campaign.rewardToken.decimals)); - }, [campaign?.amount, campaign?.rewardToken?.decimals]); + return parseUnits(campaign.amount, 0); + }, [campaign?.amount]); + + const dailyRewards = useMemo(() => { + const duration = campaign.endTimestamp - campaign.startTimestamp; + const oneDayInSeconds = BigInt(3600 * 24); + const dayspan = BigInt(duration) / BigInt(oneDayInSeconds) || BigInt(1); + const amountInUnits = parseUnits(amount.toString(), 0); + const dailyReward = amountInUnits / dayspan; + + return dailyReward; + }, [campaign, amount]); + + const dailyRewardsUsd = useMemo(() => { + return formatUnits( + parseUnits(dailyRewards.toString(), 0) * parseUnits(campaign.rewardToken.price?.toString() ?? "0", 18), + 18, + ); + }, [campaign, dailyRewards]); + + // ─── Campaign Amount Time displaying ────────────────────────────────── const time = useMemo(() => { return