HextorUI
block

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-analytics

Source

"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.

Dependencies

@hextor/store-dash-ui@hextor/store-fixtures