HextaUI

Commerce Dashboard

A complete store admin built from the HextaUI commerce blocks. Handles refunds, fulfillment and stock edits that update every page at once.

Open in new tab

Commerce Dashboard is a whole store admin for the Halden home goods shop, made from the other commerce blocks. Home and Analytics are Sales Overview, Orders is Orders Table, an order opens in Order Detail with the Refund sheet, Products opens Product Editor, Inventory is Inventory Table, and Discounts opens Discount Builder. The dashboard adds only the glue: a sidebar, a top bar with search and notifications, routing, one shared store, and small Products, Customers and Discounts lists.

Everything reads from one store, so a change in one place shows up everywhere. Refund an item and the order’s payment pill, its timeline, the customer’s total spent and the stock you chose to restock all change together. Fulfill an order and its pill in the orders table flips, the unfulfilled count in the sidebar drops and committed stock goes down. Rename a product and the list, the inventory table and the search palette pick it up.

Routing lives in the block. Pages are hash URLs like #/orders/order-1042, so Back and Forward work and links open in a new tab, and you can swap in Next.js routes through the router prop. Leaving a page with unsaved product, stock or discount edits is blocked with a message, including with the browser’s Back button. Pages cross-fade in 150 ms, and not at all with reduced motion.

The sidebar follows the new Shopify admin. The store switcher, search and a notifications bell sit at the top. Orders, Products, Customers and Analytics open their sub-pages with a separate chevron, the current section opens on its own, and your open sections are remembered. Sales channels and pinned apps sit below, with an amber dot when a channel fails to sync. The footer holds one card at a time (a setup checklist first, then an announcement you can dismiss), Help & support, Settings and your account menu with theme and language. ⌘B or the rail collapses it to icons, where sections open as flyouts and counts become dots. On a phone it's a sheet that closes when you pick a page, and only the page content scrolls.

Press ⌘K or Ctrl+K to jump to a page, an order by number or customer, a product, or a discount code, ⌘1 to ⌘9 to switch stores, and ? for the shortcuts list. Pages that don't have their own block yet, like Drafts or Purchase orders, show a short empty state with what they're for, and Collections and Segments list your real data.

  1. HextaUI Pro holen

    Eine einmalige Zahlung schaltet jeden Block frei, auch künftige. Preise ansehen.

  2. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  4. Den Block hinzufügen

    pnpm dlx shadcn@latest add @hextaui-pro/commerce-dashboard

Your own data and API

Load orders, products and discounts once, put them in a store with createCommerceStore, and pass api functions that call your server. Each one runs before the store changes, and anything it throws shows where the action happened.

"use client"

import * as React from "react"

import type { SalesOverviewLoaders } from "../sales-overview/sales-overview"
import type { CommerceData } from "@/components/blocks/commerce-dashboard/commerce"
import { CommerceDashboard, type CommerceShop } from "@/components/blocks/commerce-dashboard/commerce-dashboard"
import { createCommerceStore, type CommerceApi } from "@/components/blocks/commerce-dashboard/store"

async function request<Result>(url: string, init?: RequestInit): Promise<Result> {
  const response = await fetch(url, {
    ...init,
    headers: { "Content-Type": "application/json", ...init?.headers },
  })
  if (!response.ok) {
    const body = (await response.json().catch(() => null)) as { message?: string } | null
    throw new Error(body?.message ?? `The server answered ${response.status}. Try again.`)
  }
  return (await response.json()) as Result
}

const send = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) })

const api: Partial<CommerceApi> = {
  fulfillOrder: (order, draft) => request(`/api/orders/${order.id}/fulfillments`, send("POST", draft)),
  refundOrder: (order, refund) => request(`/api/orders/${order.id}/refunds`, send("POST", refund)),
  capturePayment: (order) => request(`/api/orders/${order.id}/capture`, send("POST", {})),
  cancelOrder: (order, options) => request(`/api/orders/${order.id}/cancel`, send("POST", options)),
  updateOrder: (order, patch) => request(`/api/orders/${order.id}`, send("PATCH", patch)),
  bulkOrders: (action, selection) => request("/api/orders/bulk", send("POST", { action, ids: selection.ids })),
  saveProduct: (product) => request(`/api/products/${product.id}`, send("PUT", product)),
  saveInventory: (locationId, adjustments) =>
    request(`/api/locations/${locationId}/adjustments`, send("POST", { adjustments })),
  saveDiscount: (discount) => request(`/api/discounts/${discount.id ?? "new"}`, send("PUT", discount)),
  checkDiscountCode: (code, { signal }) => request(`/api/discounts/check?code=${encodeURIComponent(code)}`, { signal }),
}

const salesLoaders: SalesOverviewLoaders = {
  series: (query, signal) =>
    request(`/api/analytics/sales?${new URLSearchParams({ start: query.range.start, end: query.range.end })}`, { signal }),
}

export function Admin({ shop }: { shop: CommerceShop }) {
  const [store, setStore] = React.useState<ReturnType<typeof createCommerceStore> | null>(null)
  const [error, setError] = React.useState<string | null>(null)

  React.useEffect(() => {
    let active = true
    request<CommerceData>("/api/admin/bootstrap")
      .then((data) => active && setStore(createCommerceStore(data)))
      .catch((reason: Error) => active && setError(reason.message))
    return () => {
      active = false
    }
  }, [])

  if (error) return <p role="alert">{error}</p>
  if (!store) return null

  return (
    <CommerceDashboard
      className="h-svh"
      store={store}
      shop={shop}
      user={{ name: "Maya Chen", role: "Store owner" }}
      salesLoaders={salesLoaders}
      now={new Date().toISOString()}
      api={api}
    />
  )
}

Next.js App Router routes

The dashboard routes with the URL hash by default. Pass a router built from usePathname and useRouter to give every page a real URL under /admin, with the same unsaved-changes guard.

"use client"

import * as React from "react"
import { usePathname, useRouter } from "next/navigation"

import type { SalesOverviewLoaders } from "../sales-overview/sales-overview"
import { CommerceDashboard, type CommerceShop } from "@/components/blocks/commerce-dashboard/commerce-dashboard"
import { routeFromPath, routeToPath, type CommerceRouter } from "@/components/blocks/commerce-dashboard/routes"
import type { CommerceStore } from "@/components/blocks/commerce-dashboard/store"

const base = "/admin"

function useAdminRouter(): CommerceRouter {
  const router = useRouter()
  const pathname = usePathname()
  const route = React.useMemo(() => routeFromPath(pathname.slice(base.length)), [pathname])

  return React.useMemo(
    () => ({
      route,
      href: (next) => `${base}${routeToPath(next)}`.replace(/\/$/, ""),
      navigate: (next, options) => {
        const href = `${base}${routeToPath(next)}`.replace(/\/$/, "")
        if (options?.replace) router.replace(href)
        else router.push(href)
      },
    }),
    [route, router]
  )
}

export function AdminShell({
  store,
  shop,
  salesLoaders,
}: {
  store: CommerceStore
  shop: CommerceShop
  salesLoaders: SalesOverviewLoaders
}) {
  const router = useAdminRouter()
  return (
    <CommerceDashboard
      className="h-svh"
      store={store}
      shop={shop}
      user={{ name: "Maya Chen" }}
      salesLoaders={salesLoaders}
      now={new Date().toISOString()}
      router={router}
    />
  )
}

Shopify Admin API

Map each api call to a Shopify Admin GraphQL mutation: fulfillmentCreate, refundCreate, orderCapture, orderUpdate, inventoryAdjustQuantities and productUpdate. userErrors become the message people see.

"use client"

import type { InventoryReason } from "../inventory/inventory"
import type { CommerceApi } from "@/components/blocks/commerce-dashboard/store"

type UserError = { field: string[] | null; message: string }

async function shopify<Data>(query: string, variables: Record<string, unknown>) {
  const response = await fetch("/api/shopify/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  })
  if (!response.ok) throw new Error("Shopify didn’t answer. Try again in a moment.")
  const { data, errors } = (await response.json()) as { data: Data; errors?: { message: string }[] }
  if (errors?.length) throw new Error(errors[0].message)
  return data
}

function assertNoErrors(result: { userErrors: UserError[] }) {
  if (result.userErrors.length > 0) throw new Error(result.userErrors[0].message)
}

const reasons: Record<InventoryReason, string> = {
  correction: "correction",
  received: "received",
  damaged: "damaged",
  theft_or_loss: "theft_or_loss",
  returned: "restock",
  cycle_count: "cycle_count_available",
}

export const shopifyApi: Partial<CommerceApi> = {
  async fulfillOrder(order, draft) {
    const data = await shopify<{ fulfillmentCreate: { userErrors: UserError[] } }>(
      `mutation ($fulfillment: FulfillmentInput!) {
        fulfillmentCreate(fulfillment: $fulfillment) { fulfillment { id } userErrors { field message } }
      }`,
      {
        fulfillment: {
          notifyCustomer: draft.notifyCustomer,
          trackingInfo: draft.trackingNumber ? { number: draft.trackingNumber, company: draft.carrier || undefined } : undefined,
          lineItemsByFulfillmentOrder: [
            {
              fulfillmentOrderId: order.id,
              fulfillmentOrderLineItems: draft.lines.map((line) => ({ id: line.lineId, quantity: line.quantity })),
            },
          ],
        },
      }
    )
    assertNoErrors(data.fulfillmentCreate)
  },
  async refundOrder(order, refund) {
    const data = await shopify<{ refundCreate: { userErrors: UserError[] } }>(
      `mutation ($input: RefundInput!) {
        refundCreate(input: $input) { refund { id } userErrors { field message } }
      }`,
      {
        input: {
          orderId: order.id,
          note: refund.note || undefined,
          notify: refund.notify,
          shipping: { amount: refund.shipping.amount },
          refundLineItems: refund.lines.map((line) => ({
            lineItemId: line.lineId,
            quantity: line.quantity,
            restockType: line.restock ? "RETURN" : "NO_RESTOCK",
            locationId: line.restock ? refund.restockLocationId : undefined,
          })),
          transactions: refund.transactions.map((transaction) => ({
            parentId: transaction.paymentId,
            amount: transaction.amount.amount,
            gateway: "shopify_payments",
            kind: "REFUND",
            orderId: order.id,
          })),
        },
      }
    )
    assertNoErrors(data.refundCreate)
  },
  async capturePayment(order) {
    const data = await shopify<{ orderCapture: { userErrors: UserError[] } }>(
      `mutation ($input: OrderCaptureInput!) {
        orderCapture(input: $input) { transaction { id } userErrors { field message } }
      }`,
      { input: { id: order.id, amount: order.total.amount, currency: order.currencyCode } }
    )
    assertNoErrors(data.orderCapture)
  },
  async updateOrder(order, patch) {
    if (patch.note === undefined && patch.tags === undefined) return
    const data = await shopify<{ orderUpdate: { userErrors: UserError[] } }>(
      `mutation ($input: OrderInput!) {
        orderUpdate(input: $input) { order { id } userErrors { field message } }
      }`,
      { input: { id: order.id, note: patch.note, tags: patch.tags } }
    )
    assertNoErrors(data.orderUpdate)
  },
  async saveInventory(locationId, adjustments) {
    const data = await shopify<{ inventoryAdjustQuantities: { userErrors: UserError[] } }>(
      `mutation ($input: InventoryAdjustQuantitiesInput!) {
        inventoryAdjustQuantities(input: $input) { userErrors { field message } }
      }`,
      {
        input: {
          name: "available",
          reason: reasons[adjustments[0]?.reason ?? "correction"],
          changes: adjustments.map((adjustment) => ({
            inventoryItemId: adjustment.itemId,
            locationId,
            delta: adjustment.delta,
          })),
        },
      }
    )
    assertNoErrors(data.inventoryAdjustQuantities)
  },
  async saveProduct(product) {
    const data = await shopify<{ productUpdate: { userErrors: UserError[] } }>(
      `mutation ($product: ProductUpdateInput!) {
        productUpdate(product: $product) { product { id } userErrors { field message } }
      }`,
      {
        product: {
          id: product.id,
          title: product.title,
          descriptionHtml: product.description,
          status: product.status.toUpperCase(),
          productType: product.productType,
          tags: product.tags,
          seo: { title: product.seoTitle, description: product.seoDescription },
        },
      }
    )
    assertNoErrors(data.productUpdate)
  },
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
CommerceDashboardThe app shell: sidebar, top bar, command palette, notifications, routing and every page.
AppSidebarThe sidebar: store switcher, search, notifications, nested nav with flyouts in icon mode, sales channels, pinned apps, feature card, help, settings and account menu.
createCommerceStore, useCommerceStoreA tiny external store holding orders, products and discounts, read with useSyncExternalStore.
simulatedApiThe default api: waits like a real server and checks discount codes against the store.
useHashRouter, routeFromPath, routeToPathThe built-in hash router and the helpers to build your own.
inventoryItems, customersFromOrders, productStockPure helpers that derive stock rows, customer totals and product stock from the store.
CommandPaletteThe ⌘K palette: pages, orders, products and discounts in one Raycast-simple list.

CommerceDashboard

Also takes div props for the root.

PropTypStandard
storeThe shared data, from createCommerceStore({ orders, products, discounts }).
CommerceStore–
shop{ name, domain, currencyCode, timeZone?, logo?, locations, fulfillmentLocationId?, channels, collections, settingsHref? }. Locations drive inventory, fulfillment and restocking.
CommerceShop–
userThe staff member in the sidebar footer. Their name signs comments and timeline events.
{ name; email?; avatar?; role? }–
salesLoadersLoaders for Home and Analytics, passed to Sales Overview.
SalesOverviewLoaders–
nowThe current instant as ISO, for greetings, relative dates and discount status.
string–
apiServer calls: fulfillOrder, refundOrder, capturePayment, cancelOrder, updateOrder, bulkOrders, saveProduct, saveInventory, saveDiscount, checkDiscountCode. Missing ones are simulated. Return the saved object to use it, or throw to show the message.
Partial<CommerceApi>simulatedApi(store)
router{ route, navigate, href }. Replaces the built-in hash router.
CommerceRouteruseHashRouter()
liveLive view data for Home. Leave out to hide the card.
LiveViewProps | "loading" | null–
segmentsCustomer segments for the discount builder.
DiscountSegment[][]
localeLocale for money, numbers and dates.
string"en-US"
stores{ id, name, domain, logo?, role?, testMode? }. More than five adds a search field to the switcher. A test-mode store shows a banner above the page.
SidebarStore[]one store from shop
storeId, onStoreChangeThe current store, controlled. Switching closes open popups and announces the change.
string, (id: string) => void–
onAddStoreShows Add store in the switcher.
() => void–
channels, pinnedChannelsSales channels and which are shown. { id, label, icon, route, status?, description? }. A status shows an amber dot and a Retry sync action on the channel page.
SidebarShortcut[], string[]–
apps, pinnedAppsApps and which are pinned. Pinned apps can be reordered or unpinned from their ⋯ menu.
SidebarShortcut[], string[]–
cardsFooter cards, shown one at a time: { kind: "setup", done, total, next } or { kind: "announcement", image, description }, each with a cta. Dismissed ids are remembered.
FeatureCard[]–
helpLinks in Help & support.
{ center?, status?, email? }–
theme, onThemeChangeThe theme choice in the account menu. Leave out onThemeChange to hide it.
"light" | "dark" | "system", (theme) => void–
language, languages, onLanguageChangeThe language submenu.
string, { value, label }[], (value) => void–
onLogOutCalled by Log out.
() => void–

CommerceStore

Returned by createCommerceStore.

PropTypStandard
getSnapshot, subscribeFor useSyncExternalStore.
() => CommerceData, (listener) => () => void–
setOrders, updateOrderReplace all orders or change one by id.
(change) => void–
saveProductInsert or replace a product.
(product: ProductDraft) => void–
adjustStockMove stock at a location by a delta. itemId is a variant id, or the product id for products without options.
(locationId, { itemId, delta }[]) => void–
saveDiscountInsert or replace a discount.
(record: { discount, stats }) => void–

CommerceRoute

Where the dashboard is.

PropTypStandard
pageThe page.
"home" | "orders" | "products" | "inventory" | "discounts" | "customers" | "analytics" | "settings"–
idFor orders, products and discounts: the open item, or "new" to create one.
string–
TasteAktion
⌘KOpens search. Ctrl+K on Windows and Linux.
⌘BCollapses the sidebar to icons or expands it.
⌘1Switches to the first store. ⌘2 to ⌘9 switch to the next ones.
?Shows the keyboard shortcuts.
EnterSpaceOn a section's chevron, shows or hides its pages without leaving the page.
↑↓In search and in menus, moves between items. Enter opens the highlighted one.
JKOn an order, opens the next or previous order, from Order Detail.
EscCloses search, menus, the refund sheet or the phone sidebar.
  • The page links sit in a nav named Main, and the current page's link has aria-current="page".
  • Section chevrons are buttons with aria-expanded and aria-controls, and closed sections are inert so Tab skips them.
  • In icon mode every item is named by its label and count, like "Orders, 9 unfulfilled", and counts become dots.
  • Each page change and store switch is announced politely, and so is a finished refund.
  • Table rows open with a real link in the first cell, so they work with the keyboard and open in a new tab.
  • Status dots for unread notifications and failed channels have text for screen readers and stay visible in forced colors.
  • Blocked navigation shows a warning toast instead of silently dropping the click.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Commerce Dashboard besteht. Jede lässt sich einzeln installieren.

Code

10 Dateien, hinzugefügt zu components/blocks/commerce-dashboard.