+
{usage || usage === 0 ? (
-
+
{nFormatter(usage, { full: true })}
) : (
-
- )}
-
- {usageLimit && usageLimit >= 1000000000 ? (
-
- ) : (
-
- {nFormatter(usageLimit, { full: true })}
-
+
)}
+
/
+
+ {usageLimit && usageLimit >= 1000000000
+ ? "∞"
+ : nFormatter(usageLimit, { full: true })}
+
) : (
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/usage-chart.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/usage-chart.tsx
new file mode 100644
index 0000000000..1a2a41b692
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/usage-chart.tsx
@@ -0,0 +1,137 @@
+import useUsage from "@/lib/swr/use-usage";
+import { Bars } from "@/ui/charts/bars";
+import TimeSeriesChart from "@/ui/charts/time-series-chart";
+import XAxis from "@/ui/charts/x-axis";
+import YAxis from "@/ui/charts/y-axis";
+import { EmptyState } from "@dub/blocks/src/empty-state";
+import { LoadingSpinner } from "@dub/ui";
+import { CircleDollar, CursorRays, Hyperlink } from "@dub/ui/src/icons";
+import { formatDate, nFormatter } from "@dub/utils";
+import { LinearGradient } from "@visx/gradient";
+import { useSearchParams } from "next/navigation";
+import { ComponentProps, Fragment, useMemo } from "react";
+
+const RESOURCES = ["links", "events", "revenue"] as const;
+const resourceEmptyStates: Record<
+ (typeof RESOURCES)[number],
+ ComponentProps
+> = {
+ links: {
+ icon: Hyperlink,
+ title: "Links Created",
+ description:
+ "No short links have been created in the current billing cycle.",
+ },
+ events: {
+ icon: CursorRays,
+ title: "Events Tracked",
+ description: "No events have been tracked in the current billing cycle.",
+ },
+ revenue: {
+ icon: CircleDollar,
+ title: "Revenue Tracked",
+ description: "No revenue has been tracked in the current billing cycle.",
+ },
+};
+
+export function UsageChart() {
+ const searchParams = useSearchParams();
+ const resource =
+ RESOURCES.find((r) => r === searchParams.get("tab")) ?? "links";
+
+ const { usage, loading } = useUsage({ resource });
+
+ const chartData = useMemo(
+ () =>
+ usage?.map(({ date, value }) => ({
+ date: new Date(date),
+ values: { usage: resource === "revenue" ? value / 100 : value },
+ })),
+ [usage, resource],
+ );
+
+ const allZeroes = useMemo(
+ () => chartData?.every(({ values }) => values.usage === 0),
+ [chartData],
+ );
+
+ return (
+
+ {chartData && chartData.length > 0 ? (
+ !allZeroes ? (
+
d.values.usage,
+ colorClassName: "text-violet-500",
+ isActive: true,
+ },
+ ]}
+ tooltipClassName="p-0"
+ tooltipContent={(d) => {
+ return (
+ <>
+
+ {formatDate(d.date)}
+
+
+
+
+
+ {resource === "revenue" && "$"}
+ {nFormatter(d.values.usage, { full: true })}
+
+
+
+ >
+ );
+ }}
+ >
+
+
+
+
+
+
+
+ `$${nFormatter(v)}` : nFormatter
+ }
+ />
+
+ ) : (
+
+
+
+ )
+ ) : (
+
+ {loading ?
:
Failed to load usage data
}
+
+ )}
+
+ );
+}
diff --git a/apps/web/lib/swr/use-usage.ts b/apps/web/lib/swr/use-usage.ts
new file mode 100644
index 0000000000..02b7b504fb
--- /dev/null
+++ b/apps/web/lib/swr/use-usage.ts
@@ -0,0 +1,30 @@
+import { fetcher } from "@dub/utils";
+import useSWR from "swr";
+import { UsageResponse } from "../types";
+import useWorkspace from "./use-workspace";
+
+export default function useUsage({
+ resource,
+}: {
+ resource: "links" | "events" | "revenue";
+}) {
+ const { id } = useWorkspace();
+
+ const {
+ data: usage,
+ error,
+ isValidating,
+ } = useSWR(
+ id && `/api/workspaces/${id}/billing/usage?resource=${resource}`,
+ fetcher,
+ {
+ dedupingInterval: 60000,
+ },
+ );
+
+ return {
+ usage,
+ loading: !usage && !error,
+ isValidating,
+ };
+}
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index 2ea884b144..91149e924a 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -10,6 +10,7 @@ import { createLinkBodySchema } from "./zod/schemas/links";
import { createOAuthAppSchema, oAuthAppSchema } from "./zod/schemas/oauth";
import { trackSaleResponseSchema } from "./zod/schemas/sales";
import { tokenSchema } from "./zod/schemas/token";
+import { usageResponse } from "./zod/schemas/usage";
import {
createWebhookSchema,
webhookEventSchemaTB,
@@ -267,3 +268,5 @@ export type TrackCustomerResponse = z.infer;
export type TrackLeadResponse = z.infer;
export type TrackSaleResponse = z.infer;
+
+export type UsageResponse = z.infer;
diff --git a/apps/web/lib/zod/schemas/usage.ts b/apps/web/lib/zod/schemas/usage.ts
new file mode 100644
index 0000000000..e8c8074f17
--- /dev/null
+++ b/apps/web/lib/zod/schemas/usage.ts
@@ -0,0 +1,10 @@
+import z from "@/lib/zod";
+
+export const usageQuerySchema = z.object({
+ resource: z.enum(["links", "events", "revenue"]),
+});
+
+export const usageResponse = z.object({
+ date: z.string(),
+ value: z.number(),
+});
diff --git a/apps/web/package.json b/apps/web/package.json
index 2f699df886..c28b603c0b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -41,6 +41,7 @@
"@vercel/functions": "^1.4.2",
"@vercel/og": "^0.6.3",
"@visx/axis": "^2.14.0",
+ "@visx/clip-path": "^3.3.0",
"@visx/curve": "^3.3.0",
"@visx/event": "^2.6.0",
"@visx/geo": "^2.10.0",
diff --git a/apps/web/ui/charts/areas.tsx b/apps/web/ui/charts/areas.tsx
index ece6f5ed97..b468bea1d2 100644
--- a/apps/web/ui/charts/areas.tsx
+++ b/apps/web/ui/charts/areas.tsx
@@ -13,6 +13,10 @@ export default function Areas({
}) {
const { data, series, margin, xScale, yScale, startDate, endDate } =
useChartContext();
+
+ if (!("ticks" in xScale))
+ throw new Error("Areas require a time scale (type=area)");
+
const { tooltipData } = useChartTooltipContext();
// Data with all values set to zero to animate from
diff --git a/apps/web/ui/charts/bars.tsx b/apps/web/ui/charts/bars.tsx
new file mode 100644
index 0000000000..6af8d7572b
--- /dev/null
+++ b/apps/web/ui/charts/bars.tsx
@@ -0,0 +1,108 @@
+import { cn } from "@dub/utils";
+import { RectClipPath } from "@visx/clip-path";
+import { LinearGradient } from "@visx/gradient";
+import { Group } from "@visx/group";
+import { BarRounded } from "@visx/shape";
+import { AnimatePresence, motion } from "framer-motion";
+import { useId } from "react";
+import { useChartContext } from "./chart-context";
+
+export function Bars({
+ seriesStyles,
+}: {
+ seriesStyles?: {
+ id: string;
+ gradientClassName?: string;
+ barClassName?: string;
+ barFill?: string;
+ }[];
+}) {
+ const clipPathId = useId();
+ const {
+ data,
+ series,
+ margin,
+ xScale,
+ yScale,
+ width,
+ height,
+ startDate,
+ endDate,
+ } = useChartContext();
+
+ if (!("bandwidth" in xScale))
+ throw new Error("Bars require a band scale (type=bar)");
+
+ return (
+
+
+
+ {series
+ .filter(({ isActive }) => isActive)
+ .map((s) => {
+ const styles = seriesStyles?.find(({ id }) => id === s.id);
+ return (
+ // Prevent ugly x-scale animations when start/end dates change with unique key
+
+ {/* Bar gradient */}
+
+
+ {/* Bars */}
+
+ {data.map((d) => {
+ const barWidth = xScale.bandwidth();
+ const x = xScale(d.date) ?? 0;
+ const y = yScale(s.valueAccessor(d) ?? 0);
+ const barHeight = height - y;
+ const radius = Math.min(barWidth, barHeight) / 2;
+ return barHeight > 0 ? (
+
+ ) : null;
+ })}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/ui/charts/time-series-chart.tsx b/apps/web/ui/charts/time-series-chart.tsx
index d157bff073..a9a625215b 100644
--- a/apps/web/ui/charts/time-series-chart.tsx
+++ b/apps/web/ui/charts/time-series-chart.tsx
@@ -1,7 +1,7 @@
import { cn } from "@dub/utils";
import { Group } from "@visx/group";
import { ParentSize } from "@visx/responsive";
-import { scaleLinear, scaleUtc } from "@visx/scale";
+import { scaleBand, scaleLinear, scaleUtc } from "@visx/scale";
import { Bar, Circle, Line } from "@visx/shape";
import { PropsWithChildren, useMemo, useState } from "react";
import { ChartContext, ChartTooltipContext } from "./chart-context";
@@ -32,6 +32,7 @@ export default function TimeSeriesChart(
}
function TimeSeriesChartInner({
+ type = "area",
width: outerWidth,
height: outerHeight,
children,
@@ -45,10 +46,7 @@ function TimeSeriesChartInner({
bottom: 32,
left: 4,
},
- padding = {
- top: 0.1,
- bottom: 0.1,
- },
+ padding: paddingProp,
}: {
width: number;
height: number;
@@ -60,6 +58,11 @@ function TimeSeriesChartInner({
left: marginProp.left + (leftAxisMargin ?? 0),
};
+ const padding = paddingProp ?? {
+ top: 0.1,
+ bottom: type === "area" ? 0.1 : 0,
+ };
+
const width = outerWidth - margin.left - margin.right;
const height = outerHeight - margin.top - margin.bottom;
@@ -81,7 +84,8 @@ function TimeSeriesChartInner({
.filter((v): v is number => v != null);
return {
- minY: Math.min(...values),
+ // Start at 0 for bar charts
+ minY: type === "area" ? Math.min(...values) : Math.min(0, ...values),
maxY: Math.max(...values),
};
}, [data, series]);
@@ -98,14 +102,23 @@ function TimeSeriesChartInner({
nice: true,
clamp: true,
}),
- xScale: scaleUtc({
- domain: [startDate, endDate],
- range: [0, width],
- }),
+ xScale:
+ type === "area"
+ ? scaleUtc({
+ domain: [startDate, endDate],
+ range: [0, width],
+ })
+ : scaleBand({
+ domain: data.map(({ date }) => date),
+ range: [0, width],
+ padding: Math.min(0.75, (width / data.length) * 0.02),
+ align: 0.5,
+ }),
};
- }, [startDate, endDate, minY, maxY, height, width]);
+ }, [startDate, endDate, minY, maxY, height, width, data.length, type]);
const chartContext: ChartContextType = {
+ type,
width,
height,
data,
@@ -146,31 +159,46 @@ function TimeSeriesChartInner({
{children}
{/* Tooltip hover line + circle */}
- {tooltipData && (
- <>
-
-
- {series
- .filter(({ isActive }) => isActive)
- .map((s) => (
-
- ))}
- >
- )}
+ {tooltipData &&
+ ("bandwidth" in xScale ? (
+ <>
+
+ >
+ ) : (
+ <>
+
+
+ {series
+ .filter(({ isActive }) => isActive)
+ .map((s) => (
+
+ ))}
+ >
+ ))}
{/* Tooltip hover region */}
({
key={tooltipData.date.toString()}
left={(tooltipLeft ?? 0) + margin.left}
top={(tooltipTop ?? 0) + margin.top}
- offsetLeft={8}
+ offsetLeft={"bandwidth" in xScale ? xScale.bandwidth() + 8 : 8}
offsetTop={12}
className="absolute"
unstyled={true}
diff --git a/apps/web/ui/charts/types.ts b/apps/web/ui/charts/types.ts
index 23c5e7de4e..c5ea5c8a8a 100644
--- a/apps/web/ui/charts/types.ts
+++ b/apps/web/ui/charts/types.ts
@@ -30,6 +30,7 @@ type ChartRequiredProps = {
};
type ChartOptionalProps = {
+ type?: "area" | "bar";
tooltipContent?: (datum: TimeSeriesDatum) => ReactElement | string;
tooltipClassName?: string;
@@ -61,7 +62,9 @@ export type ChartContext = Required> & {
height: number;
startDate: Date;
endDate: Date;
- xScale: ScaleTypeToD3Scale["utc"];
+ xScale:
+ | ScaleTypeToD3Scale["utc"]
+ | ScaleTypeToD3Scale["band"];
yScale: ScaleTypeToD3Scale["linear"];
minY: number;
maxY: number;
diff --git a/apps/web/ui/charts/useTooltip.ts b/apps/web/ui/charts/useTooltip.ts
index 2cc6a39613..277639a532 100644
--- a/apps/web/ui/charts/useTooltip.ts
+++ b/apps/web/ui/charts/useTooltip.ts
@@ -49,7 +49,17 @@ export function useTooltip({
) => {
const lp = localPoint(event) || { x: 0 };
const x = lp.x - margin.left;
- const x0 = xScale.invert(x);
+ const x0 =
+ "invert" in xScale
+ ? xScale.invert(x)
+ : (xScale.domain()[
+ Math.round((x - xScale.step() * 0.75) / xScale.step())
+ ] as Date | undefined);
+
+ if (x0 === undefined) {
+ visxTooltip.hideTooltip();
+ return;
+ }
const index = bisectDate(data, x0, 1);
const d0 = data[index - 1];
const d1 = data[index];
diff --git a/apps/web/ui/charts/x-axis.tsx b/apps/web/ui/charts/x-axis.tsx
index 71df0e333f..7a634a992e 100644
--- a/apps/web/ui/charts/x-axis.tsx
+++ b/apps/web/ui/charts/x-axis.tsx
@@ -16,6 +16,11 @@ export type XAxisProps = {
*/
showGridLines?: boolean;
+ /**
+ * Whether to highlight the latest tick label when no other area is hovered
+ */
+ highlightLast?: boolean;
+
/**
* Custom formatting function for tick labels
*/
@@ -25,6 +30,7 @@ export type XAxisProps = {
export default function XAxis({
maxTicks: maxTicksProp,
showGridLines = false,
+ highlightLast = true,
tickFormat = (date) =>
date.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
}: XAxisProps) {
@@ -67,7 +73,11 @@ export default function XAxis({
textAnchor:
idx === 0 ? "start" : idx === length - 1 ? "end" : "middle",
fontSize: 12,
- fill: (tooltipData ? tooltipData.date === date : idx === length - 1)
+ fill: (
+ tooltipData
+ ? tooltipData.date === date
+ : highlightLast && idx === length - 1
+ )
? "#000"
: "#00000066",
})}
diff --git a/packages/blocks/src/empty-state.tsx b/packages/blocks/src/empty-state.tsx
index 60a04f6a67..80b1203ac4 100644
--- a/packages/blocks/src/empty-state.tsx
+++ b/packages/blocks/src/empty-state.tsx
@@ -21,7 +21,7 @@ export function EmptyState({
{title}
{description && (
-
+
{description}{" "}
{learnMore && (
+ Timeseries data
+
+
+TOKEN "v1_usage_endpoint_read_1647" READ
+
+NODE day_intervals
+SQL >
+
+ %
+ WITH
+ toStartOfDay(
+ toDateTime64({{ DateTime64(start, '2024-09-03 00:00:00.000') }}, 3),
+ {{ String(timezone, 'UTC') }}
+ ) AS start,
+ toStartOfDay(
+ toDateTime64({{ DateTime64(end, '2024-10-03 00:00:00.000') }}, 3),
+ {{ String(timezone, 'UTC') }}
+ ) AS
+ end
+ SELECT
+ arrayJoin(
+ arrayMap(
+ x -> toDateTime64(toStartOfDay(toDateTime64(x, 3), {{ String(timezone, 'UTC') }}), 3),
+ range(toUInt32(start + 86400), toUInt32(end + 86400),
+ 86400
+ )
+ )
+ ) as interval
+
+
+
+NODE workspace_links
+SQL >
+
+ %
+ SELECT link_id
+ from dub_links_metadata_latest FINAL
+ WHERE
+ workspace_id
+ = {{
+ String(
+ workspaceId,
+ 'ws_clrei1gld0002vs9mzn93p8ik',
+ description="The ID of the workspace",
+ required=True,
+ )
+ }}
+ AND deleted = 0
+
+
+
+NODE usage_clicks_data
+SQL >
+
+ %
+ SELECT
+ toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3) AS interval,
+ uniq(*) as clicks
+ FROM
+ dub_click_events_mv
+ PREWHERE link_id in (SELECT link_id from workspace_links)
+ WHERE
+ timestamp >= {{ DateTime(start, '2024-09-03 00:00:00') }}
+ AND timestamp < {{ DateTime(end, '2024-10-03 00:00:00') }}
+ GROUP BY interval
+ ORDER BY interval
+
+
+
+NODE usage_leads_data
+SQL >
+
+ %
+ SELECT
+ toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3) AS interval,
+ uniq(*) as leads
+ FROM
+ dub_lead_events_mv
+ PREWHERE link_id in (SELECT link_id from workspace_links)
+ WHERE
+ timestamp >= {{ DateTime(start, '2024-09-03 00:00:00') }}
+ AND timestamp < {{ DateTime(end, '2024-10-03 00:00:00') }}
+ GROUP BY interval
+ ORDER BY interval
+
+
+
+NODE usage_events
+SQL >
+
+ SELECT
+ formatDateTime(di.interval, '%FT%T.000%z') as date, clicks, leads, (clicks + leads) as value
+ FROM day_intervals as di
+ LEFT JOIN (SELECT * FROM usage_clicks_data) AS uc ON di.interval = uc.interval
+ LEFT JOIN (SELECT * FROM usage_leads_data) AS ul ON di.interval = ul.interval
+
+
+
+NODE usage_links_data
+DESCRIPTION >
+ undefined
+
+SQL >
+
+ %
+ SELECT
+ toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3) AS interval,
+ uniq(*) as links
+ FROM dub_links_metadata_latest FINAL
+ WHERE
+ workspace_id
+ = {{
+ String(
+ workspaceId,
+ 'ws_clrei1gld0002vs9mzn93p8ik',
+ description="The ID of the workspace",
+ required=True,
+ )
+ }}
+ AND deleted = 0
+ AND created_at >= {{ DateTime(start, '2024-09-03 00:00:00') }}
+ AND created_at < {{ DateTime(end, '2024-10-03 00:00:00') }}
+ GROUP BY interval
+ ORDER BY interval
+
+
+
+NODE usage_links
+SQL >
+
+ %
+ SELECT formatDateTime(interval, '%FT%T.000%z') as date, links as value
+ FROM day_intervals
+ LEFT JOIN usage_links_data USING interval
+
+
+NODE usage_revenue_data
+DESCRIPTION >
+ undefined
+
+SQL >
+
+ %
+ SELECT
+ toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3) AS interval,
+ sum(amount) as revenue
+ FROM
+ dub_sale_events_mv
+ PREWHERE link_id in (SELECT link_id from workspace_links)
+ WHERE
+ timestamp >= {{ DateTime(start, '2024-09-03 00:00:00') }}
+ AND timestamp < {{ DateTime(end, '2024-10-03 00:00:00') }}
+ GROUP BY interval
+ ORDER BY interval
+
+NODE usage_revenue
+SQL >
+
+ %
+ SELECT formatDateTime(interval, '%FT%T.000%z') as date, revenue as value
+ FROM day_intervals
+ LEFT JOIN usage_revenue_data USING interval
+
+NODE endpoint
+SQL >
+
+ %
+ SELECT *
+ FROM
+ {% if resource == 'events' %} usage_events
+ {% elif resource == 'revenue' %} usage_revenue
+ {% else %} usage_links
+ {% end %}
+
+
diff --git a/packages/ui/src/icons/nucleo/circle-dollar.tsx b/packages/ui/src/icons/nucleo/circle-dollar.tsx
new file mode 100644
index 0000000000..2ff132cbfa
--- /dev/null
+++ b/packages/ui/src/icons/nucleo/circle-dollar.tsx
@@ -0,0 +1,56 @@
+import { SVGProps } from "react";
+
+export function CircleDollar(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts
index ce505d8d28..ae0fcfc857 100644
--- a/packages/ui/src/icons/nucleo/index.ts
+++ b/packages/ui/src/icons/nucleo/index.ts
@@ -20,6 +20,7 @@ export * from "./check2";
export * from "./checkbox-checked-fill";
export * from "./checkbox-unchecked";
export * from "./circle-check";
+export * from "./circle-dollar";
export * from "./circle-dotted";
export * from "./circle-half-dotted-clock";
export * from "./circle-info";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3c2a154ab5..e06aafddf9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -119,6 +119,9 @@ importers:
'@visx/axis':
specifier: ^2.14.0
version: 2.14.0(react@18.2.0)
+ '@visx/clip-path':
+ specifier: ^3.3.0
+ version: 3.3.0(react@18.2.0)
'@visx/curve':
specifier: ^3.3.0
version: 3.3.0
@@ -10028,6 +10031,16 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /@visx/clip-path@3.3.0(react@18.2.0):
+ resolution: {integrity: sha512-uMuI2M05qZTgUdTSHJGg4VDr2gytGmGyuaC89iByHqNaeMHkrJqQi/cOFAZi4D0dn75p7lVirJijEgDgSpcrMQ==}
+ peerDependencies:
+ react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0
+ dependencies:
+ '@types/react': 18.2.48
+ prop-types: 15.8.1
+ react: 18.2.0
+ dev: false
+
/@visx/curve@2.1.0:
resolution: {integrity: sha512-9b6JOnx91gmOQiSPhUOxdsvcnW88fgqfTPKoVgQxidMsD/I3wksixtwo8TR/vtEz2aHzzsEEhlv1qK7Y3yaSDw==}
dependencies: