Skip to content

Commit a5ad05d

Browse files
committed
[Dashboard] Add detailed usage breakdown and billing preview
1 parent 5c4c49b commit a5ad05d

File tree

38 files changed

+359
-560
lines changed

38 files changed

+359
-560
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import "server-only";
2+
3+
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
4+
import { getAuthToken } from "../../../app/(app)/api/lib/getAuthToken";
5+
6+
type LineItem = {
7+
quantity: number;
8+
amountUsdCents: number;
9+
unitAmountUsdCents: string;
10+
description: string;
11+
};
12+
13+
export type UsageCategory = {
14+
category: string;
15+
unitName: string;
16+
lineItems: LineItem[];
17+
};
18+
19+
type UsageApiResponse = {
20+
result: UsageCategory[];
21+
};
22+
23+
export async function getBilledUsage(teamSlug: string) {
24+
const authToken = await getAuthToken();
25+
if (!authToken) {
26+
throw new Error("No auth token found");
27+
}
28+
const response = await fetch(
29+
new URL(
30+
`/v1/teams/${teamSlug}/billing/billed-usage`,
31+
NEXT_PUBLIC_THIRDWEB_API_HOST,
32+
),
33+
{
34+
next: {
35+
// revalidate this query once per minute (does not need to be more granular than that)
36+
revalidate: 60,
37+
},
38+
headers: {
39+
Authorization: `Bearer ${authToken}`,
40+
},
41+
},
42+
);
43+
if (!response.ok) {
44+
const body = await response.text();
45+
throw new Error(
46+
`Failed to fetch billed usage – ${response.status} ${response.statusText}: ${body}`,
47+
);
48+
}
49+
return response.json() as Promise<UsageApiResponse>;
50+
}

apps/dashboard/src/@/api/usage/rpc.ts

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,58 +2,6 @@ import "server-only";
22
import { unstable_cache } from "next/cache";
33
import { ANALYTICS_SERVICE_URL } from "../../constants/server-envs";
44

5-
export type RPCUsageDataItem = {
6-
date: string;
7-
usageType: "included" | "overage" | "rate-limit";
8-
count: string;
9-
};
10-
11-
export const fetchRPCUsage = unstable_cache(
12-
async (params: {
13-
teamId: string;
14-
projectId?: string;
15-
authToken: string;
16-
from: string;
17-
to: string;
18-
period: "day" | "week" | "month" | "year" | "all";
19-
}) => {
20-
const analyticsEndpoint = ANALYTICS_SERVICE_URL;
21-
const url = new URL(`${analyticsEndpoint}/v2/rpc/usage-types`);
22-
url.searchParams.set("teamId", params.teamId);
23-
if (params.projectId) {
24-
url.searchParams.set("projectId", params.projectId);
25-
}
26-
url.searchParams.set("from", params.from);
27-
url.searchParams.set("to", params.to);
28-
url.searchParams.set("period", params.period);
29-
30-
const res = await fetch(url, {
31-
headers: {
32-
Authorization: `Bearer ${params.authToken}`,
33-
},
34-
});
35-
36-
if (!res.ok) {
37-
const error = await res.text();
38-
return {
39-
ok: false as const,
40-
error: error,
41-
};
42-
}
43-
44-
const resData = await res.json();
45-
46-
return {
47-
ok: true as const,
48-
data: resData.data as RPCUsageDataItem[],
49-
};
50-
},
51-
["rpc-usage"],
52-
{
53-
revalidate: 60 * 60, // 1 hour
54-
},
55-
);
56-
575
type Last24HoursRPCUsageApiResponse = {
586
peakRate: {
597
date: string;

apps/dashboard/src/@/components/blocks/charts/area-chart.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
EmptyChartState,
2121
LoadingChartState,
2222
} from "components/analytics/empty-chart-state";
23-
import { formatDate } from "date-fns";
23+
import { format } from "date-fns";
2424
import { useMemo } from "react";
2525
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
2626

@@ -94,7 +94,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
9494
axisLine={false}
9595
tickMargin={20}
9696
tickFormatter={(value) =>
97-
formatDate(
97+
format(
9898
new Date(value),
9999
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
100100
)

apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
EmptyChartState,
2323
LoadingChartState,
2424
} from "components/analytics/empty-chart-state";
25-
import { formatDate } from "date-fns";
25+
import { format } from "date-fns";
2626
import { useMemo } from "react";
2727

2828
type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
@@ -86,7 +86,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
8686
tickLine={false}
8787
axisLine={false}
8888
tickMargin={10}
89-
tickFormatter={(value) => formatDate(new Date(value), "MMM d")}
89+
tickFormatter={(value) => format(new Date(value), "MMM d")}
9090
/>
9191
<ChartTooltip
9292
content={

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/account-permissions/components/account-signer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { WalletAddress } from "@/components/blocks/wallet-address";
22
import { Badge } from "@/components/ui/badge";
33
import { Flex, SimpleGrid, useBreakpointValue } from "@chakra-ui/react";
4-
import { formatDistance } from "date-fns/formatDistance";
4+
import { formatDistance } from "date-fns";
55
import { useAllChainsData } from "hooks/chains/allChains";
66
import type { ThirdwebClient } from "thirdweb";
77
import { useActiveAccount } from "thirdweb/react";

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
useTotalContractTransactionAnalytics,
2020
useTotalContractUniqueWallets,
2121
} from "data/analytics/hooks";
22-
import { formatDate } from "date-fns";
2322
import { useMemo, useState } from "react";
2423
import type { ThirdwebContract } from "thirdweb";
2524

@@ -125,7 +124,7 @@ type ChartProps = {
125124
function toolTipLabelFormatter(_v: string, item: unknown) {
126125
if (Array.isArray(item)) {
127126
const time = item[0].payload.time as number;
128-
return formatDate(new Date(time), "MMM d, yyyy");
127+
return format(new Date(time), "MMM d, yyyy");
129128
}
130129
return undefined;
131130
}

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/Analytics.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
useContractTransactionAnalytics,
88
useContractUniqueWalletAnalytics,
99
} from "data/analytics/hooks";
10-
import { differenceInCalendarDays, formatDate } from "date-fns";
10+
import { differenceInCalendarDays } from "date-fns";
1111
import { useTrack } from "hooks/analytics/useTrack";
1212
import { ArrowRightIcon } from "lucide-react";
1313
import Link from "next/link";
@@ -200,7 +200,7 @@ export function toolTipLabelFormatterWithPrecision(precision: "day" | "hour") {
200200
return function toolTipLabelFormatter(_v: string, item: unknown) {
201201
if (Array.isArray(item)) {
202202
const time = item[0].payload.time as number;
203-
return formatDate(
203+
return format(
204204
new Date(time),
205205
precision === "day" ? "MMM d, yyyy" : "MMM d, yyyy hh:mm a",
206206
);

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
66
import { SkeletonContainer } from "@/components/ui/skeleton";
77
import { ToolTipLabel } from "@/components/ui/tooltip";
88
import { cn } from "@/lib/utils";
9-
import { differenceInCalendarDays, formatDate } from "date-fns";
9+
import { differenceInCalendarDays } from "date-fns";
1010
import { ArrowUpIcon, InfoIcon } from "lucide-react";
1111
import { ArrowDownIcon } from "lucide-react";
1212
import { useMemo, useState } from "react";
@@ -81,7 +81,7 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
8181
return (_v: string, item: unknown) => {
8282
if (Array.isArray(item)) {
8383
const time = item[0].payload.time as number;
84-
return formatDate(
84+
return format(
8585
new Date(time),
8686
includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy",
8787
);

apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
2-
import { format } from "date-fns/format";
2+
import { format } from "date-fns";
33
import { resolveEns } from "lib/ens";
44
import { correctAndUniqueLicenses } from "lib/licenses";
55
import { getSocialProfiles } from "thirdweb/social";

apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
2-
import { format } from "date-fns/format";
2+
import { format } from "date-fns";
33
import { resolveEns } from "lib/ens";
44
import { correctAndUniqueLicenses } from "lib/licenses";
55
import { getSocialProfiles } from "thirdweb/social";

apps/dashboard/src/app/(app)/account/wallets/LinkWalletUI.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
} from "@/components/ui/table";
2424
import { useDashboardRouter } from "@/lib/DashboardRouter";
2525
import { useMutation } from "@tanstack/react-query";
26-
import { formatDate } from "date-fns";
2726
import { MinusIcon } from "lucide-react";
2827
import { useState } from "react";
2928
import { toast } from "sonner";
@@ -106,7 +105,7 @@ export function LinkWalletUI(props: {
106105
/>
107106
</TableCell>
108107
<TableCell className="text-muted-foreground text-sm">
109-
{formatDate(wallet.createdAt, "MMM d, yyyy")}
108+
{format(wallet.createdAt, "MMM d, yyyy")}
110109
</TableCell>
111110
<TableCell>
112111
<UnlinkButton
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { UsageCategory } from "@/api/usage/billing-preview";
2+
import {
3+
Card,
4+
CardContent,
5+
CardDescription,
6+
CardFooter,
7+
CardHeader,
8+
CardTitle,
9+
} from "@/components/ui/card";
10+
import {
11+
Table,
12+
TableBody,
13+
TableCell,
14+
TableHead,
15+
TableHeader,
16+
TableRow,
17+
} from "@/components/ui/table";
18+
19+
interface UsageCategoryDetailsProps {
20+
category: UsageCategory;
21+
}
22+
23+
export function UsageCategoryDetails({ category }: UsageCategoryDetailsProps) {
24+
const categoryTotalCents = category.lineItems.reduce(
25+
(sum, item) => sum + item.amountUsdCents,
26+
0,
27+
);
28+
29+
// filter out any lines with 0 quantity
30+
const filteredLineItems = category.lineItems.filter(
31+
(item) => item.quantity > 0,
32+
);
33+
34+
return (
35+
<Card className="overflow-hidden">
36+
<CardHeader>
37+
<CardTitle className="text-lg">{category.category}</CardTitle>
38+
<CardDescription className="text-sm">
39+
Unit of Measure: {category.unitName}
40+
</CardDescription>
41+
</CardHeader>
42+
<CardContent className="p-0">
43+
<Table>
44+
<TableHeader className="bg-transparent">
45+
<TableRow>
46+
<TableHead className="w-[45%] pl-6">Description</TableHead>
47+
<TableHead className="text-right">Quantity</TableHead>
48+
<TableHead className="text-right">Unit Price</TableHead>
49+
<TableHead className="pr-6 text-right">Amount</TableHead>
50+
</TableRow>
51+
</TableHeader>
52+
<TableBody>
53+
{filteredLineItems.length > 0 ? (
54+
filteredLineItems.map((item, index) => (
55+
<TableRow
56+
key={`${item.description}_${index}`}
57+
className="hover:bg-accent"
58+
>
59+
<TableCell className="py-3 pl-6 font-medium">
60+
{item.description}
61+
</TableCell>
62+
<TableCell className="py-3 text-right">
63+
{item.quantity.toLocaleString()}
64+
</TableCell>
65+
<TableCell className="py-3 text-right">
66+
{formatPrice(item.unitAmountUsdCents, {
67+
isUnitPrice: true,
68+
inCents: true,
69+
})}
70+
</TableCell>
71+
<TableCell className="py-3 pr-6 text-right">
72+
{formatPrice(item.amountUsdCents, { inCents: true })}
73+
</TableCell>
74+
</TableRow>
75+
))
76+
) : (
77+
<TableRow>
78+
<TableCell
79+
colSpan={4}
80+
className="h-24 pl-6 text-center text-muted-foreground"
81+
>
82+
No usage during this period.
83+
</TableCell>
84+
</TableRow>
85+
)}
86+
</TableBody>
87+
</Table>
88+
</CardContent>
89+
{categoryTotalCents > 0 && (
90+
<CardFooter className="flex justify-end p-4 pr-6 ">
91+
<div className="font-semibold text-md">
92+
Subtotal: {formatPrice(categoryTotalCents, { inCents: true })}
93+
</div>
94+
</CardFooter>
95+
)}
96+
</Card>
97+
);
98+
}
99+
100+
// Currency Formatting Helper
101+
export function formatPrice(
102+
value: number | string,
103+
options?: { isUnitPrice?: boolean; inCents?: boolean },
104+
) {
105+
const { isUnitPrice = false, inCents = true } = options || {};
106+
const numericValue =
107+
typeof value === "string" ? Number.parseFloat(value) : value;
108+
109+
if (Number.isNaN(numericValue)) {
110+
return "N/A";
111+
}
112+
113+
const amountInDollars = inCents ? numericValue / 100 : numericValue;
114+
115+
return amountInDollars.toLocaleString("en-US", {
116+
style: "currency",
117+
currency: "USD",
118+
minimumFractionDigits: 2,
119+
maximumFractionDigits: isUnitPrice ? 10 : 2, // Allow more precision for unit prices
120+
});
121+
}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
66
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
77
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
88
import { DocLink } from "components/shared/DocLink";
9-
import { formatDate } from "date-fns";
109
import { formatTickerNumber } from "lib/format-utils";
1110
import { useMemo } from "react";
1211
import type { EcosystemWalletStats } from "types/analytics";
@@ -169,7 +168,7 @@ export function EcosystemWalletUsersChartCard(props: {
169168
toolTipLabelFormatter={(_v, item) => {
170169
if (Array.isArray(item)) {
171170
const time = item[0].payload.time as number;
172-
return formatDate(new Date(time), "MMM d, yyyy");
171+
return format(new Date(time), "MMM d, yyyy");
173172
}
174173
return undefined;
175174
}}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
import { useDashboardRouter } from "@/lib/DashboardRouter";
2626
import { cn } from "@/lib/utils";
2727
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
28-
import { formatDate } from "date-fns";
2928
import { ArrowDownNarrowWideIcon, PlusIcon, SearchIcon } from "lucide-react";
3029
import Link from "next/link";
3130
import { useMemo, useState } from "react";
@@ -207,7 +206,7 @@ export function TeamProjectsPage(props: {
207206
/>
208207
</TableCell>
209208
<TableCell className="text-muted-foreground">
210-
{formatDate(new Date(project.createdAt), "MMM d, yyyy")}
209+
{format(new Date(project.createdAt), "MMM d, yyyy")}
211210
</TableCell>
212211
</TableRow>
213212
))}

0 commit comments

Comments
 (0)