Dashboard — Analytics
Revenue, orders and conversion as small multiples, plus top products, a purchase funnel and a channel breakdown.
npx shadcn@latest add @hextor/store-dash-analyticsSource
"use client";
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import { DollarSign, Percent, Receipt, ShoppingBag } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
DashboardShell,
DEFAULT_DASHBOARD_NAV,
type DashboardBrand,
type DashboardNavItem,
} from "@/registry/hextor/components/store/dashboard/dashboard-shell";
import { PageHeader } from "@/registry/hextor/components/store/dashboard/page-header";
import { SavedViews } from "@/registry/hextor/components/store/dashboard/data-grid";
import { KpiTile } from "@/registry/hextor/components/store/dashboard/kpi-tile";
import { SalesChart, type SalesChartPoint } from "@/registry/hextor/components/store/dashboard/sales-chart";
import { TopProductsChart, type TopProductRow } from "@/registry/hextor/components/store/dashboard/top-products-chart";
import { FunnelChart, type FunnelStage } from "@/registry/hextor/components/store/dashboard/funnel-chart";
import { BreakdownTable, type BreakdownRow } from "@/registry/hextor/components/store/dashboard/breakdown-table";
import {
resolveAnalyticsLabels,
type AnalyticsLabelsOverrides,
} from "@/registry/hextor/components/store/dashboard/analytics-labels";
import { formatMoney, formatMoneyCompact, money, type CurrencyCode } from "@/registry/hextor/lib/store";
type RangeDays = "7" | "30" | "90";
/**
* Revenue, orders and conversion rate over time, top products, a
* traffic-to-purchase funnel and a channel breakdown — the five kinds of
* content Shopify's own Analytics tab leads with. `SalesChart` (owned
* elsewhere in this template, reused unmodified) is deliberately used for
* all three trend cards: three single-series line charts is the house
* standard's own answer to "I have several measures on different scales" —
* never a dual-axis chart, small multiples instead. `SalesChart` hardcodes
* its accent to `--axis-correctness`, so all three render in the same blue;
* a `color`/`accent` prop on that component would let this page distinguish
* them, but that file belongs to a sibling task and isn't touched here (see
* the task report).
*
* The date-range control scopes the three trend series by slicing the tail
* of whatever history the caller passes in — no fetching, no `new Date()`;
* unlike Discounts (whose whole status column is clock-relative) nothing
* on this page needs the current instant, so there is no `now` prop to
* thread through. Top products, the funnel and the channel breakdown are treated
* as already-aggregated-for-the-period summaries (the shape a real
* analytics backend would hand over pre-scoped) rather than re-sliced
* client-side.
*/
export interface AnalyticsKpis {
revenue: number;
orders: number;
/** 0-100, per day, averaged across the selected range. */
conversionRatePct: number;
}
export default function AnalyticsPage({
revenueSeries,
ordersSeries,
conversionSeries,
topProducts,
funnel,
breakdown,
currency = "THB",
nav = DEFAULT_DASHBOARD_NAV,
brand,
activeHref = "/dashboard/analytics",
actions,
locale = "en-US",
labels,
}: {
/** Daily points, oldest first, at least 90 long so every range preset has data. */
revenueSeries: SalesChartPoint[];
ordersSeries: SalesChartPoint[];
/** Daily conversion rate, 0-100. */
conversionSeries: SalesChartPoint[];
topProducts: TopProductRow[];
funnel: FunnelStage[];
breakdown: BreakdownRow[];
currency?: CurrencyCode;
nav?: DashboardNavItem[];
brand?: DashboardBrand;
activeHref?: string;
actions?: ReactNode;
locale?: string;
labels?: AnalyticsLabelsOverrides;
}) {
const copy = resolveAnalyticsLabels(labels);
const [range, setRange] = useState<RangeDays>("30");
const days = Number(range);
const revenueSlice = useMemo(() => revenueSeries.slice(-days), [revenueSeries, days]);
const ordersSlice = useMemo(() => ordersSeries.slice(-days), [ordersSeries, days]);
const conversionSlice = useMemo(() => conversionSeries.slice(-days), [conversionSeries, days]);
const revenueTotal = revenueSlice.reduce((sum, p) => sum + p.value, 0);
const ordersTotal = ordersSlice.reduce((sum, p) => sum + p.value, 0);
const conversionAvg = conversionSlice.length
? conversionSlice.reduce((sum, p) => sum + p.value, 0) / conversionSlice.length
: 0;
const averageOrderValue = ordersTotal > 0 ? Math.round(revenueTotal / ordersTotal) : 0;
const rangeViews = (["7", "30", "90"] as RangeDays[]).map((id) => ({
id,
label: id === "7" ? copy.dateRange.last7 : id === "30" ? copy.dateRange.last30 : copy.dateRange.last90,
}));
return (
<DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
<PageHeader title={copy.title} description={copy.description} />
{/* One filter row, above everything it scopes — never per-chart. */}
<div className="border-b bg-background px-2 py-1.5 md:px-4">
<SavedViews views={rangeViews} value={range} onChange={(id) => setRange(id as RangeDays)} />
</div>
<div className="space-y-6 p-4 md:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiTile
label={copy.kpis.revenue}
value={formatMoneyCompact(money(revenueTotal, currency), locale)}
icon={DollarSign}
/>
<KpiTile label={copy.kpis.orders} value={ordersTotal.toLocaleString(locale)} icon={ShoppingBag} />
<KpiTile label={copy.kpis.conversionRate} value={`${conversionAvg.toFixed(1)}%`} icon={Percent} />
<KpiTile
label={copy.kpis.averageOrderValue}
value={formatMoney(money(averageOrderValue, currency), locale)}
icon={Receipt}
/>
</div>
<Card>
<CardHeader>
<CardTitle>{copy.charts.revenue.title}</CardTitle>
<CardDescription>{copy.charts.revenue.description}</CardDescription>
</CardHeader>
<CardContent>
<SalesChart data={revenueSlice} formatValue={(v) => formatMoney(money(v, currency), locale)} />
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{copy.charts.orders.title}</CardTitle>
<CardDescription>{copy.charts.orders.description}</CardDescription>
</CardHeader>
<CardContent>
<SalesChart data={ordersSlice} formatValue={(v) => v.toLocaleString(locale)} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{copy.charts.conversion.title}</CardTitle>
<CardDescription>{copy.charts.conversion.description}</CardDescription>
</CardHeader>
<CardContent>
<SalesChart data={conversionSlice} formatValue={(v) => `${v.toFixed(1)}%`} />
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{copy.charts.topProducts.title}</CardTitle>
<CardDescription>{copy.charts.topProducts.description}</CardDescription>
</CardHeader>
<CardContent>
<TopProductsChart rows={topProducts} formatValue={(v) => formatMoney(money(v, currency), locale)} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{copy.charts.funnel.title}</CardTitle>
<CardDescription>{copy.charts.funnel.description}</CardDescription>
</CardHeader>
<CardContent>
<FunnelChart stages={funnel} formatValue={(v) => v.toLocaleString(locale)} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>{copy.breakdown.title}</CardTitle>
<CardDescription>{copy.breakdown.description}</CardDescription>
</CardHeader>
<CardContent className="px-0">
<BreakdownTable rows={breakdown} locale={locale} />
</CardContent>
</Card>
</div>
</DashboardShell>
);
}
Docs
Three separate charts, never a dual axis. Two measures on one pair of axes is the most common charting mistake there is: the crossing point is an artefact of the scales you picked, and readers take it as a finding.
The three share one hue on purpose. Their titles carry identity, and giving each its own colour would imply a relationship between revenue, orders and conversion that does not exist. The funnel uses an ordinal ramp of a single hue, validated monotone with adjacent lightness gaps above 0.06 in both modes.
Every chart ships a table twin. A tooltip is not access; it is a convenience for people who can already see and point.
Ships with sample data from @hextor/store-fixtures so it renders on install. The route file is the only place that import appears — swap it for your own query and the view is unchanged.