Skip to content

Commit f89c15c

Browse files
authored
[Dashboard] Add detailed usage breakdown and billing preview (#7290)
1 parent c95c0dc commit f89c15c

File tree

40 files changed

+454
-570
lines changed

40 files changed

+454
-570
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
periodStart: string;
22+
periodEnd: string;
23+
planVersion: number;
24+
};
25+
26+
export async function getBilledUsage(teamSlug: string) {
27+
const authToken = await getAuthToken();
28+
if (!authToken) {
29+
throw new Error("No auth token found");
30+
}
31+
const response = await fetch(
32+
new URL(
33+
`/v1/teams/${teamSlug}/billing/billed-usage`,
34+
NEXT_PUBLIC_THIRDWEB_API_HOST,
35+
),
36+
{
37+
next: {
38+
// revalidate this query once per minute (does not need to be more granular than that)
39+
revalidate: 60,
40+
},
41+
headers: {
42+
Authorization: `Bearer ${authToken}`,
43+
},
44+
},
45+
);
46+
if (!response.ok) {
47+
// if the status is 404, the most likely reason is that the team is on a free plan
48+
if (response.status === 404) {
49+
return {
50+
status: "error",
51+
reason: "free_plan",
52+
} as const;
53+
}
54+
const body = await response.text();
55+
return {
56+
status: "error",
57+
reason: "unknown",
58+
body,
59+
} as const;
60+
}
61+
const data = (await response.json()) as UsageApiResponse;
62+
return {
63+
status: "success",
64+
data,
65+
} as const;
66+
}

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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
useTotalContractTransactionAnalytics,
2020
useTotalContractUniqueWallets,
2121
} from "data/analytics/hooks";
22-
import { formatDate } from "date-fns";
22+
import { format } from "date-fns";
2323
import { useMemo, useState } from "react";
2424
import type { ThirdwebContract } from "thirdweb";
2525

@@ -125,7 +125,7 @@ type ChartProps = {
125125
function toolTipLabelFormatter(_v: string, item: unknown) {
126126
if (Array.isArray(item)) {
127127
const time = item[0].payload.time as number;
128-
return formatDate(new Date(time), "MMM d, yyyy");
128+
return format(new Date(time), "MMM d, yyyy");
129129
}
130130
return undefined;
131131
}

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, format } 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, format } 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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ 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";
26+
import { format } from "date-fns";
2727
import { MinusIcon } from "lucide-react";
2828
import { useState } from "react";
2929
import { toast } from "sonner";
@@ -106,7 +106,7 @@ export function LinkWalletUI(props: {
106106
/>
107107
</TableCell>
108108
<TableCell className="text-muted-foreground text-sm">
109-
{formatDate(wallet.createdAt, "MMM d, yyyy")}
109+
{format(wallet.createdAt, "MMM d, yyyy")}
110110
</TableCell>
111111
<TableCell>
112112
<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+
CardFooter,
6+
CardHeader,
7+
CardTitle,
8+
} from "@/components/ui/card";
9+
import {
10+
Table,
11+
TableBody,
12+
TableCell,
13+
TableHead,
14+
TableHeader,
15+
TableRow,
16+
} from "@/components/ui/table";
17+
18+
interface UsageCategoryDetailsProps {
19+
category: UsageCategory;
20+
}
21+
22+
export function UsageCategoryDetails({ category }: UsageCategoryDetailsProps) {
23+
const categoryTotalCents = category.lineItems.reduce(
24+
(sum, item) => sum + item.amountUsdCents,
25+
0,
26+
);
27+
28+
// filter out any lines with 0 quantity
29+
const filteredLineItems = category.lineItems.filter(
30+
(item) => item.quantity > 0,
31+
);
32+
33+
return (
34+
<Card className="overflow-hidden">
35+
<CardHeader>
36+
<CardTitle className="text-lg">{category.category}</CardTitle>
37+
</CardHeader>
38+
<CardContent className="p-0">
39+
<Table>
40+
<TableHeader className="bg-transparent">
41+
<TableRow>
42+
<TableHead className="w-[45%] pl-6">Description</TableHead>
43+
<TableHead className="text-right">Quantity</TableHead>
44+
<TableHead className="text-right">Unit Price</TableHead>
45+
<TableHead className="pr-6 text-right">Amount</TableHead>
46+
</TableRow>
47+
</TableHeader>
48+
<TableBody>
49+
{filteredLineItems.length > 0 ? (
50+
filteredLineItems.map((item, index) => (
51+
<TableRow
52+
key={`${item.description}_${index}`}
53+
className="hover:bg-accent"
54+
>
55+
<TableCell className="py-3 pl-6 font-medium">
56+
{item.description}
57+
</TableCell>
58+
<TableCell className="py-3 text-right">
59+
{item.quantity.toLocaleString()}
60+
</TableCell>
61+
<TableCell className="py-3 text-right">
62+
{formatPrice(item.unitAmountUsdCents, {
63+
isUnitPrice: true,
64+
inCents: true,
65+
})}
66+
</TableCell>
67+
<TableCell className="py-3 pr-6 text-right">
68+
{formatPrice(
69+
item.quantity *
70+
Number.parseFloat(item.unitAmountUsdCents),
71+
{ inCents: true },
72+
)}
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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ 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";
9+
import { format } from "date-fns";
1010
import { formatTickerNumber } from "lib/format-utils";
1111
import { useMemo } from "react";
1212
import type { EcosystemWalletStats } from "types/analytics";
@@ -169,7 +169,7 @@ export function EcosystemWalletUsersChartCard(props: {
169169
toolTipLabelFormatter={(_v, item) => {
170170
if (Array.isArray(item)) {
171171
const time = item[0].payload.time as number;
172-
return formatDate(new Date(time), "MMM d, yyyy");
172+
return format(new Date(time), "MMM d, yyyy");
173173
}
174174
return undefined;
175175
}}

0 commit comments

Comments
 (0)