diff --git a/.changeset/perfect-shrimps-rush.md b/.changeset/perfect-shrimps-rush.md new file mode 100644 index 00000000..821352d0 --- /dev/null +++ b/.changeset/perfect-shrimps-rush.md @@ -0,0 +1,5 @@ +--- +"stackspulse": minor +--- + +Create new token page. diff --git a/apps/server/src/api/tokens/holders.get.ts b/apps/server/src/api/tokens/holders.get.ts index cf71e5e4..9b5a8473 100644 --- a/apps/server/src/api/tokens/holders.get.ts +++ b/apps/server/src/api/tokens/holders.get.ts @@ -5,8 +5,8 @@ import { stacksClient } from "~/lib/stacks"; const tokensHoldersRouteSchema = z.object({ token: z.string(), - limit: z.number().min(1).max(100).optional(), - offset: z.number().min(0).optional(), + limit: z.coerce.number().min(1).max(100).optional(), + offset: z.coerce.number().min(0).optional(), }); type TokensHoldersRouteResponse = { diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 503dede5..16d11ec0 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -3,6 +3,14 @@ import { withSentryConfig } from "@sentry/nextjs"; /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "**.hiro.so", + }, + ], + }, }; export default withSentryConfig(nextConfig, { diff --git a/apps/web/package.json b/apps/web/package.json index af8031e9..c4f50d2a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,7 @@ "@stackspulse/protocols": "workspace:*", "@t3-oss/env-core": "0.11.1", "@t3-oss/env-nextjs": "0.11.1", - "@tabler/icons-react": "3.18.0", + "@tabler/icons-react": "3.19.0", "@tanstack/react-query": "5.56.2", "class-variance-authority": "0.7.0", "clsx": "2.1.1", diff --git a/apps/web/src/app/tokens/[token]/page.tsx b/apps/web/src/app/tokens/[token]/page.tsx new file mode 100644 index 00000000..437544ce --- /dev/null +++ b/apps/web/src/app/tokens/[token]/page.tsx @@ -0,0 +1,47 @@ +import { TokenHoldersTable } from "@/components/Token/TokenHoldersTable"; +import { TokenInfo } from "@/components/Token/TokenInfo"; +import { TokenStats } from "@/components/Token/TokenStats"; +import { TokenTransactionsVolume } from "@/components/Token/TokenTransactionsVolume"; +import { stacksTokensApi } from "@/lib/stacks"; +import { Container } from "@radix-ui/themes"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; + +export const dynamic = "force-dynamic"; + +interface PageProps { + params: { token: string }; +} + +export default async function ProtocolPage({ params }: PageProps) { + const token = decodeURIComponent(params.token); + const tokenInfo = await stacksTokensApi + .getFtMetadata(token.split("::")[0]) + .catch((error) => { + if (error.status === 404) { + return null; + } + throw error; + }); + if (!tokenInfo) { + notFound(); + } + + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/Token/TokenHoldersTable.tsx b/apps/web/src/components/Token/TokenHoldersTable.tsx new file mode 100644 index 00000000..1046b38d --- /dev/null +++ b/apps/web/src/components/Token/TokenHoldersTable.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useGetTokenHolders } from "@/hooks/api/useGetTokenHolders"; +import type { FtMetadataResponse } from "@hirosystems/token-metadata-api-client"; +import { Link, Table, Text } from "@radix-ui/themes"; + +interface TokenHoldersTableProps { + token: string; + tokenInfo: FtMetadataResponse; +} + +export const TokenHoldersTable = ({ + token, + tokenInfo, +}: TokenHoldersTableProps) => { + const { data } = useGetTokenHolders({ token, limit: 10 }); + + const calculatePercentage = (balance: string) => { + const holderBalance = Number.parseFloat(balance); + const totalSupply = Number.parseFloat(data.total_supply); + return ((holderBalance / totalSupply) * 100).toFixed(2); + }; + + return ( + + + + Rank + Address + Balance + + % of Supply + + + + + + {data.results.map((holder, index) => ( + + + {index + 1} + + + + {holder.address} + + + + + {( + Number(holder.balance) / + Number(10 ** (tokenInfo.decimals ?? 0)) + ).toLocaleString("en-US")} + + + + {calculatePercentage(holder.balance)}% + + + ))} + + + ); +}; diff --git a/apps/web/src/components/Token/TokenInfo.tsx b/apps/web/src/components/Token/TokenInfo.tsx new file mode 100644 index 00000000..8bc4bc1b --- /dev/null +++ b/apps/web/src/components/Token/TokenInfo.tsx @@ -0,0 +1,33 @@ +import type { FtMetadataResponse } from "@hirosystems/token-metadata-api-client"; +import { Heading, Text } from "@radix-ui/themes"; +import Image from "next/image"; + +interface TokenInfoProps { + tokenInfo: FtMetadataResponse; +} + +export const TokenInfo = ({ tokenInfo }: TokenInfoProps) => { + const tokenImage = tokenInfo.image_thumbnail_uri || tokenInfo.image_uri; + return ( +
+ {tokenImage ? ( + {`${tokenInfo.name} + ) : null} +
+ + {tokenInfo.symbol} - {tokenInfo.name} + + + {tokenInfo.description} + +
+
+ ); +}; diff --git a/apps/web/src/components/Token/TokenStats.tsx b/apps/web/src/components/Token/TokenStats.tsx new file mode 100644 index 00000000..b011646d --- /dev/null +++ b/apps/web/src/components/Token/TokenStats.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useGetTokenHolders } from "@/hooks/api/useGetTokenHolders"; +import type { FtMetadataResponse } from "@hirosystems/token-metadata-api-client"; +import { Card, Text } from "@radix-ui/themes"; + +interface TokenStatsProps { + token: string; + tokenInfo: FtMetadataResponse; +} + +export const TokenStats = ({ token, tokenInfo }: TokenStatsProps) => { + const { data } = useGetTokenHolders({ token, limit: 1 }); + + return ( +
+ + + Supply + + + {Number( + Number(data.total_supply) / Number(10 ** (tokenInfo.decimals ?? 0)), + ).toLocaleString("en-US")} + + + + + Holders + + + {data.total.toLocaleString("en-US")} + + +
+ ); +}; diff --git a/apps/web/src/components/Token/TokenTransactionsVolume.tsx b/apps/web/src/components/Token/TokenTransactionsVolume.tsx new file mode 100644 index 00000000..99b52352 --- /dev/null +++ b/apps/web/src/components/Token/TokenTransactionsVolume.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useGetTransactionVolume } from "@/hooks/api/useGetTransactionVolume"; +import type { FtMetadataResponse } from "@hirosystems/token-metadata-api-client"; +import { Card, Inset, Separator, Text } from "@radix-ui/themes"; +import { useMemo } from "react"; +import { AreaChart } from "../ui/AreaChart"; +import { bigNumberValueFormatter, numberValueFormatter } from "../ui/utils"; + +interface TokenStatsProps { + token: string; + tokenInfo: FtMetadataResponse; +} + +export const TokenTransactionsVolume = ({ + token, + tokenInfo, +}: TokenStatsProps) => { + const { data } = useGetTransactionVolume({ token }); + + const formattedData = useMemo(() => { + return data.map((d) => ({ + date: d.date, + daily_volume: + Number(d.daily_volume) / Number(10 ** (tokenInfo.decimals ?? 0)), + })); + }, [data, tokenInfo.decimals]); + + return ( + + + Transactions volume + + + The total volume of transactions for the token. + + + + + + + ); +}; diff --git a/apps/web/src/components/ui/AreaChart.tsx b/apps/web/src/components/ui/AreaChart.tsx new file mode 100644 index 00000000..f20f1c23 --- /dev/null +++ b/apps/web/src/components/ui/AreaChart.tsx @@ -0,0 +1,986 @@ +// biome-ignore lint: This file is auto-generated and shouldn't be linted + +// Tremor AreaChart [v0.3.1] + +"use client"; + +import { useOnWindowResize } from "@/hooks/useOnWindowResize"; +import { + AvailableChartColors, + type AvailableChartColorsKeys, + constructCategoryColors, + getColorClassName, + getYAxisDomain, + hasOnlyOneValueForKey, +} from "@/lib/chartUtils"; +import { cn as cx } from "@/lib/cn"; +import { Text } from "@radix-ui/themes"; +import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"; +import React from "react"; +import { + Area, + CartesianGrid, + Dot, + Label, + Line, + AreaChart as RechartsAreaChart, + Legend as RechartsLegend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { AxisDomain } from "recharts/types/util/types"; + +//#region Legend + +interface LegendItemProps { + name: string; + color: AvailableChartColorsKeys; + onClick?: (name: string, color: AvailableChartColorsKeys) => void; + activeLegend?: string; +} + +const LegendItem = ({ + name, + color, + onClick, + activeLegend, +}: LegendItemProps) => { + const hasOnValueChange = !!onClick; + return ( +
  • { + e.stopPropagation(); + onClick?.(name, color); + }} + > + +

    + {name} +

    +
  • + ); +}; + +interface ScrollButtonProps { + icon: React.ElementType; + onClick?: () => void; + disabled?: boolean; +} + +const ScrollButton = ({ icon, onClick, disabled }: ScrollButtonProps) => { + const Icon = icon; + const [isPressed, setIsPressed] = React.useState(false); + const intervalRef = React.useRef(null); + + React.useEffect(() => { + if (isPressed) { + intervalRef.current = setInterval(() => { + onClick?.(); + }, 300); + } else { + clearInterval(intervalRef.current as NodeJS.Timeout); + } + return () => clearInterval(intervalRef.current as NodeJS.Timeout); + }, [isPressed, onClick]); + + React.useEffect(() => { + if (disabled) { + clearInterval(intervalRef.current as NodeJS.Timeout); + setIsPressed(false); + } + }, [disabled]); + + return ( + + ); +}; + +interface LegendProps extends React.OlHTMLAttributes { + categories: string[]; + colors?: AvailableChartColorsKeys[]; + onClickLegendItem?: (category: string, color: string) => void; + activeLegend?: string; + enableLegendSlider?: boolean; +} + +type HasScrollProps = { + left: boolean; + right: boolean; +}; + +const Legend = React.forwardRef((props, ref) => { + const { + categories, + colors = AvailableChartColors, + className, + onClickLegendItem, + activeLegend, + enableLegendSlider = false, + ...other + } = props; + const scrollableRef = React.useRef(null); + const scrollButtonsRef = React.useRef(null); + const [hasScroll, setHasScroll] = React.useState(null); + const [isKeyDowned, setIsKeyDowned] = React.useState(null); + const intervalRef = React.useRef(null); + + const checkScroll = React.useCallback(() => { + const scrollable = scrollableRef?.current; + if (!scrollable) return; + + const hasLeftScroll = scrollable.scrollLeft > 0; + const hasRightScroll = + scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft; + + setHasScroll({ left: hasLeftScroll, right: hasRightScroll }); + }, [setHasScroll]); + + const scrollToTest = React.useCallback( + (direction: "left" | "right") => { + const element = scrollableRef?.current; + const scrollButtons = scrollButtonsRef?.current; + const scrollButtonsWith = scrollButtons?.clientWidth ?? 0; + const width = element?.clientWidth ?? 0; + + if (element && enableLegendSlider) { + element.scrollTo({ + left: + direction === "left" + ? element.scrollLeft - width + scrollButtonsWith + : element.scrollLeft + width - scrollButtonsWith, + behavior: "smooth", + }); + setTimeout(() => { + checkScroll(); + }, 400); + } + }, + [enableLegendSlider, checkScroll], + ); + + React.useEffect(() => { + const keyDownHandler = (key: string) => { + if (key === "ArrowLeft") { + scrollToTest("left"); + } else if (key === "ArrowRight") { + scrollToTest("right"); + } + }; + if (isKeyDowned) { + keyDownHandler(isKeyDowned); + intervalRef.current = setInterval(() => { + keyDownHandler(isKeyDowned); + }, 300); + } else { + clearInterval(intervalRef.current as NodeJS.Timeout); + } + return () => clearInterval(intervalRef.current as NodeJS.Timeout); + }, [isKeyDowned, scrollToTest]); + + const keyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + setIsKeyDowned(e.key); + } + }; + const keyUp = (e: KeyboardEvent) => { + e.stopPropagation(); + setIsKeyDowned(null); + }; + + React.useEffect(() => { + const scrollable = scrollableRef?.current; + if (enableLegendSlider) { + checkScroll(); + scrollable?.addEventListener("keydown", keyDown); + scrollable?.addEventListener("keyup", keyUp); + } + + return () => { + scrollable?.removeEventListener("keydown", keyDown); + scrollable?.removeEventListener("keyup", keyUp); + }; + }, [checkScroll, enableLegendSlider]); + + return ( +
      +
      + {categories.map((category, index) => ( + + ))} +
      + {enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? ( + <> +
      + { + setIsKeyDowned(null); + scrollToTest("left"); + }} + disabled={!hasScroll?.left} + /> + { + setIsKeyDowned(null); + scrollToTest("right"); + }} + disabled={!hasScroll?.right} + /> +
      + + ) : null} +
    + ); +}); + +Legend.displayName = "Legend"; + +const ChartLegend = ( + { payload }: any, + categoryColors: Map, + setLegendHeight: React.Dispatch>, + activeLegend: string | undefined, + onClick?: (category: string, color: string) => void, + enableLegendSlider?: boolean, + legendPosition?: "left" | "center" | "right", + yAxisWidth?: number, +) => { + const legendRef = React.useRef(null); + + useOnWindowResize(() => { + const calculateHeight = (height: number | undefined) => + height ? Number(height) + 15 : 60; + setLegendHeight(calculateHeight(legendRef.current?.clientHeight)); + }); + + const legendPayload = payload.filter((item: any) => item.type !== "none"); + + const paddingLeft = + legendPosition === "left" && yAxisWidth ? yAxisWidth - 8 : 0; + + return ( +
    + entry.value)} + colors={legendPayload.map((entry: any) => + categoryColors.get(entry.value), + )} + onClickLegendItem={onClick} + activeLegend={activeLegend} + enableLegendSlider={enableLegendSlider} + /> +
    + ); +}; + +//#region Tooltip + +type TooltipProps = Pick; + +type PayloadItem = { + category: string; + value: number; + index: string; + color: AvailableChartColorsKeys; + type?: string; + payload: any; +}; + +interface ChartTooltipProps { + active: boolean | undefined; + payload: PayloadItem[]; + label: string; + valueFormatter: (value: number) => string; +} + +const ChartTooltip = ({ + active, + payload, + label, + valueFormatter, +}: ChartTooltipProps) => { + if (active && payload && payload.length) { + return ( +
    +
    + {label} +
    +
    + {payload.map(({ value, category, color }, index) => ( +
    +
    +
    +

    + {valueFormatter(value)} +

    +
    + ))} +
    +
    + ); + } + return null; +}; + +//#region AreaChart + +interface ActiveDot { + index?: number; + dataKey?: string; +} + +type BaseEventProps = { + eventType: "dot" | "category"; + categoryClicked: string; + [key: string]: number | string; +}; + +type AreaChartEventProps = BaseEventProps | null | undefined; + +interface AreaChartProps extends React.HTMLAttributes { + data: Record[]; + index: string; + categories: string[]; + colors?: AvailableChartColorsKeys[]; + valueFormatter?: (value: number) => string; + valueFormatterYAxis?: (value: number) => string; + startEndOnly?: boolean; + showXAxis?: boolean; + showYAxis?: boolean; + showGridLines?: boolean; + yAxisWidth?: number; + intervalType?: "preserveStartEnd" | "equidistantPreserveStart"; + showTooltip?: boolean; + showLegend?: boolean; + autoMinValue?: boolean; + minValue?: number; + maxValue?: number; + allowDecimals?: boolean; + onValueChange?: (value: AreaChartEventProps) => void; + enableLegendSlider?: boolean; + tickGap?: number; + connectNulls?: boolean; + xAxisLabel?: string; + yAxisLabel?: string; + type?: "default" | "stacked" | "percent"; + legendPosition?: "left" | "center" | "right"; + fill?: "gradient" | "solid" | "none"; + tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void; + customTooltip?: React.ComponentType; +} + +const AreaChart = React.forwardRef( + (props, ref) => { + const { + data = [], + categories = [], + index, + colors = AvailableChartColors, + valueFormatter = (value: number) => value.toString(), + valueFormatterYAxis = valueFormatter, + startEndOnly = false, + showXAxis = true, + showYAxis = true, + showGridLines = true, + yAxisWidth = 56, + intervalType = "equidistantPreserveStart", + showTooltip = true, + showLegend = true, + autoMinValue = false, + minValue, + maxValue, + allowDecimals = true, + connectNulls = false, + className, + onValueChange, + enableLegendSlider = false, + tickGap = 5, + xAxisLabel, + yAxisLabel, + type = "default", + legendPosition = "right", + fill = "gradient", + tooltipCallback, + customTooltip, + ...other + } = props; + const CustomTooltip = customTooltip; + const paddingValue = + (!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20; + const [legendHeight, setLegendHeight] = React.useState(60); + const [activeDot, setActiveDot] = React.useState( + undefined, + ); + const [activeLegend, setActiveLegend] = React.useState( + undefined, + ); + const categoryColors = constructCategoryColors(categories, colors); + + const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue); + const hasOnValueChange = !!onValueChange; + const stacked = type === "stacked" || type === "percent"; + const areaId = React.useId(); + + const prevActiveRef = React.useRef(undefined); + const prevLabelRef = React.useRef(undefined); + + const getFillContent = ({ + fillType, + activeDot, + activeLegend, + category, + }: { + fillType: AreaChartProps["fill"]; + activeDot: ActiveDot | undefined; + activeLegend: string | undefined; + category: string; + }) => { + const stopOpacity = + activeDot || (activeLegend && activeLegend !== category) ? 0.1 : 0.3; + + switch (fillType) { + case "none": + return ; + case "gradient": + return ( + <> + + + + ); + case "solid": + default: + return ; + } + }; + + function valueToPercent(value: number) { + return `${(value * 100).toFixed(0)}%`; + } + + function onDotClick(itemData: any, event: React.MouseEvent) { + event.stopPropagation(); + + if (!hasOnValueChange) return; + if ( + (itemData.index === activeDot?.index && + itemData.dataKey === activeDot?.dataKey) || + (hasOnlyOneValueForKey(data, itemData.dataKey) && + activeLegend && + activeLegend === itemData.dataKey) + ) { + setActiveLegend(undefined); + setActiveDot(undefined); + onValueChange?.(null); + } else { + setActiveLegend(itemData.dataKey); + setActiveDot({ + index: itemData.index, + dataKey: itemData.dataKey, + }); + onValueChange?.({ + eventType: "dot", + categoryClicked: itemData.dataKey, + ...itemData.payload, + }); + } + } + + function onCategoryClick(dataKey: string) { + if (!hasOnValueChange) return; + if ( + (dataKey === activeLegend && !activeDot) || + (hasOnlyOneValueForKey(data, dataKey) && + activeDot && + activeDot.dataKey === dataKey) + ) { + setActiveLegend(undefined); + onValueChange?.(null); + } else { + setActiveLegend(dataKey); + onValueChange?.({ + eventType: "category", + categoryClicked: dataKey, + }); + } + setActiveDot(undefined); + } + + return ( +
    + + { + setActiveDot(undefined); + setActiveLegend(undefined); + onValueChange?.(null); + } + : undefined + } + margin={{ + bottom: xAxisLabel ? 30 : undefined, + left: yAxisLabel ? 20 : undefined, + right: yAxisLabel ? 5 : undefined, + top: 5, + }} + stackOffset={type === "percent" ? "expand" : undefined} + > + {showGridLines ? ( + + ) : null} + + {xAxisLabel && ( + + )} + + + {yAxisLabel && ( + + )} + + { + const cleanPayload: TooltipProps["payload"] = payload + ? payload.map((item: any) => ({ + category: item.dataKey, + value: item.value, + index: item.payload[index], + color: categoryColors.get( + item.dataKey, + ) as AvailableChartColorsKeys, + type: item.type, + payload: item.payload, + })) + : []; + + if ( + tooltipCallback && + (active !== prevActiveRef.current || + label !== prevLabelRef.current) + ) { + tooltipCallback({ active, payload: cleanPayload, label }); + prevActiveRef.current = active; + prevLabelRef.current = label; + } + + return showTooltip && active ? ( + CustomTooltip ? ( + + ) : ( + + ) + ) : null; + }} + /> + + {showLegend ? ( + + ChartLegend( + { payload }, + categoryColors, + setLegendHeight, + activeLegend, + hasOnValueChange + ? (clickedLegendItem: string) => + onCategoryClick(clickedLegendItem) + : undefined, + enableLegendSlider, + legendPosition, + yAxisWidth, + ) + } + /> + ) : null} + {categories.map((category) => { + const categoryId = `${areaId}-${category.replace( + /[^a-zA-Z0-9]/g, + "", + )}`; + return ( + + + + {getFillContent({ + fillType: fill, + activeDot: activeDot, + activeLegend: activeLegend, + category: category, + })} + + + { + const { + cx: cxCoord, + cy: cyCoord, + stroke, + strokeLinecap, + strokeLinejoin, + strokeWidth, + dataKey, + } = props; + return ( + onDotClick(props, event)} + /> + ); + }} + dot={(props: any) => { + const { + stroke, + strokeLinecap, + strokeLinejoin, + strokeWidth, + cx: cxCoord, + cy: cyCoord, + dataKey, + index, + } = props; + + if ( + (hasOnlyOneValueForKey(data, category) && + !( + activeDot || + (activeLegend && activeLegend !== category) + )) || + (activeDot?.index === index && + activeDot?.dataKey === category) + ) { + return ( + + ); + } + return ; + }} + key={category} + name={category} + type="linear" + dataKey={category} + stroke="" + strokeWidth={2} + strokeLinejoin="round" + strokeLinecap="round" + isAnimationActive={false} + connectNulls={connectNulls} + stackId={stacked ? "stack" : undefined} + fill={`url(#${categoryId})`} + /> + + ); + })} + {/* hidden lines to increase clickable target area */} + {onValueChange + ? categories.map((category) => ( + { + event.stopPropagation(); + const { name } = props; + onCategoryClick(name); + }} + /> + )) + : null} + + +
    + ); + }, +); + +AreaChart.displayName = "AreaChart"; + +export { AreaChart, type AreaChartEventProps, type TooltipProps }; diff --git a/apps/web/src/components/ui/utils.ts b/apps/web/src/components/ui/utils.ts index 3b5df9a2..94351012 100644 --- a/apps/web/src/components/ui/utils.ts +++ b/apps/web/src/components/ui/utils.ts @@ -34,3 +34,19 @@ export const defaultValueFormatter = (value: number | string) => { export const numberValueFormatter = (value: number | string) => { return value.toLocaleString("en-US"); }; + +export const bigNumberValueFormatter = (value: number | string) => { + const num = typeof value === "string" ? Number.parseFloat(value) : value; + if (Number.isNaN(num)) return value.toString(); + + const absNum = Math.abs(num); + if (absNum >= 1e9) { + return `${(num / 1e9).toFixed(1)}B`; + } else if (absNum >= 1e6) { + return `${(num / 1e6).toFixed(1)}M`; + } else if (absNum >= 1e3) { + return `${(num / 1e3).toFixed(1)}K`; + } else { + return num.toString(); + } +}; diff --git a/apps/web/src/hooks/api/useGetTokenHolders.ts b/apps/web/src/hooks/api/useGetTokenHolders.ts new file mode 100644 index 00000000..db7b6293 --- /dev/null +++ b/apps/web/src/hooks/api/useGetTokenHolders.ts @@ -0,0 +1,21 @@ +import { env } from "@/env"; +import type { TokensHoldersRouteResponse } from "@/lib/api"; +import { useSuspenseQuery } from "@tanstack/react-query"; + +export const useGetTokenHolders = (params: { + token: string; + limit?: number; +}) => { + return useSuspenseQuery({ + queryKey: ["get-token-holders", params.token, params.limit], + queryFn: async () => { + const url = new URL(`${env.NEXT_PUBLIC_API_URL}/api/tokens/holders`); + url.searchParams.append("token", params.token); + if (params.limit) { + url.searchParams.append("limit", params.limit.toString()); + } + const res = await fetch(url); + return res.json(); + }, + }); +}; diff --git a/apps/web/src/hooks/api/useGetTransactionVolume.ts b/apps/web/src/hooks/api/useGetTransactionVolume.ts new file mode 100644 index 00000000..ecc6d596 --- /dev/null +++ b/apps/web/src/hooks/api/useGetTransactionVolume.ts @@ -0,0 +1,17 @@ +import { env } from "@/env"; +import type { TokensTransactionVolumeRouteResponse } from "@/lib/api"; +import { useSuspenseQuery } from "@tanstack/react-query"; + +export const useGetTransactionVolume = (params: { token: string }) => { + return useSuspenseQuery({ + queryKey: ["get-transaction-volume", params.token], + queryFn: async () => { + const url = new URL( + `${env.NEXT_PUBLIC_API_URL}/api/tokens/transaction-volume`, + ); + url.searchParams.append("token", params.token); + const res = await fetch(url); + return res.json(); + }, + }); +}; diff --git a/apps/web/src/hooks/useOnWindowResize.ts b/apps/web/src/hooks/useOnWindowResize.ts new file mode 100644 index 00000000..46968704 --- /dev/null +++ b/apps/web/src/hooks/useOnWindowResize.ts @@ -0,0 +1,15 @@ +// Tremor useOnWindowResize [v0.0.0] + +import * as React from "react"; + +export const useOnWindowResize = (handler: () => void) => { + React.useEffect(() => { + const handleResize = () => { + handler(); + }; + handleResize(); + window.addEventListener("resize", handleResize); + + return () => window.removeEventListener("resize", handleResize); + }, [handler]); +}; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f8aeb1a5..00edc641 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -53,3 +53,25 @@ export type StackingDAOProtocolStatsResponse = { deposits: number; withdrawals: number; }[]; + +/** + * `/api/tokens/holders` + */ + +export type TokensHoldersRouteResponse = { + total_supply: string; + total: number; + results: { + address: string; + balance: string; + }[]; +}; + +/** + * `/api/tokens/transaction-volume` + */ + +export type TokensTransactionVolumeRouteResponse = { + date: string; + daily_volume: string; +}[]; diff --git a/apps/web/src/lib/chartUtils.tsx b/apps/web/src/lib/chartUtils.tsx new file mode 100644 index 00000000..e62bf59b --- /dev/null +++ b/apps/web/src/lib/chartUtils.tsx @@ -0,0 +1,133 @@ +// Tremor chartColors [v0.1.0] + +export type ColorUtility = "bg" | "stroke" | "fill" | "text"; + +export const chartColors = { + orange: { + bg: "bg-orange-9", + stroke: "stroke-orange-9", + fill: "fill-orange-9", + text: "text-orange-9", + }, + blue: { + bg: "bg-blue-9", + stroke: "stroke-blue-9", + fill: "fill-blue-9", + text: "text-blue-9", + }, + emerald: { + bg: "bg-emerald-500", + stroke: "stroke-emerald-500", + fill: "fill-emerald-500", + text: "text-emerald-500", + }, + violet: { + bg: "bg-violet-500", + stroke: "stroke-violet-500", + fill: "fill-violet-500", + text: "text-violet-500", + }, + amber: { + bg: "bg-amber-500", + stroke: "stroke-amber-500", + fill: "fill-amber-500", + text: "text-amber-500", + }, + gray: { + bg: "bg-gray-500", + stroke: "stroke-gray-500", + fill: "fill-gray-500", + text: "text-gray-500", + }, + cyan: { + bg: "bg-cyan-500", + stroke: "stroke-cyan-500", + fill: "fill-cyan-500", + text: "text-cyan-500", + }, + pink: { + bg: "bg-pink-500", + stroke: "stroke-pink-500", + fill: "fill-pink-500", + text: "text-pink-500", + }, + lime: { + bg: "bg-lime-500", + stroke: "stroke-lime-500", + fill: "fill-lime-500", + text: "text-lime-500", + }, + fuchsia: { + bg: "bg-fuchsia-500", + stroke: "stroke-fuchsia-500", + fill: "fill-fuchsia-500", + text: "text-fuchsia-500", + }, +} as const satisfies { + [color: string]: { + [key in ColorUtility]: string; + }; +}; + +export type AvailableChartColorsKeys = keyof typeof chartColors; + +export const AvailableChartColors: AvailableChartColorsKeys[] = Object.keys( + chartColors, +) as Array; + +export const constructCategoryColors = ( + categories: string[], + colors: AvailableChartColorsKeys[], +): Map => { + const categoryColors = new Map(); + categories.forEach((category, index) => { + categoryColors.set(category, colors[index % colors.length]); + }); + return categoryColors; +}; + +export const getColorClassName = ( + color: AvailableChartColorsKeys, + type: ColorUtility, +): string => { + const fallbackColor = { + bg: "bg-gray-9", + stroke: "stroke-gray-9", + fill: "fill-gray-9", + text: "text-gray-9", + }; + return chartColors[color]?.[type] ?? fallbackColor[type]; +}; + +// Tremor getYAxisDomain [v0.0.0] + +export const getYAxisDomain = ( + autoMinValue: boolean, + minValue: number | undefined, + maxValue: number | undefined, +) => { + const minDomain = autoMinValue ? "auto" : (minValue ?? 0); + const maxDomain = maxValue ?? "auto"; + return [minDomain, maxDomain]; +}; + +// Tremor hasOnlyOneValueForKey [v0.1.0] + +export function hasOnlyOneValueForKey( + // biome-ignore lint/suspicious/noExplicitAny: external + array: any[], + keyToCheck: string, +): boolean { + const val: any[] = []; + + for (const obj of array) { + if (Object.prototype.hasOwnProperty.call(obj, keyToCheck)) { + val.push(obj[keyToCheck]); + if (val.length > 1) { + return false; + } + } + } + + return true; +} diff --git a/apps/web/src/lib/cn.ts b/apps/web/src/lib/cn.ts index 365058ce..de186de2 100644 --- a/apps/web/src/lib/cn.ts +++ b/apps/web/src/lib/cn.ts @@ -1,6 +1,29 @@ import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** + * tailwind-merge doesn't have access to the tailwind.config.js file, so we + * have to manually define the class groups we want to merge in case of issues. + */ +const customTwMerge = extendTailwindMerge({ + override: { + classGroups: { + "font-size": [ + "text-1", + "text-2", + "text-3", + "text-4", + "text-5", + "text-6", + "text-7", + "text-8", + "text-9", + "text-10", + ], + }, + }, +}); export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return customTwMerge(clsx(inputs)); } diff --git a/apps/web/src/lib/stacks.ts b/apps/web/src/lib/stacks.ts new file mode 100644 index 00000000..61eacde0 --- /dev/null +++ b/apps/web/src/lib/stacks.ts @@ -0,0 +1,3 @@ +import { TokensApi } from "@hirosystems/token-metadata-api-client"; + +export const stacksTokensApi = new TokensApi({}, "https://api.hiro.so", fetch); diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 78d683e8..739e8274 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -6,6 +6,7 @@ const config: Config = { content: [ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/lib/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { diff --git a/biome.json b/biome.json index 900af56b..77c35b14 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,7 @@ "indentStyle": "space", "indentWidth": 2, "lineWidth": 80, - "ignore": [".next", "dist"] + "ignore": [".nitro", ".next", "dist"] }, "linter": { "enabled": true, @@ -22,6 +22,12 @@ "noUselessElse": "off" } }, - "ignore": [".next", "dist"] + "ignore": [ + ".nitro", + ".next", + "dist", + "apps/web/src/lib/chartUtils.tsx", + "apps/web/src/components/ui/AreaChart.tsx" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce275127..40a39cfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,8 +107,8 @@ importers: specifier: 0.11.1 version: 0.11.1(typescript@5.6.2)(zod@3.23.8) '@tabler/icons-react': - specifier: 3.18.0 - version: 3.18.0(react@18.3.1) + specifier: 3.19.0 + version: 3.19.0(react@18.3.1) '@tanstack/react-query': specifier: 5.56.2 version: 5.56.2(react@18.3.1) @@ -2214,13 +2214,13 @@ packages: typescript: optional: true - '@tabler/icons-react@3.18.0': - resolution: {integrity: sha512-2gGMWJe67T7q6Sgb+4r/OsAjbq6hH30D6D2l02kOnl9kAauSsp/u6Gx1zteQ/GiwqRYSTEIhYMOhOV4LLa8rAw==} + '@tabler/icons-react@3.19.0': + resolution: {integrity: sha512-AqEWGI0tQWgqo6ZjMO5yJ9sYT8oXLuAM/up0hN9iENS6IdtNZryKrkNSiMgpwweNTpl8wFFG/dAZ959S91A/uQ==} peerDependencies: react: '>= 16' - '@tabler/icons@3.18.0': - resolution: {integrity: sha512-zLuwTs/xjQSsGh1X5Pt6qytvM8r6lFpWo5aAiDgnX2ekrOshNs4d87PcJEkP3555gaKy0ZftgckxPzedrN00fQ==} + '@tabler/icons@3.19.0': + resolution: {integrity: sha512-A4WEWqpdbTfnpFEtwXqwAe9qf9sp1yRPvzppqAuwcoF0q5YInqB+JkJtSFToCyBpPVeLxJUxxkapLvt2qQgnag==} '@tanstack/query-core@5.56.2': resolution: {integrity: sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q==} @@ -5348,8 +5348,8 @@ packages: engines: {node: '>=10'} hasBin: true - terser@5.34.0: - resolution: {integrity: sha512-y5NUX+U9HhVsK/zihZwoq4r9dICLyV2jXGOriDAVOeKhq3LKVjgJbGO90FisozXLlJfvjHqgckGmJFBb9KYoWQ==} + terser@5.34.1: + resolution: {integrity: sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA==} engines: {node: '>=10'} hasBin: true @@ -7984,12 +7984,12 @@ snapshots: optionalDependencies: typescript: 5.6.2 - '@tabler/icons-react@3.18.0(react@18.3.1)': + '@tabler/icons-react@3.19.0(react@18.3.1)': dependencies: - '@tabler/icons': 3.18.0 + '@tabler/icons': 3.19.0 react: 18.3.1 - '@tabler/icons@3.18.0': {} + '@tabler/icons@3.19.0': {} '@tanstack/query-core@5.56.2': {} @@ -11451,7 +11451,7 @@ snapshots: jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 - terser: 5.34.0 + terser: 5.34.1 webpack: 5.95.0 terser@5.31.6: @@ -11461,7 +11461,7 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - terser@5.34.0: + terser@5.34.1: dependencies: '@jridgewell/source-map': 0.3.6 acorn: 8.12.1