analytics overhaul (#4107)
<!-- start pr-codex -->
## PR-Codex overview
This PR enhances blockchain analytics in the dashboard. It introduces support for analytics on specific chains and updates chart components for better visualization.
### Detailed summary
- Added `CHAINSAW_API_KEY` environment variable for contract analytics
- Updated chart components for better visualization
- Improved analytics support for specific chains
- Refactored chart styling for better UI/UX
> The following files were skipped due to too many changes: `apps/dashboard/src/contract-ui/tabs/overview/components/Analytics.tsx`, `apps/dashboard/src/contract-ui/tabs/analytics/page.tsx`
> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
<!-- end pr-codex -->
This commit is contained in:
@@ -40,6 +40,9 @@ NEXT_PUBLIC_STRIPE_KEY=
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PAYMENT_METHOD_CFG_ID=
|
||||
|
||||
# Needed for contract analytics / blockchain data information
|
||||
CHAINSAW_API_KEY=
|
||||
|
||||
#
|
||||
# Private (server)
|
||||
#
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
// metadata
|
||||
title: string;
|
||||
description?: string;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
showLegend?: boolean;
|
||||
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
props: ThirdwebAreaChartProps<TConfig>,
|
||||
) {
|
||||
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
{props.description && (
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<defs>
|
||||
{configKeys.map((key) => (
|
||||
<linearGradient
|
||||
key={key}
|
||||
id={`fill_${key}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={`var(--color-${key})`}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={`var(--color-${key})`}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{configKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
dataKey={key}
|
||||
type="natural"
|
||||
fill={`url(#fill_${key})`}
|
||||
fillOpacity={0.4}
|
||||
stroke={`var(--color-${key})`}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showLegend && (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
)}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
// metadata
|
||||
title: string;
|
||||
description?: string;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
showLegend?: boolean;
|
||||
variant?: "stacked" | "grouped";
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
};
|
||||
|
||||
export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
props: ThirdwebBarChartProps<TConfig>,
|
||||
) {
|
||||
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
|
||||
// if there are more than 4 keys then we should stack them by default
|
||||
const variant =
|
||||
props.variant || configKeys.length > 4 ? "stacked" : "grouped";
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
{props.description && (
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
<BarChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
{props.showLegend && (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
)}
|
||||
{configKeys.map((key, idx) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
// if stacked then they should all be the same stackId
|
||||
// if grouped then they should all be unique stackId (so the key works great)
|
||||
stackId={variant === "stacked" ? "a" : key}
|
||||
fill={`var(--color-${key})`}
|
||||
// if stacked then we need to figure out the radius based on the index in the array
|
||||
// if grouped then we can just use the same radius for all
|
||||
radius={
|
||||
variant === "stacked"
|
||||
? idx === 0
|
||||
? [0, 0, 4, 4]
|
||||
: idx === configKeys.length - 1
|
||||
? [4, 4, 0, 0]
|
||||
: [0, 0, 0, 0]
|
||||
: [4, 4, 4, 4]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -283,7 +283,7 @@ const ChartLegendContent = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
"flex items-center justify-center gap-4 overflow-x-auto",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -94,19 +94,22 @@
|
||||
/* shadcn charts */
|
||||
@layer base {
|
||||
:root {
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
/* below is ai generated based on the initial */
|
||||
--chart-6: 200 65% 50%;
|
||||
--chart-7: 120 70% 45%;
|
||||
--chart-8: 45 75% 55%;
|
||||
--chart-9: 260 60% 60%;
|
||||
--chart-10: 0 70% 50%;
|
||||
--chart-11: 180 60% 50%;
|
||||
--chart-12: 100 65% 55%;
|
||||
--chart-13: 360 70% 55%;
|
||||
--chart-14: 240 65% 50%;
|
||||
--chart-15: 80 60% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,13 +129,11 @@ export const BarChart = <
|
||||
tickFormatter={(payload) =>
|
||||
index.format
|
||||
? index.format(payload)
|
||||
: index.type === "date"
|
||||
? new Date(payload).toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
})
|
||||
: payload
|
||||
: new Date(payload).toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
})
|
||||
}
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEns } from "components/contract-components/hooks";
|
||||
import { ContractOverviewPage } from "contract-ui/tabs/overview/page";
|
||||
import type { EnhancedRoute } from "contract-ui/types/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useAnalyticsSupportedForChain } from "../../data/analytics/hooks";
|
||||
|
||||
const LazyContractExplorerPage = dynamic(() =>
|
||||
import("../tabs/explorer/page").then(
|
||||
@@ -100,6 +101,10 @@ export function useContractRouteConfig(
|
||||
const contractQuery = useContract(ensQuery.data?.address);
|
||||
const contractTypeQuery = contractType.useQuery(contractAddress);
|
||||
|
||||
const analyticsSupported = useAnalyticsSupportedForChain(
|
||||
contractQuery.contract?.chainId,
|
||||
);
|
||||
|
||||
const claimconditionExtensionDetection = extensionDetectedState({
|
||||
contractQuery,
|
||||
feature: [
|
||||
@@ -158,6 +163,11 @@ export function useContractRouteConfig(
|
||||
component: LazyContractAnalyticsPage,
|
||||
isDefault: true,
|
||||
isBeta: true,
|
||||
isEnabled: analyticsSupported.isLoading
|
||||
? "loading"
|
||||
: analyticsSupported.data
|
||||
? "enabled"
|
||||
: "disabled",
|
||||
},
|
||||
{
|
||||
title: "NFTs",
|
||||
|
||||
@@ -9,23 +9,15 @@ import {
|
||||
Flex,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
} from "@chakra-ui/react";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import type {
|
||||
AreaChartProps,
|
||||
GenericDataType,
|
||||
} from "components/analytics/area-chart";
|
||||
import { AutoBarChart } from "components/analytics/auto-bar-chart";
|
||||
import { BarChart } from "components/analytics/bar-chart";
|
||||
import { ChartContainer } from "components/analytics/chart-container";
|
||||
import {
|
||||
type AnalyticsQueryParams,
|
||||
type TotalQueryResult,
|
||||
useAnalyticsSupportedChains,
|
||||
useAnalyticsSupportedForChain,
|
||||
useEventsAnalytics,
|
||||
useFunctionsAnalytics,
|
||||
useLogsAnalytics,
|
||||
@@ -36,7 +28,8 @@ import {
|
||||
useUniqueWalletsAnalytics,
|
||||
} from "data/analytics/hooks";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { Card, Heading, Text } from "tw-components";
|
||||
import { Card, Heading } from "tw-components";
|
||||
import { ThirdwebBarChart } from "../../../@/components/blocks/charts/bar-chart";
|
||||
import { useIsomorphicLayoutEffect } from "../../../@/lib/useIsomorphicLayoutEffect";
|
||||
|
||||
interface ContractAnalyticsPageProps {
|
||||
@@ -61,11 +54,32 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
|
||||
|
||||
const evmContractInfo = useEVMContractInfo();
|
||||
|
||||
if (!contractAddress) {
|
||||
const analyticsSupported = useAnalyticsSupportedForChain(
|
||||
evmContractInfo?.chain?.chainId,
|
||||
);
|
||||
|
||||
if (
|
||||
!contractAddress ||
|
||||
!evmContractInfo?.chain?.chainId ||
|
||||
analyticsSupported.isLoading
|
||||
) {
|
||||
// TODO build a skeleton for this
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!analyticsSupported.data) {
|
||||
return (
|
||||
<Alert status="warning" borderRadius="md" mb={4}>
|
||||
<AlertIcon />
|
||||
<AlertTitle>Analytics is not supported for this chain.</AlertTitle>
|
||||
<AlertDescription>
|
||||
Analytics support is rolling out to additional chains. Please check
|
||||
back later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={6}>
|
||||
{contractAddress && evmContractInfo?.chain && (
|
||||
@@ -111,124 +125,40 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
|
||||
</Flex>
|
||||
</Flex>
|
||||
<SimpleGrid columns={{ base: 1, md: 1 }} gap={4}>
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Unique Wallets
|
||||
</Heading>
|
||||
<Text>
|
||||
The number of unique wallet addresses that have sent a
|
||||
transaction to this contract.
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "wallets", label: "Unique Wallets" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useUniqueWalletsAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Total Transactions
|
||||
</Heading>
|
||||
<Text>
|
||||
The number of transactions that have been sent to this
|
||||
contract.
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "count", label: "Transactions" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useTransactionAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<UniqueWalletsChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Total Events
|
||||
</Heading>
|
||||
<Text>
|
||||
The number of on-chain events that have been emitted from this
|
||||
contract.
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "count", label: "Events" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useLogsAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Function Breakdown
|
||||
</Heading>
|
||||
<Text>
|
||||
The breakdown of calls to each write function from
|
||||
transactions.
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={"auto"}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useFunctionsAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
|
||||
<Stack spacing={0}>
|
||||
<Heading as="h3" size="subtitle.sm">
|
||||
Event Breakdown
|
||||
</Heading>
|
||||
<Text>The breakdown of events emitted by this contract.</Text>
|
||||
</Stack>
|
||||
<ChartContainer className="w-full" ratio={4.5 / 1}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={"auto"}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useEventsAnalytics}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
<TotalTransactionsChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
|
||||
<TotalEventsChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
|
||||
<FunctionBreakdownChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
|
||||
<EventBreakdownChart
|
||||
chainId={evmContractInfo.chain.chainId}
|
||||
contractAddress={contractAddress}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
@@ -236,87 +166,127 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface AnalyticsChartProps<
|
||||
TAnalytics extends GenericDataType = GenericDataType,
|
||||
> {
|
||||
chainId: number;
|
||||
type ChartProps = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
index: string;
|
||||
categories: AreaChartProps<TAnalytics, "time">["categories"] | "auto";
|
||||
useAnalytics: (params: AnalyticsQueryParams) => UseQueryResult<TAnalytics[]>;
|
||||
showXAxis?: boolean;
|
||||
showYAxis?: boolean;
|
||||
}
|
||||
|
||||
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
|
||||
chainId,
|
||||
contractAddress,
|
||||
startDate,
|
||||
endDate,
|
||||
index,
|
||||
categories,
|
||||
useAnalytics,
|
||||
showXAxis,
|
||||
showYAxis,
|
||||
}) => {
|
||||
const supportedChainsQuery = useAnalyticsSupportedChains();
|
||||
|
||||
const analyticsQuery = useAnalytics({
|
||||
contractAddress,
|
||||
chainId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!analyticsQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return analyticsQuery.data;
|
||||
}, [analyticsQuery.data]);
|
||||
|
||||
if (data.length <= 1) {
|
||||
const supportedChains = supportedChainsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<Alert status="info" borderRadius="md" mb={4}>
|
||||
<AlertIcon />
|
||||
{supportedChains.includes(chainId) ? (
|
||||
<AlertDescription>No recent activity.</AlertDescription>
|
||||
) : (
|
||||
<AlertDescription>
|
||||
Analytics for this chain not currently supported.
|
||||
</AlertDescription>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (categories === "auto") {
|
||||
return (
|
||||
<AutoBarChart
|
||||
data={data}
|
||||
index={{ id: index }}
|
||||
showXAxis
|
||||
showYAxis
|
||||
stacked
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
function UniqueWalletsChart(props: ChartProps) {
|
||||
const { data } = useUniqueWalletsAnalytics(props);
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
data={data}
|
||||
index={{ id: index }}
|
||||
categories={categories}
|
||||
showXAxis={showXAxis !== undefined ? showXAxis : true}
|
||||
showYAxis={showYAxis !== undefined ? showYAxis : true}
|
||||
<ThirdwebBarChart
|
||||
title="Unique Wallets"
|
||||
description="The number of unique wallet addresses that have sent a transaction to this contract."
|
||||
data={data || []}
|
||||
config={{
|
||||
wallets: {
|
||||
label: "Unique Wallets",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function TotalTransactionsChart(props: ChartProps) {
|
||||
const { data } = useTransactionAnalytics({
|
||||
chainId: props.chainId,
|
||||
contractAddress: props.contractAddress,
|
||||
endDate: props.endDate,
|
||||
startDate: props.startDate,
|
||||
});
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Total Transactions"
|
||||
description="The number of transactions that have been sent to this contract."
|
||||
data={data || []}
|
||||
config={{
|
||||
count: {
|
||||
label: "Transactions",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalEventsChart(props: ChartProps) {
|
||||
const { data } = useLogsAnalytics(props);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Total Events"
|
||||
description="The number of on-chain events that have been emitted from this contract."
|
||||
data={data || []}
|
||||
config={{
|
||||
count: {
|
||||
label: "Events",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FunctionBreakdownChart(props: ChartProps) {
|
||||
const { data } = useFunctionsAnalytics(props);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Function Breakdown"
|
||||
description="The breakdown of calls to each write function from transactions."
|
||||
data={data || []}
|
||||
config={Object.keys(data?.[0] || {}).reduce(
|
||||
(acc, key) => {
|
||||
if (key === "time") {
|
||||
return acc;
|
||||
}
|
||||
acc[key] = {
|
||||
label: key,
|
||||
color: `hsl(var(--chart-${(Object.keys(acc).length % 15) + 1}))`,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { label: string; color: string }>,
|
||||
)}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
showLegend
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EventBreakdownChart(props: ChartProps) {
|
||||
const { data } = useEventsAnalytics(props);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
title="Event Breakdown"
|
||||
description="The breakdown of events emitted by this contract."
|
||||
data={data || []}
|
||||
config={Object.keys(data?.[0] || {}).reduce(
|
||||
(acc, key) => {
|
||||
if (key === "time") {
|
||||
return acc;
|
||||
}
|
||||
acc[key] = {
|
||||
label: key,
|
||||
color: `hsl(var(--chart-${(Object.keys(acc).length % 15) + 1}))`,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { label: string; color: string }>,
|
||||
)}
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
showLegend
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnalyticsStatProps {
|
||||
label: string;
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { Flex, GridItem, Icon, SimpleGrid, Tooltip } from "@chakra-ui/react";
|
||||
import { ChartContainer } from "components/analytics/chart-container";
|
||||
import { AnalyticsChart } from "contract-ui/tabs/analytics/page";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTabHref } from "contract-ui/utils";
|
||||
import {
|
||||
useAnalyticsSupportedForChain,
|
||||
useLogsAnalytics,
|
||||
useTransactionAnalytics,
|
||||
useUniqueWalletsAnalytics,
|
||||
} from "data/analytics/hooks";
|
||||
import { useState } from "react";
|
||||
import { FiInfo } from "react-icons/fi";
|
||||
import {
|
||||
Card,
|
||||
Heading,
|
||||
TrackedLink,
|
||||
type TrackedLinkProps,
|
||||
} from "tw-components";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface AnalyticsOverviewProps {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
trackingCategory: TrackedLinkProps["category"];
|
||||
trackingCategory: string;
|
||||
}
|
||||
|
||||
export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
|
||||
@@ -27,10 +23,13 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
|
||||
contractAddress,
|
||||
trackingCategory,
|
||||
}) => {
|
||||
const analyticsSupported = useAnalyticsSupportedForChain(chainId);
|
||||
|
||||
const trackEvent = useTrack();
|
||||
const [startDate] = useState(
|
||||
(() => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 11);
|
||||
date.setDate(date.getDate() - 14);
|
||||
return date;
|
||||
})(),
|
||||
);
|
||||
@@ -38,152 +37,94 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
|
||||
|
||||
const analyticsHref = useTabHref("analytics");
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={{ base: 3, md: 6 }}>
|
||||
<Flex align="center" justify="space-between" w="full">
|
||||
<Heading size="title.sm">Analytics</Heading>
|
||||
<TrackedLink
|
||||
category={trackingCategory}
|
||||
label="view_all_nfts"
|
||||
color="blue.400"
|
||||
_light={{
|
||||
color: "blue.600",
|
||||
}}
|
||||
gap={4}
|
||||
href={analyticsHref}
|
||||
>
|
||||
View all ->
|
||||
</TrackedLink>
|
||||
</Flex>
|
||||
if (!analyticsSupported.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
<SimpleGrid gap={{ base: 3, md: 6 }} columns={{ base: 1, md: 3 }}>
|
||||
<GridItem>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
gap={4}
|
||||
as={Card}
|
||||
bg="backgroundHighlight"
|
||||
h="full"
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Heading size="label.md">Unique Wallets</Heading>
|
||||
<Tooltip
|
||||
p={0}
|
||||
label={
|
||||
<Flex p={2} fontSize="small" color="white">
|
||||
The number of unique wallet addresses that have sent a
|
||||
transaction to this contract.
|
||||
</Flex>
|
||||
}
|
||||
bgColor="backgroundCardHighlight"
|
||||
borderRadius="lg"
|
||||
placement="right"
|
||||
shouldWrapChildren
|
||||
>
|
||||
<Icon as={FiInfo} boxSize={4} color="gray.700" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<ChartContainer className="w-full" ratio={1.7}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "wallets", label: "Unique Wallets" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useUniqueWalletsAnalytics}
|
||||
showYAxis={false}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
gap={4}
|
||||
as={Card}
|
||||
bg="backgroundHighlight"
|
||||
h="full"
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Heading size="label.md">Total Transactions</Heading>
|
||||
<Tooltip
|
||||
p={0}
|
||||
label={
|
||||
<Flex p={2} fontSize="small" color="white">
|
||||
The number of transactions that have been sent to this
|
||||
contract.
|
||||
</Flex>
|
||||
}
|
||||
bgColor="backgroundCardHighlight"
|
||||
borderRadius="lg"
|
||||
placement="right"
|
||||
shouldWrapChildren
|
||||
>
|
||||
<Icon as={FiInfo} boxSize={4} color="gray.700" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<ChartContainer className="w-full" ratio={1.7}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "count", label: "Transactions" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useTransactionAnalytics}
|
||||
showYAxis={false}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
gap={4}
|
||||
as={Card}
|
||||
bg="backgroundHighlight"
|
||||
h="full"
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Heading size="label.md">Total Events</Heading>
|
||||
<Tooltip
|
||||
p={0}
|
||||
label={
|
||||
<Flex p={2} fontSize="small" color="white">
|
||||
The number of on-chain events that have been emitted from
|
||||
this contract.
|
||||
</Flex>
|
||||
}
|
||||
bgColor="backgroundCardHighlight"
|
||||
borderRadius="lg"
|
||||
placement="right"
|
||||
shouldWrapChildren
|
||||
>
|
||||
<Icon as={FiInfo} boxSize={4} color="gray.700" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<ChartContainer className="w-full" ratio={1.7}>
|
||||
<AnalyticsChart
|
||||
contractAddress={contractAddress}
|
||||
chainId={chainId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
index={"time"}
|
||||
categories={[{ id: "count", label: "Events" }]}
|
||||
// FIXME
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
useAnalytics={useLogsAnalytics}
|
||||
showYAxis={false}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</Flex>
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
</Flex>
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
asChild
|
||||
className="absolute top-4 right-6 flex flex-row gap-1 items-center"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: trackingCategory,
|
||||
action: "click",
|
||||
label: "view_all_analytics",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link href={analyticsHref}>
|
||||
<span>View All</span>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<OverviewAnalytics
|
||||
chainId={chainId}
|
||||
contractAddress={contractAddress}
|
||||
endDate={endDate}
|
||||
startDate={startDate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ChartProps = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
|
||||
function OverviewAnalytics(props: ChartProps) {
|
||||
const wallets = useUniqueWalletsAnalytics(props);
|
||||
const transactions = useTransactionAnalytics(props);
|
||||
const events = useLogsAnalytics(props);
|
||||
|
||||
const mergedData = useMemo(() => {
|
||||
const time = (wallets.data || transactions.data || events.data || []).map(
|
||||
(wallet) => wallet.time,
|
||||
);
|
||||
|
||||
return time.map((time) => {
|
||||
const wallet = wallets.data?.find((wallet) => wallet.time === time);
|
||||
const transaction = transactions.data?.find(
|
||||
(transaction) => transaction.time === time,
|
||||
);
|
||||
const event = events.data?.find((event) => event.time === time);
|
||||
|
||||
return {
|
||||
time,
|
||||
wallets: wallet?.wallets || 0,
|
||||
transactions: transaction?.count || 0,
|
||||
events: event?.count || 0,
|
||||
};
|
||||
});
|
||||
}, [wallets.data, transactions.data, events.data]);
|
||||
|
||||
return (
|
||||
<ThirdwebAreaChart
|
||||
title="Analytics"
|
||||
config={{
|
||||
wallets: {
|
||||
label: "Unique Wallets",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
transactions: {
|
||||
label: "Total Transactions",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
events: {
|
||||
label: "Total Events",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
}}
|
||||
data={mergedData}
|
||||
showLegend
|
||||
chartClassName="aspect[2] lg:aspect-[4.5]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export const ContractSidebar: React.FC<ContractSidebarProps> = ({
|
||||
href: `/${r.path.replace("overview", "")}`,
|
||||
isBeta: r.isBeta,
|
||||
isDeprecated: r.isDeprecated,
|
||||
extensionDetectedState: r.isEnabled,
|
||||
onClick: () => {
|
||||
openState.onClose();
|
||||
},
|
||||
@@ -131,25 +132,25 @@ const NavLinkSection: React.FC<NavLinkSectionProps> = ({
|
||||
</Text>
|
||||
</Flex>
|
||||
{filteredLinks.map((link) => (
|
||||
<Flex gap={2} key={link.href}>
|
||||
<DetailNavLink key={link.href} {...link}>
|
||||
<DetailNavLink key={link.href} {...link}>
|
||||
<Flex gap={2} as="span">
|
||||
{link.title}
|
||||
</DetailNavLink>
|
||||
{link.isBeta && (
|
||||
<Box>
|
||||
<Badge colorScheme="green" variant="subtle">
|
||||
Beta
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
{link.isDeprecated && (
|
||||
<Box>
|
||||
<Badge colorScheme="orange" variant="subtle">
|
||||
Deprecated
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
{link.isBeta && (
|
||||
<Box>
|
||||
<Badge colorScheme="green" variant="subtle">
|
||||
Beta
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
{link.isDeprecated && (
|
||||
<Box>
|
||||
<Badge colorScheme="orange" variant="subtle">
|
||||
Deprecated
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</DetailNavLink>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const THIRDWEB_ANALYTICS_API_HOSTNAME = "https://chainsaw.thirdweb.com";
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { THIRDWEB_ANALYTICS_API_HOSTNAME } from "./constants";
|
||||
|
||||
export type AnalyticsQueryParams = {
|
||||
chainId: number;
|
||||
@@ -17,7 +16,7 @@ async function makeQuery(
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("&")}`;
|
||||
return fetch(`${THIRDWEB_ANALYTICS_API_HOSTNAME}${path}${queryString}`, {
|
||||
return fetch(`/api/server-proxy/chainsaw/${path}${queryString}`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
@@ -374,17 +373,24 @@ export function useTotalWalletsAnalytics(params: AnalyticsQueryParams) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnalyticsSupportedChains() {
|
||||
return useQuery(
|
||||
["analytics-supported-chains"] as const,
|
||||
async (): Promise<number[]> => {
|
||||
const res = await makeQuery("/api/v1/supported-chains", {});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}`);
|
||||
}
|
||||
export function useAnalyticsSupportedForChain(chainId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["analytics-supported-chains", chainId] as const,
|
||||
queryFn: async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await makeQuery(`/service/chains/${chainId}`, {});
|
||||
if (!res.ok) {
|
||||
// assume not supported if we get a non-200 response
|
||||
return false;
|
||||
}
|
||||
|
||||
const { results } = await res.json();
|
||||
return results;
|
||||
const { data } = await res.json();
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error("Error checking if analytics is supported for chain", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
enabled: !!chainId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const config = {
|
||||
runtime: "edge",
|
||||
};
|
||||
const handler = async (req: NextRequest) => {
|
||||
if (!process.env.CHAINSAW_API_KEY) {
|
||||
return new Response("CHAINSAW_API_KEY not set", { status: 401 });
|
||||
}
|
||||
|
||||
// get the full path from the request including query params
|
||||
let pathname = req.nextUrl.pathname;
|
||||
|
||||
// remove the /api/server-proxy prefix
|
||||
pathname = pathname.replace(/^\/api\/server-proxy\/chainsaw/, "");
|
||||
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
searchParams.delete("paths");
|
||||
|
||||
// create a new URL object for the chainsaw service
|
||||
// TODO: this should really point at dev vs prod based on the environment - but for now prod only will have to suffice
|
||||
const CHAINSAW_URL = new URL("https://chainsaw.thirdweb.com");
|
||||
CHAINSAW_URL.pathname = pathname;
|
||||
searchParams.forEach((value, key) => {
|
||||
CHAINSAW_URL.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
return fetch(CHAINSAW_URL, {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
// pass the shared secret
|
||||
"x-service-api-key": process.env.CHAINSAW_API_KEY,
|
||||
},
|
||||
body: req.body,
|
||||
});
|
||||
};
|
||||
|
||||
export default handler;
|
||||
Submodule apps/hardhat-boilerplate deleted from 638fd58ec8
Reference in New Issue
Block a user