
Client revenue dashboard with a 30-day, 90-day or 12-month toggle and four metric cards, each with a total, change badge and recharts line with tooltip. Requires recharts 3.8.0.
blocks/stats/revenue-dashboard-01/index.tsx
"use client"
import { useState } from "react"
import { TrendDownIcon, TrendUpIcon } from "@phosphor-icons/react"
import { Line, LineChart, XAxis } from "recharts"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
// The copy: change text here, not in the markup.
const COPY = {
title: "Revenue",
range: "Range",
period: (range: Range) => `${range.period}, against ${range.before}`,
signed: (up: boolean, percent: string) => `${up ? "+" : "−"}${percent}`,
change: (up: boolean, percent: string, range: Range) =>
`${up ? "Up" : "Down"} ${percent} on ${range.previous}`,
chart: (metric: string, range: Range) => `${metric}, ${range.period}`,
status: (range: Range) =>
`Showing ${range.label}: ${range.period}, against ${range.before}.`,
} as const
type Range = (typeof RANGES)[number]
type RangeValue = Range["value"]
const RANGES = [
{
value: "30d",
label: "30 days",
period: "Sep 27 – Oct 26",
before: "the 30 days before",
previous: "the previous 30 days",
},
{
value: "90d",
label: "90 days",
period: "Jul 29 – Oct 26",
before: "the 90 days before",
previous: "the previous 90 days",
},
{
value: "12m",
label: "12 months",
period: "Nov 1, 2025 – Oct 26, 2026",
before: "the 12 months before",
previous: "the previous 12 months",
},
] as const
type MetricKey = (typeof METRICS)[number]["key"]
type Measured = Exclude<MetricKey, "net">
const METRICS = [
{ key: "gross", label: "Gross revenue", money: true },
{ key: "net", label: "Net revenue", money: true },
{ key: "customers", label: "New customers", money: false },
{ key: "refunds", label: "Refunds", money: true },
] as const
// Net is what gross keeps after the 8% payment and platform fee.
const NET_SHARE = 0.92
// Daily figures, Jul 29 – Oct 26. The 30-day range reads the last 30 days.
const DAILY: Record<Measured, number[]> = {
gross: [
612, 518, 572, 441, 469, 607, 608, 604, 523, 573, 500, 513, 610, 628, 564,
567, 551, 453, 444, 542, 623, 608, 534, 579, 486, 526, 541, 584, 637, 555,
577, 486, 493, 560, 589, 553, 523, 500, 439, 503, 613, 550, 621, 625, 606,
494, 478, 561, 597, 608, 553, 536, 496, 439, 603, 579, 556, 639, 564, 447,
474, 612, 590, 652, 635, 596, 493, 509, 649, 613, 643, 622, 525, 502, 506,
652, 1247, 823, 745, 558, 486, 528, 599, 684, 677, 546, 574, 548, 529, 603,
],
customers: [
8, 6, 7, 4, 4, 6, 7, 7, 7, 7, 6, 7, 7, 8, 8, 7, 5, 5, 4, 5, 7, 6, 6, 6, 6,
6, 7, 7, 7, 5, 8, 6, 5, 6, 6, 8, 6, 6, 5, 7, 6, 5, 9, 9, 7, 5, 6, 5, 9, 8,
7, 8, 7, 6, 6, 7, 5, 8, 6, 4, 5, 8, 8, 8, 7, 6, 5, 5, 8, 7, 6, 9, 8, 7, 7,
8, 14, 11, 10, 7, 5, 7, 6, 6, 6, 8, 5, 6, 5, 6,
],
refunds: [
44, 53, 52, 22, 0, 0, 0, 44, 43, 38, 35, 17, 21, 0, 21, 0, 0, 0, 0, 26, 0,
0, 0, 0, 0, 21, 21, 44, 88, 0, 0, 0, 23, 0, 57, 0, 0, 28, 0, 18, 0, 47, 0,
28, 23, 47, 103, 0, 18, 56, 0, 0, 28, 28, 0, 41, 28, 28, 37, 0, 0, 18, 37,
37, 37, 0, 12, 49, 0, 37, 52, 37, 0, 31, 30, 0, 18, 0, 18, 24, 24, 0, 0, 42,
42, 15, 15, 0, 37, 0,
],
}
// Monthly figures, Nov 2025 – Oct 2026. October runs to the 26th.
const MONTHLY: Record<Measured, number[]> = {
gross: [
18201, 19902, 15300, 14098, 15799, 15941, 15882, 15308, 16791, 16986, 16600,
16092,
],
customers: [214, 236, 176, 162, 186, 192, 190, 180, 197, 192, 200, 185],
refunds: [560, 610, 720, 450, 470, 480, 640, 610, 789, 464, 707, 520],
}
// Totals for the span just before each range: the change compares to these.
const PREVIOUS: Record<RangeValue, Record<Measured, number>> = {
"30d": { gross: 16388, customers: 196, refunds: 638 },
"90d": { gross: 46794, customers: 552, refunds: 1905 },
"12m": { gross: 154330, customers: 1880, refunds: 6430 },
}
type Point = { label: string } & Record<MetricKey, number>
const DAY = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
})
const MONTH = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
timeZone: "UTC",
})
const CURRENCY = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})
const COUNT = new Intl.NumberFormat("en-US")
const dayLabel = (index: number) => DAY.format(Date.UTC(2026, 6, 29 + index))
const monthLabel = (index: number) => MONTH.format(Date.UTC(2025, 10 + index))
function toPoints(
series: Record<Measured, number[]>,
label: (index: number) => string,
from = 0
): Point[] {
return series.gross.slice(from).map((gross, offset) => {
const index = from + offset
return {
label: label(index),
gross,
net: Math.round(gross * NET_SHARE),
customers: series.customers[index],
refunds: series.refunds[index],
}
})
}
const POINTS: Record<RangeValue, Point[]> = {
"30d": toPoints(DAILY, dayLabel, 60),
"90d": toPoints(DAILY, dayLabel),
"12m": toPoints(MONTHLY, monthLabel),
}
// One ink for every line: the tile's label already names the series.
const CHART_CONFIG = Object.fromEntries(
METRICS.map((metric) => [
metric.key,
{ label: metric.label, color: "var(--chart-2)" },
])
) satisfies ChartConfig
const total = (points: Point[], key: MetricKey) =>
points.reduce((sum, point) => sum + point[key], 0)
const previousTotal = (range: RangeValue, key: MetricKey) =>
key === "net"
? Math.round(PREVIOUS[range].gross * NET_SHARE)
: PREVIOUS[range][key]
const format = (value: number, money: boolean) =>
money ? CURRENCY.format(value) : COUNT.format(value)
// Stock ChartContainer's label color misses recharts 3's label layer, so
// the tick paints its own.
function EdgeTick({
x = 0,
y = 0,
index = 0,
payload,
}: {
x?: number
y?: number
index?: number
payload?: { value?: string }
}) {
return (
<text
x={x}
y={y}
dy="0.71em"
textAnchor={index === 0 ? "start" : "end"}
fill="var(--muted-foreground)"
>
{payload?.value}
</text>
)
}
export default function RevenueDashboard01() {
const [rangeValue, setRangeValue] = useState<RangeValue>("30d")
const [status, setStatus] = useState("")
const range =
RANGES.find((option) => option.value === rangeValue) ?? RANGES[0]
const points = POINTS[range.value]
// Only the first and the last point get a date under the line.
const ticks = [points[0].label, points[points.length - 1].label]
return (
<section className="@container/revenue mx-auto flex w-full max-w-4xl flex-col gap-6 px-4 sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
<div className="grid gap-1">
<h2 className="text-xl font-medium tracking-tight">{COPY.title}</h2>
<p className="text-sm text-muted-foreground">{COPY.period(range)}</p>
</div>
<ToggleGroup
aria-label={COPY.range}
value={[range.value]}
variant="outline"
size="sm"
spacing={0}
className="max-w-full flex-wrap"
onValueChange={(values) => {
const next = RANGES.find((option) => option.value === values[0])
if (!next) return
setRangeValue(next.value)
setStatus(COPY.status(next))
}}
>
{RANGES.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
className="text-muted-foreground aria-pressed:text-foreground"
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<ul className="grid gap-4 @min-[40rem]/revenue:grid-cols-2">
{METRICS.map((metric) => {
const value = total(points, metric.key)
const before = previousTotal(range.value, metric.key)
const change = ((value - before) / before) * 100
const up = change >= 0
const percent = `${Math.abs(change).toFixed(1)}%`
return (
<li key={metric.key} className="grid">
<Card size="sm">
<CardHeader>
<h3 className="text-sm text-muted-foreground">
{metric.label}
</h3>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="text-2xl font-medium tabular-nums">
{format(value, metric.money)}
</p>
<Badge variant="outline" className="tabular-nums">
{up ? (
<TrendUpIcon
weight="light"
aria-hidden="true"
data-icon="inline-start"
/>
) : (
<TrendDownIcon
weight="light"
aria-hidden="true"
data-icon="inline-start"
/>
)}
<span aria-hidden="true">{COPY.signed(up, percent)}</span>
<span className="sr-only">
{COPY.change(up, percent, range)}
</span>
</Badge>
</div>
</CardHeader>
<CardContent>
<ChartContainer
config={CHART_CONFIG}
className="aspect-auto h-24 w-full"
>
<LineChart
data={points}
aria-label={COPY.chart(metric.label, range)}
margin={{ top: 4, right: 2, bottom: 0, left: 2 }}
>
{/* The first date starts on the card's text edge and the
last ends on it, so the line can run the full width. */}
<XAxis
dataKey="label"
ticks={ticks}
interval={0}
tick={<EdgeTick />}
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(amount) => (
<span className="flex w-full items-center justify-between gap-4">
<span className="text-muted-foreground">
{metric.label}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{format(Number(amount), metric.money)}
</span>
</span>
)}
/>
}
/>
<Line
dataKey={metric.key}
type="monotone"
stroke={`var(--color-${metric.key})`}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
</li>
)
})}
</ul>
<p role="status" className="sr-only">
{status}
</p>
</section>
)
}