HextaUI

Sales Overview

The home analytics page of a store admin. Handles stores with no sales yet, today’s unfinished numbers and reports that fail or come back incomplete.

Open in new tab

Sales Overview is the page a merchant opens first: six KPI cards, a sales chart, a live view and three breakdowns, under a date range picker with a compare-to control. You pass loaders that take a query and return data, and the block owns loading, refreshing, errors and retries for each card on its own.

Every KPI card shows its value, a change chip and the period it’s compared with, like “vs Aug 23–Sep 21”. The chip colors by whether up is good for that metric, reads New when the previous period was zero instead of a meaningless ∞%, and shows a dash when there is nothing to compare. When the range changes, only the digits that changed roll; nothing counts up on first load.

Today is never a finished number. Its point on the chart and every sparkline is drawn dotted, the tooltip says “so far”, and the Today preset compares with yesterday at the same time instead of the whole day. Hovering any chart moves one crosshair across all six sparklines and the main chart at once, and the card captions show the hovered day’s values.

The chart picks hours for a single day, days up to two months and weeks beyond that. It can switch between sales, orders and average order value, and View as table shows the same numbers as an accessible table. An empty store shows zeros with a Share your store button, a failed report shows its error with Try again in its own card, and a metric your source can’t provide says so rather than showing a zero.

  1. Procurez-vous HextaUI Pro

    Un seul paiement débloque tous les blocs, y compris les prochains. Voir les tarifs.

  2. Ajouter le registre Pro à components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Ajouter votre token

    Créez un token sur votre page de compte et placez-le dans .env.local sous le nom HEXTAUI_PRO_TOKEN.

  4. Ajouter le block

    pnpm dlx shadcn@latest add @hextaui-pro/sales-overview

From Shopify analytics

Run one ShopifyQL query per range through the Admin API, grouped by the granularity the query asks for, and map each row to a SalesPoint. Sessions and returning customers come from the same query, so every card fills from one request.

"use client"

import { money } from "../price/money"
import type { SalesPoint, SalesQuery } from "@/components/blocks/sales-overview/sales"
import { SalesOverview } from "@/components/blocks/sales-overview/sales-overview"

type ShopifyqlResponse = {
  data: {
    shopifyqlQuery: {
      tableData: { columns: { name: string }[]; rows: Record<string, string>[] } | null
      parseErrors: string[]
    }
  }
}

const groupBy = { hour: "hour", day: "day", week: "week" } as const

async function shopifyql(query: string, signal: AbortSignal) {
  const response = await fetch("/api/shopify/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: `query Sales($q: String!) { shopifyqlQuery(query: $q) { tableData { columns { name } rows } parseErrors } }`,
      variables: { q: query },
    }),
    signal,
  })
  if (!response.ok) throw new Error("Shopify analytics didn’t respond. Try again in a minute.")
  const { data } = (await response.json()) as ShopifyqlResponse
  if (data.shopifyqlQuery.parseErrors.length) throw new Error(data.shopifyqlQuery.parseErrors[0])
  return data.shopifyqlQuery.tableData?.rows ?? []
}

function toPoints(rows: Record<string, string>[], currencyCode: string): SalesPoint[] {
  return rows.map((row) => ({
    date: row.hour ? row.hour.slice(0, 13) : (row.day ?? row.week).slice(0, 10),
    sales: money(row.total_sales, currencyCode),
    orders: Number(row.orders),
    sessions: row.sessions === undefined ? undefined : Number(row.sessions),
    customers: Number(row.customers),
    returningCustomers: Number(row.returning_customers),
  }))
}

async function loadSeries(query: SalesQuery, signal: AbortSignal) {
  const select = "SHOW total_sales, orders, sessions, customers, returning_customers"
  const range = (start: string, end: string) =>
    `FROM sales, sessions ${select} SINCE ${start} UNTIL ${end} TIMESERIES ${groupBy[query.granularity]}`
  const [current, previous] = await Promise.all([
    shopifyql(range(query.range.start, query.range.end), signal),
    query.comparison
      ? shopifyql(range(query.comparison.start, query.comparison.end), signal)
      : Promise.resolve(null),
  ])
  return {
    current: toPoints(current, "USD"),
    previous: previous ? toPoints(previous, "USD") : null,
  }
}

export function ShopifyHome({ shopTimeZone }: { shopTimeZone: string }) {
  return (
    <SalesOverview
      loaders={{ series: loadSeries }}
      currencyCode="USD"
      now={new Date().toISOString()}
      timeZone={shopTimeZone}
      title="Home"
    />
  )
}

From Stripe charges

List succeeded charges for the range and let bucketize sum them into hours, days or weeks in the shop’s time zone. Stripe has no sessions, so those cards show Not available instead of a fake zero.

"use client"

import { fromMinorUnits } from "../price/money"
import { bucketize, zonedNow, type SalesPoint, type SalesQuery } from "@/components/blocks/sales-overview/sales"
import { SalesOverview } from "@/components/blocks/sales-overview/sales-overview"

type StripeCharge = {
  id: string
  amount: number
  amount_refunded: number
  currency: string
  created: number
  status: "succeeded" | "pending" | "failed"
}

const timeZone = "America/New_York"

async function listCharges(start: string, end: string, signal: AbortSignal) {
  const params = new URLSearchParams({ start, end, timeZone })
  const response = await fetch(`/api/stripe/charges?${params}`, { signal })
  if (!response.ok) throw new Error("Couldn’t load charges from Stripe.")
  return (await response.json()) as StripeCharge[]
}

function chargesToRows(charges: StripeCharge[]): SalesPoint[] {
  return charges
    .filter((charge) => charge.status === "succeeded")
    .map((charge) => {
      const local = zonedNow(new Date(charge.created * 1000), timeZone)
      return {
        date: `${local.day}T${String(local.hour).padStart(2, "0")}`,
        sales: fromMinorUnits(charge.amount - charge.amount_refunded, charge.currency),
        orders: 1,
      }
    })
}

async function loadSeries(query: SalesQuery, signal: AbortSignal) {
  const current = await listCharges(query.range.start, query.range.end, signal)
  const previous = query.comparison
    ? await listCharges(query.comparison.start, query.comparison.end, signal)
    : null
  return {
    current: bucketize(chargesToRows(current), query.buckets, "USD"),
    previous: previous ? bucketize(chargesToRows(previous), query.comparisonBuckets, "USD") : null,
  }
}

export function StripeRevenue() {
  return (
    <SalesOverview
      loaders={{ series: loadSeries }}
      currencyCode="USD"
      now={new Date().toISOString()}
      timeZone={timeZone}
      metrics={["sales", "orders", "averageOrderValue"]}
      title="Revenue"
    />
  )
}

Your own SQL endpoint

Point the loaders at a route that runs one GROUP BY per range. The query carries the start, end, comparison range and granularity, and the abort signal cancels a request when the merchant picks another range.

"use client"

import type { SalesChannelRow, TopProductRow } from "@/components/blocks/sales-overview/breakdowns"
import type { SalesQuery, SalesSeries } from "@/components/blocks/sales-overview/sales"
import { SalesOverview } from "@/components/blocks/sales-overview/sales-overview"

function report<T>(name: string) {
  return async (query: SalesQuery, signal: AbortSignal): Promise<T> => {
    const response = await fetch(`/api/reports/${name}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        start: query.range.start,
        end: query.range.end,
        compareStart: query.comparison?.start,
        compareEnd: query.comparison?.end,
        granularity: query.granularity,
        timeZone: query.timeZone,
      }),
      signal,
    })
    if (!response.ok) {
      const body = (await response.json().catch(() => null)) as { error?: string } | null
      throw new Error(body?.error ?? "This report didn’t load.")
    }
    return response.json() as Promise<T>
  }
}

const loaders = {
  series: report<SalesSeries>("sales"),
  channels: report<SalesChannelRow[]>("channels"),
  products: report<TopProductRow[]>("top-products"),
}

export function Dashboard({ now, timeZone }: { now: string; timeZone: string }) {
  return (
    <SalesOverview
      loaders={loaders}
      currencyCode="EUR"
      locale="de-DE"
      now={now}
      timeZone={timeZone}
      title="Overview"
    />
  )
}

Anatomie

Les parties à composer, de l’extérieur vers l’intérieur.

PartieDescription
SalesOverviewThe whole page: header, range picker, KPI grid, chart, live view and breakdowns, with data loading built in.
DateRangePickerPresets, a range calendar for custom dates and the compare-to select.
KpiCardOne metric with an info tooltip, rolling value, change chip, comparison caption and sparkline.
DeltaChipThe change between two periods, colored by whether up is good.
SparklineA small line with the comparison dashed and today dotted, synced to the shared crosshair.
SalesChartThe main chart with a metric switcher, comparison line, crosshair tooltip and table view.
LiveViewVisitors right now, orders and sales today and recent orders sliding in.
ChannelBreakdown, TopProducts, DeviceBreakdownBreakdown cards with their own loading, empty and error states.
SalesHoverProviderShares the hovered point between charts. SalesOverview adds it for you.
presetRange, comparisonRange, granularityFor, buildQuery, bucketize, getDeltaPure helpers for ranges, comparison periods, buckets and percent change.

SalesOverview

Also takes div props. Dates are shop-local days like 2026-10-21.

PropTypePar défaut
loadersEach is (query, signal) => Promise. series returns { current, previous?, unavailable? } as SalesPoint[]. Leave out a breakdown loader to hide its card. Throw to show the message in that card.
{ series, channels?, products?, devices? }–
currencyCodeThe shop currency, like "USD".
string–
nowThe current instant as ISO. Decides today, the incomplete point and future hours.
string–
timeZoneThe shop’s IANA time zone. Days and hours are counted in it.
string"UTC"
value{ preset, range: { start, end }, compare }. Controlled range.
SalesRangeValue–
defaultValueThe starting range when uncontrolled.
SalesRangeValuelast 30 days vs previous period
onValueChangeCalled when the range or comparison changes.
(value: SalesRangeValue) => void–
titleThe page heading, like a greeting.
ReactNode–
descriptionA line under the heading.
ReactNode–
actionsExtra controls next to the range picker.
ReactNode–
metricsWhich KPI cards to show, in order.
SalesMetric[]all six
chartMetricsMetrics the chart can switch between.
SalesMetric[]["sales", "orders", "averageOrderValue"]
metricDefinitionsRename a metric, change its tooltip, or set polarity to "down" when a fall is good.
Partial<Record<SalesMetric, { label, description, polarity }>>–
liveData for the live view card. Leave out to hide it.
LiveViewProps | "loading" | null–
onShareStoreShows Share your store on an empty chart.
() => void–
localeLocale for numbers and dates.
Intl.LocalesArgument"en-US"
labelsWords for captions, card titles, the picker and the chart.
Partial<SalesOverviewLabels>–

DateRangePicker

Also takes div props.

PropTypePar défaut
todayToday in the shop’s time zone. Later days can’t be picked.
string–
valueControlled value.
SalesRangeValue–
defaultValueUncontrolled starting value.
SalesRangeValue–
onValueChangeCalled with the new preset, range and comparison.
(value: SalesRangeValue) => void–
presetstoday, yesterday, last_7_days, last_30_days, last_90_days, month_to_date and custom.
DateRangePreset[]all
compareOptionsOptions in the compare-to select.
CompareMode[]["previous_period", "previous_year", "none"]
timeZoneNamed in the picker so merchants know which day “today” is.
string–

KpiCard

Also takes div props.

PropTypePar défaut
labelThe metric name.
string–
descriptionThe definition shown in the info tooltip.
string–
valueThe value for the period.
number | null–
previousThe comparison value. Leave out when not comparing.
number | null–
formatHow the rolling value is formatted.
Intl.NumberFormatOptions–
polarityWhich direction is good. Sets the chip color.
"up" | "down" | "neutral""up"
comparisonLabelThe caption, like “vs Aug 23–Sep 21”.
string | null–
points{ label, value, state?, previous?, previousLabel? } for the sparkline and hover caption.
KpiPoint[]–
statusLoading shows a skeleton the same size.
"ready" | "loading" | "unavailable" | "error""ready"
messageWhy the metric is unavailable or failed.
string–

SalesChart

Also takes div props.

PropTypePar défaut
modelFrom buildSalesModel(query, series, currencyCode).
SalesModel | null–
statusLoading shows a skeleton, error shows the message with Try again.
"loading" | "ready" | "error""ready"
onRetryCalled by Try again.
() => void–
metricControlled metric. Also defaultMetric and onMetricChange.
SalesMetric–
viewControlled view. Also defaultView and onViewChange.
"chart" | "table"–
onShareStoreAction in the empty state.
() => void–

LiveView

Also takes div props.

PropTypePar défaut
visitorsVisitors right now. Changes roll gently.
number–
ordersTodayOrders so far today.
number–
salesTodaySales so far today.
Money–
recentOrders{ id, name, customer, total, items, at, location? }. toLiveOrder(order) maps an Order. New ids slide in at the top.
LiveOrder[]–
statusShows Reconnecting… when your socket drops.
"live" | "reconnecting""live"
nowUsed for “4 min ago”.
string–
PropTypePar défaut
dateThe bucket: 2026-10-21 for a day or week, 2026-10-21T14 for an hour.
string–
salesTotal sales in the bucket.
Money–
ordersOrders in the bucket.
number–
sessionsOptional. Without it, conversion rate and sessions show Not available.
number–
customers, returningCustomersOptional. Drive the returning customer rate.
number–
ToucheAction
←→With the chart focused, move the crosshair to the previous or next point. Every sparkline follows.
HomeEndJump to the first or latest point.
EscHide the crosshair.
↑↓Move between presets in the date range picker.
  • Each KPI card is a group named with its full reading, like “Total sales, $113,661.39, Up 5.5%, an improvement, vs Aug 23–Sep 21”. Sparklines are hidden from screen readers, and the change is never shown by color alone: an arrow and a word carry it.
  • The chart is focusable. Arrow keys move point by point and a polite live region reads the date, the value and the comparison. View as table shows the same data as a real table.
  • A polite announcement reads the new range and its total once data loads, failed cards use role="alert", and new live orders are announced as they arrive. Visitor counts aren’t announced, so screen readers aren’t flooded.
  • Numbers roll only when they change and jump straight to the new value with reduced motion. Nothing pulses, and the live dot is static.

Construit avec

Les composants HextaUI gratuits dont Sales Overview est constitué. Chacun s’installe séparément.

Code

11 fichiers, ajoutés à components/blocks/sales-overview.