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 ? (
+
+ ) : 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 (
+
+
+ );
+};
+
+//#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