Skip to content

[Dashboard] Add detailed usage breakdown and billing preview #7290

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/dashboard/src/@/api/usage/billing-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import "server-only";

import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getAuthToken } from "../../../app/(app)/api/lib/getAuthToken";

type LineItem = {
quantity: number;
amountUsdCents: number;
unitAmountUsdCents: string;
description: string;
};

export type UsageCategory = {
category: string;
unitName: string;
lineItems: LineItem[];
};

type UsageApiResponse = {
result: UsageCategory[];
periodStart: string;
periodEnd: string;
planVersion: number;
};

export async function getBilledUsage(teamSlug: string) {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error("No auth token found");
}
const response = await fetch(
new URL(
`/v1/teams/${teamSlug}/billing/billed-usage`,
NEXT_PUBLIC_THIRDWEB_API_HOST,
),
{
next: {
// revalidate this query once per minute (does not need to be more granular than that)
revalidate: 60,
},
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!response.ok) {
// if the status is 404, the most likely reason is that the team is on a free plan
if (response.status === 404) {
return {
status: "error",
reason: "free_plan",
} as const;
}
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
} as const;
}
const data = (await response.json()) as UsageApiResponse;
return {
status: "success",
data,
} as const;
}
52 changes: 0 additions & 52 deletions apps/dashboard/src/@/api/usage/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,6 @@ import "server-only";
import { unstable_cache } from "next/cache";
import { ANALYTICS_SERVICE_URL } from "../../constants/server-envs";

export type RPCUsageDataItem = {
date: string;
usageType: "included" | "overage" | "rate-limit";
count: string;
};

export const fetchRPCUsage = unstable_cache(
async (params: {
teamId: string;
projectId?: string;
authToken: string;
from: string;
to: string;
period: "day" | "week" | "month" | "year" | "all";
}) => {
const analyticsEndpoint = ANALYTICS_SERVICE_URL;
const url = new URL(`${analyticsEndpoint}/v2/rpc/usage-types`);
url.searchParams.set("teamId", params.teamId);
if (params.projectId) {
url.searchParams.set("projectId", params.projectId);
}
url.searchParams.set("from", params.from);
url.searchParams.set("to", params.to);
url.searchParams.set("period", params.period);

const res = await fetch(url, {
headers: {
Authorization: `Bearer ${params.authToken}`,
},
});

if (!res.ok) {
const error = await res.text();
return {
ok: false as const,
error: error,
};
}

const resData = await res.json();

return {
ok: true as const,
data: resData.data as RPCUsageDataItem[],
};
},
["rpc-usage"],
{
revalidate: 60 * 60, // 1 hour
},
);

type Last24HoursRPCUsageApiResponse = {
peakRate: {
date: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { formatDate } from "date-fns";
import { format } from "date-fns";
import { useMemo } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";

Expand Down Expand Up @@ -94,7 +94,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
axisLine={false}
tickMargin={20}
tickFormatter={(value) =>
formatDate(
format(
new Date(value),
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
)
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { formatDate } from "date-fns";
import { format } from "date-fns";
import { useMemo } from "react";

type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
Expand Down Expand Up @@ -86,7 +86,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => formatDate(new Date(value), "MMM d")}
tickFormatter={(value) => format(new Date(value), "MMM d")}
/>
<ChartTooltip
content={
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Flex, SimpleGrid, useBreakpointValue } from "@chakra-ui/react";
import { formatDistance } from "date-fns/formatDistance";
import { formatDistance } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import type { ThirdwebClient } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
useTotalContractTransactionAnalytics,
useTotalContractUniqueWallets,
} from "data/analytics/hooks";
import { formatDate } from "date-fns";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import type { ThirdwebContract } from "thirdweb";

Expand Down Expand Up @@ -125,7 +125,7 @@ type ChartProps = {
function toolTipLabelFormatter(_v: string, item: unknown) {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(new Date(time), "MMM d, yyyy");
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
useContractTransactionAnalytics,
useContractUniqueWalletAnalytics,
} from "data/analytics/hooks";
import { differenceInCalendarDays, formatDate } from "date-fns";
import { differenceInCalendarDays, format } from "date-fns";
import { useTrack } from "hooks/analytics/useTrack";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
Expand Down Expand Up @@ -200,7 +200,7 @@ export function toolTipLabelFormatterWithPrecision(precision: "day" | "hour") {
return function toolTipLabelFormatter(_v: string, item: unknown) {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(
return format(
new Date(time),
precision === "day" ? "MMM d, yyyy" : "MMM d, yyyy hh:mm a",
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { differenceInCalendarDays, formatDate } from "date-fns";
import { differenceInCalendarDays, format } from "date-fns";
import { ArrowUpIcon, InfoIcon } from "lucide-react";
import { ArrowDownIcon } from "lucide-react";
import { useMemo, useState } from "react";
Expand Down Expand Up @@ -81,7 +81,7 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
return (_v: string, item: unknown) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(
return format(
new Date(time),
includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy",
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { format } from "date-fns/format";
import { format } from "date-fns";
import { resolveEns } from "lib/ens";
import { correctAndUniqueLicenses } from "lib/licenses";
import { getSocialProfiles } from "thirdweb/social";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { format } from "date-fns/format";
import { format } from "date-fns";
import { resolveEns } from "lib/ens";
import { correctAndUniqueLicenses } from "lib/licenses";
import { getSocialProfiles } from "thirdweb/social";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from "@/components/ui/table";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useMutation } from "@tanstack/react-query";
import { formatDate } from "date-fns";
import { format } from "date-fns";
import { MinusIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
Expand Down Expand Up @@ -106,7 +106,7 @@ export function LinkWalletUI(props: {
/>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(wallet.createdAt, "MMM d, yyyy")}
{format(wallet.createdAt, "MMM d, yyyy")}
</TableCell>
<TableCell>
<UnlinkButton
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { UsageCategory } from "@/api/usage/billing-preview";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";

interface UsageCategoryDetailsProps {
category: UsageCategory;
}

export function UsageCategoryDetails({ category }: UsageCategoryDetailsProps) {
const categoryTotalCents = category.lineItems.reduce(
(sum, item) => sum + item.amountUsdCents,
0,
);

// filter out any lines with 0 quantity
const filteredLineItems = category.lineItems.filter(
(item) => item.quantity > 0,
);

return (
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-lg">{category.category}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-transparent">
<TableRow>
<TableHead className="w-[45%] pl-6">Description</TableHead>
<TableHead className="text-right">Quantity</TableHead>
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="pr-6 text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLineItems.length > 0 ? (
filteredLineItems.map((item, index) => (
<TableRow
key={`${item.description}_${index}`}
className="hover:bg-accent"
>
<TableCell className="py-3 pl-6 font-medium">
{item.description}
</TableCell>
<TableCell className="py-3 text-right">
{item.quantity.toLocaleString()}
</TableCell>
<TableCell className="py-3 text-right">
{formatPrice(item.unitAmountUsdCents, {
isUnitPrice: true,
inCents: true,
})}
</TableCell>
<TableCell className="py-3 pr-6 text-right">
{formatPrice(
item.quantity *
Number.parseFloat(item.unitAmountUsdCents),
{ inCents: true },
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={4}
className="h-24 pl-6 text-center text-muted-foreground"
>
No usage during this period.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
{categoryTotalCents > 0 && (
<CardFooter className="flex justify-end p-4 pr-6 ">
<div className="font-semibold text-md">
Subtotal: {formatPrice(categoryTotalCents, { inCents: true })}
</div>
</CardFooter>
)}
</Card>
);
}

// Currency Formatting Helper
export function formatPrice(
value: number | string,
options?: { isUnitPrice?: boolean; inCents?: boolean },
) {
const { isUnitPrice = false, inCents = true } = options || {};
const numericValue =
typeof value === "string" ? Number.parseFloat(value) : value;

if (Number.isNaN(numericValue)) {
return "N/A";
}

const amountInDollars = inCents ? numericValue / 100 : numericValue;

return amountInDollars.toLocaleString("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: isUnitPrice ? 10 : 2, // Allow more precision for unit prices
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
import { DocLink } from "components/shared/DocLink";
import { formatDate } from "date-fns";
import { format } from "date-fns";
import { formatTickerNumber } from "lib/format-utils";
import { useMemo } from "react";
import type { EcosystemWalletStats } from "types/analytics";
Expand Down Expand Up @@ -169,7 +169,7 @@ export function EcosystemWalletUsersChartCard(props: {
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(new Date(time), "MMM d, yyyy");
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
Expand Down
Loading
Loading