HextaUI

Order Detail

The merchant’s page for one order: shipments, payment, customer and timeline. Handles partial fulfillment, holds, failed payments and cancellations.

Open in new tab

Order Detail is the page a merchant opens from the orders table: one order with everything they need to act on it. The header shows the order number, separate payment and fulfillment pills with a progress glyph (empty, half or full circle) so status never depends on color, the date and channel, the order actions and previous and next arrows.

The main column splits items by what happened to them. An Unfulfilled card lists what’s left to ship, worked out as purchased minus fulfilled minus refunded. Each shipment gets its own card with carrier, tracking link, delivery status and items, and refunded items get a Refunded card. The Payment card shows the total and only the lines that matter right now (refunded and net, an authorization with its expiry, or a balance due); subtotal, discounts, shipping, tax and currency conversion sit behind Breakdown. It has Capture payment for authorizations and Send invoice for pending or failed payments.

Fulfill items opens a sheet with a quantity stepper per line, capped at what’s left, a location, a tracking number that detects UPS, USPS, FedEx, DHL, Canada Post and Royal Mail from its pattern, and a Notify customer switch. Submitting closes the sheet and adds the shipment and a timeline event straight away. If onFulfill throws, both roll back and a toast offers Try again with the same choices.

The timeline reads like Shopify, Linear and Stripe activity feeds: a rail with one icon per event type (status color only on the icon), a short title built from the order data (“Payment of $564.41 captured”, “Shipped with UPS” with the tracking number linked), a muted second line and a relative time with the exact date in a tooltip. Day labels stick while you scroll. Customer emails fold into the event that sent them (“Customer emailed”, with Resend in Show details), three or more same-kind events in a row collapse into one line, and older history folds behind “Show N earlier events”. Comments are cards with avatar, author, edit and delete, and attachments as chips. Filter by All, Comments or System. The composer is one line until you focus it, then shows attachments, “Only visible to staff” and Post (⌘↵), and posts optimistically with retry. The right rail keeps to what matters: notes and tags only appear when they have content or can be edited, the customer card drops repeated name and phone lines, and conversion and risk show a one-line summary with details behind Show more. Cancelled, on hold, archived and loading states, and actions a staff member isn’t allowed to take, are all handled with a reason.

  1. Obtenha o HextaUI Pro

    Um único pagamento libera todos os blocos, incluindo os futuros. Ver preços.

  2. Adicione o registro Pro ao components.json

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

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  4. Adicione o bloco

    pnpm dlx shadcn@latest add @hextaui-pro/order-detail

From the Shopify Admin API

Map an Admin API order to Order once, then pass onFulfill to call fulfillmentCreate with the quantities, tracking number and notify flag from the Fulfill items sheet. Throw the first userError and the new shipment rolls back with that message.

"use client"

import * as React from "react"

import type { Fulfillment, FulfillmentStatus, Order, PaymentStatus } from "../order-status/order"
import { OrderDetail, OrderDetailSkeleton } from "@/components/blocks/order-detail/order-detail"
import type { FulfillmentDraft } from "@/components/blocks/order-detail/order-detail-utils"

type ShopifyMoney = { amount: string; currencyCode: string }

type ShopifyOrder = {
  id: string
  name: string
  createdAt: string
  email: string | null
  note: string | null
  tags: string[]
  cancelledAt: string | null
  displayFinancialStatus: string
  displayFulfillmentStatus: string
  sourceName: string | null
  currentSubtotalPriceSet: { presentmentMoney: ShopifyMoney }
  totalShippingPriceSet: { presentmentMoney: ShopifyMoney }
  totalTaxSet: { presentmentMoney: ShopifyMoney }
  totalPriceSet: { presentmentMoney: ShopifyMoney }
  lineItems: {
    nodes: {
      id: string
      title: string
      variantTitle: string | null
      sku: string | null
      quantity: number
      refundableQuantity: number
      unfulfilledQuantity: number
      image: { url: string; altText: string | null } | null
      originalUnitPriceSet: { presentmentMoney: ShopifyMoney }
      product: { id: string } | null
    }[]
  }
  fulfillmentOrders: { nodes: { id: string; status: string }[] }
}

const ORDER_QUERY = `#graphql
  query OrderDetail($id: ID!) {
    order(id: $id) {
      id name createdAt email note tags cancelledAt sourceName
      displayFinancialStatus displayFulfillmentStatus
      currentSubtotalPriceSet { presentmentMoney { amount currencyCode } }
      totalShippingPriceSet { presentmentMoney { amount currencyCode } }
      totalTaxSet { presentmentMoney { amount currencyCode } }
      totalPriceSet { presentmentMoney { amount currencyCode } }
      lineItems(first: 50) {
        nodes {
          id title variantTitle sku quantity refundableQuantity unfulfilledQuantity
          image { url altText }
          originalUnitPriceSet { presentmentMoney { amount currencyCode } }
          product { id }
        }
      }
      fulfillmentOrders(first: 10) { nodes { id status } }
    }
  }
`

const FULFILL_MUTATION = `#graphql
  mutation Fulfill($fulfillment: FulfillmentInput!) {
    fulfillmentCreate(fulfillment: $fulfillment) {
      fulfillment { id status createdAt trackingInfo { company number url } }
      userErrors { field message }
    }
  }
`

const payment: Record<string, PaymentStatus> = {
  PENDING: "pending",
  AUTHORIZED: "authorized",
  PAID: "paid",
  PARTIALLY_PAID: "partially_paid",
  PARTIALLY_REFUNDED: "partially_refunded",
  REFUNDED: "refunded",
  VOIDED: "voided",
}

const fulfillment: Record<string, FulfillmentStatus> = {
  UNFULFILLED: "unfulfilled",
  PARTIALLY_FULFILLED: "partially_fulfilled",
  FULFILLED: "fulfilled",
  ON_HOLD: "on_hold",
  SCHEDULED: "scheduled",
}

async function admin<T>(query: string, variables: Record<string, unknown>): Promise<T> {
  const response = await fetch("/api/shopify/admin", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ query, variables }),
  })
  const { data, errors } = (await response.json()) as { data: T; errors?: { message: string }[] }
  if (errors?.length) throw new Error(errors[0].message)
  return data
}

function fromShopify(order: ShopifyOrder): Order {
  return {
    id: order.id,
    name: order.name,
    createdAt: order.createdAt,
    email: order.email ?? undefined,
    note: order.note ?? undefined,
    tags: order.tags,
    cancelledAt: order.cancelledAt ?? undefined,
    channel: order.sourceName ?? undefined,
    currencyCode: order.totalPriceSet.presentmentMoney.currencyCode,
    subtotal: order.currentSubtotalPriceSet.presentmentMoney,
    shipping: order.totalShippingPriceSet.presentmentMoney,
    tax: order.totalTaxSet.presentmentMoney,
    total: order.totalPriceSet.presentmentMoney,
    discounts: [],
    paymentStatus: payment[order.displayFinancialStatus] ?? "pending",
    fulfillmentStatus: fulfillment[order.displayFulfillmentStatus] ?? "unfulfilled",
    lines: order.lineItems.nodes.map((line) => ({
      id: line.id,
      productId: line.product?.id,
      title: line.title,
      variantTitle: line.variantTitle ?? undefined,
      sku: line.sku ?? undefined,
      image: line.image
        ? { id: line.image.url, src: line.image.url, alt: line.image.altText ?? line.title }
        : undefined,
      quantity: line.quantity,
      price: line.originalUnitPriceSet.presentmentMoney,
      refundedQuantity: line.quantity - line.refundableQuantity,
      fulfilledQuantity: line.refundableQuantity - line.unfulfilledQuantity,
    })),
    fulfillments: [],
    refunds: [],
    events: [],
  }
}

export function ShopifyOrderPage({ orderId }: { orderId: string }) {
  const [order, setOrder] = React.useState<Order | null>(null)
  const [fulfillmentOrderId, setFulfillmentOrderId] = React.useState<string>()

  const load = React.useCallback(async () => {
    const data = await admin<{ order: ShopifyOrder }>(ORDER_QUERY, { id: orderId })
    setOrder(fromShopify(data.order))
    setFulfillmentOrderId(data.order.fulfillmentOrders.nodes.find((item) => item.status === "OPEN")?.id)
  }, [orderId])

  React.useEffect(() => {
    void load()
  }, [load])

  const fulfill = async (_: Order, draft: FulfillmentDraft): Promise<Fulfillment | void> => {
    if (!fulfillmentOrderId) throw new Error("This order has no open fulfillment order.")
    const data = await admin<{
      fulfillmentCreate: { userErrors: { message: string }[] }
    }>(FULFILL_MUTATION, {
      fulfillment: {
        notifyCustomer: draft.notifyCustomer,
        trackingInfo: draft.trackingNumber
          ? { number: draft.trackingNumber, company: draft.carrier || undefined }
          : undefined,
        lineItemsByFulfillmentOrder: [{ fulfillmentOrderId }],
      },
    })
    const error = data.fulfillmentCreate.userErrors[0]
    if (error) throw new Error(error.message)
    await load()
  }

  if (!order) return <OrderDetailSkeleton />

  return (
    <OrderDetail
      order={order}
      timeZone="America/New_York"
      backHref="/orders"
      onFulfill={fulfill}
    />
  )
}

Post timeline comments

onComment gets the message and attached files from the one-line composer (it expands on focus, ⌘↵ posts). The comment card shows right away, dimmed, and turns into a failed state with Retry when the request throws. Return the saved OrderEvent so edits and deletes use your id; onResendEmail adds Resend inside an event’s Show details.

"use client"

import type { Order, OrderEvent } from "../order-status/order"
import { OrderDetail } from "@/components/blocks/order-detail/order-detail"

type Staff = { id: string; name: string; avatar?: string }

async function send<T>(url: string, method: string, body?: unknown): Promise<T> {
  const response = await fetch(url, {
    method,
    headers: { "content-type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  })
  if (!response.ok) {
    const problem = (await response.json().catch(() => null)) as { detail?: string } | null
    throw new Error(problem?.detail ?? "Couldn’t reach the server. Try again.")
  }
  return (await response.json()) as T
}

export function OrderWithComments({ order, staff }: { order: Order; staff: Staff }) {
  const base = `/api/orders/${encodeURIComponent(order.id)}/comments`

  return (
    <OrderDetail
      order={order}
      currentUser={{ name: staff.name, avatar: staff.avatar }}
      onComment={async ({ message, attachments }) => {
        const form = new FormData()
        form.set("message", message)
        for (const file of attachments) form.append("files", file)
        const response = await fetch(base, { method: "POST", body: form })
        if (!response.ok) throw new Error("Your comment wasn’t saved. Try again.")
        return (await response.json()) as OrderEvent
      }}
      onEditComment={(event, message) =>
        send<OrderEvent>(`${base}/${event.id}`, "PATCH", { message })
      }
      onDeleteComment={(event) => send(`${base}/${event.id}`, "DELETE")}
      onResendEmail={(event) =>
        send(`/api/orders/${encodeURIComponent(order.id)}/emails/resend`, "POST", { eventId: event.id })
      }
    />
  )
}

A Stripe charge in the payment card

Read a Stripe charge into paymentStatus, the card label, amountPaid and a failed-payment event. When the balance transaction settles in another currency, pass shopCurrency and the card adds the converted total and net payment.

import { fromMinorUnits, toMinorUnits } from "../price/money"
import type { Order, PaymentStatus } from "../order-status/order"

type StripeCharge = {
  id: string
  amount: number
  amount_captured: number
  amount_refunded: number
  currency: string
  captured: boolean
  status: "succeeded" | "pending" | "failed"
  failure_message: string | null
  created: number
  payment_method_details: { card?: { brand: string; last4: string } } | null
  balance_transaction: { amount: number; currency: string; exchange_rate: number | null } | null
}

function paymentStatus(charge: StripeCharge): PaymentStatus {
  if (charge.status === "failed") return "failed"
  if (charge.status === "pending") return "pending"
  if (!charge.captured) return "authorized"
  if (charge.amount_refunded >= charge.amount_captured) return "refunded"
  if (charge.amount_refunded > 0) return "partially_refunded"
  return "paid"
}

export function withStripeCharge(order: Order, charge: StripeCharge) {
  const currency = charge.currency.toUpperCase()
  const card = charge.payment_method_details?.card
  const next: Order = {
    ...order,
    paymentStatus: paymentStatus(charge),
    paymentMethod: card
      ? {
          brand: card.brand,
          last4: card.last4,
          label: `${card.brand[0].toUpperCase()}${card.brand.slice(1)} ending in ${card.last4}`,
        }
      : order.paymentMethod,
    events: charge.failure_message
      ? [
          ...order.events,
          {
            id: `${charge.id}-failed`,
            type: "payment_failed",
            at: new Date(charge.created * 1000).toISOString(),
            message: `${card ? `Card ending in ${card.last4}` : "The payment"} was declined: ${charge.failure_message}.`,
          },
        ]
      : order.events,
  }
  const settlement = charge.balance_transaction
  return {
    order: next,
    amountPaid: fromMinorUnits(charge.amount_captured, currency),
    shopCurrency:
      settlement && settlement.currency.toUpperCase() !== currency && settlement.exchange_rate
        ? { currencyCode: settlement.currency.toUpperCase(), rate: settlement.exchange_rate }
        : undefined,
    capturable: charge.captured ? null : fromMinorUnits(charge.amount, currency),
    matchesOrder: toMinorUnits(order.total) === charge.amount,
  }
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
OrderDetailThe whole page: header, banners, item cards, payment card, timeline and right rail.
OrderDetailSkeletonThe loading layout, also shown when loading is true.
FulfillItemsSheetThe sheet that picks quantities, location, tracking and notification for a new shipment.
OrderTimelineThe one-line composer, the All / Comments / System filter and the day-grouped rail of events and comment cards.
NotesCard, CustomerCard, ConversionCard, RiskCard, TagsCardThe right rail cards, usable on their own.
orderActionState, paymentSummary, lineQuantities, detectCarrier, applyFulfillmentPure helpers for action availability with reasons, payment totals, remaining quantities, carrier detection and optimistic shipments.

OrderDetail

Also takes div props. Every callback may return a promise; throw to show the message where the action happened.

PropTipoPadrão
orderThe order, from ../order-status/order. Pass a new object when it changes; local optimistic state resets to it.
Order–
loadingShows OrderDetailSkeleton.
booleanfalse
localeLocale for dates, money and plurals.
string"en-US"
timeZoneThe shop’s time zone for dates and day groups.
string"UTC"
nowAn ISO time for Today and Yesterday labels and the authorization countdown. New events are stamped from it.
string–
currentUserThe staff member. Their comments can be edited and deleted.
{ name: string; avatar?: string }–
permissionsfalse or a reason string disables that action with a tooltip, such as { refund: "Only admins can refund orders." }.
Partial<Record<OrderDetailAction, boolean | string>>–
archivedShows the archived banner and badge.
boolean–
holdReasonText for the on hold banner. Defaults to the latest hold note on the order.
string–
riskLevel, headline, indicators with good, bad or neutral tone, IP and provider. Defaults to order.risk.
OrderRiskAnalysis–
conversionVisit details for the conversion summary.
{ channel?; source?; firstVisit?; sessions?; landingPage? }–
shopCurrencyWhen it differs from the order currency, the payment card adds the total and net in the shop currency.
{ currencyCode: string; rate: number }–
amountPaidWhat the customer has paid, for partially paid orders.
Money–
locationsLocations for the Fulfill items sheet. Defaults to the locations of existing shipments.
string[]–
backHref, onBackThe back arrow.
string, () => void–
customerHrefLink for the customer’s name.
string–
previousOrder, nextOrderThe neighbors for the arrows and J/K.
{ name: string; href?: string } | null–
onNavigateCalled by the arrows and by J and K.
(direction: "previous" | "next") => void–
onRefund, onReturn, onEditHeader actions. Wire onRefund to the Refund block’s sheet.
(order: Order) => void–
onDuplicate, onPrintPackingSlipMore menu actions.
(order: Order) => Awaitable–
onCancelConfirmed in a dialog that explains what happens to the payment. The dialog stays open and shows the error if it throws.
(order, { reason, restock, notifyCustomer }) => Awaitable–
onArchiveChangeArchive and unarchive, optimistic with Undo.
(order, archived: boolean) => Awaitable–
onFulfillCreates a shipment. Return the saved Fulfillment to replace the optimistic one.
(order, draft: FulfillmentDraft) => Awaitable<Fulfillment | void>–
onCapturePayment, onSendInvoice, onReleaseHoldPayment and hold actions.
(order: Order) => Awaitable–
onCommentPosts a comment. Return the saved event.
({ message, attachments }) => Awaitable<OrderEvent | void>–
onEditComment, onDeleteCommentEdit and delete your own comments. Delete waits for the Undo toast to close.
(event, message?) => Awaitable–
onResendEmailShows Resend on email events.
(event: OrderEvent) => Awaitable–
onNoteChange, onTagsChangeSaved optimistically and reverted with a message if they throw. Omit to make them read-only.
(note: string) / (tags: string[]) => Awaitable–
labelsWords for the actions and buttons.
Partial<OrderDetailLabels>–

OrderTimeline

Usable on its own for customers, drafts or inventory history.

PropTipoPadrão
orderThe order whose events, fulfillments and refunds the titles are built from.
Order–
currentUserThe signed-in staff member. Their comments get Edit and Delete.
{ name: string; avatar?: string }–
defaultFilterThe filter selected on first render. Defaults to "all".
"all" | "comments" | "system"–
visibleCountHow many entries show before older history folds behind “Show N earlier events”. Comments-only view never folds. Defaults to 6.
number–
nowISO time used for relative times (“2 hr. ago”) and Today / Yesterday labels. Without it, times show as clock times.
string | null–
commentStateDisables the composer and explains why.
{ available: boolean; reason?: string }–
onComment, onEditComment, onDeleteCommentComment posting is optimistic with Retry and Discard on failure; delete has a 5 second Undo toast.
async callbacks–
onResendEmailAdds Resend next to each email inside Show details.
(event: OrderEvent) => Awaitable–
PropTipoPadrão
linesQuantities to ship, each at most the line’s remaining quantity.
{ lineId: string; quantity: number }[]–
locationNameThe location it ships from.
string–
trackingNumberOptional tracking number.
string–
carrierThe chosen carrier, or empty to use the detected one.
string–
notifyCustomerSend a shipping confirmation.
boolean–

orderActionState

(order, action, { permissions, archived }) => { available, reason }

PropTipoPadrão
actionReturns why an action isn’t available, such as “Items have shipped. Refund or return them instead.”
"refund" | "return" | "edit" | "duplicate" | "cancel" | "archive" | "print" | "fulfill" | "capture" | "invoice" | "releaseHold" | "comment"–
TeclaAção
JOpens the next order when focus isn’t in a field.
KOpens the previous order.
MetaEnterPosts the comment, or saves a comment or note you’re editing.
EscapeCancels a comment or note edit and closes sheets, dialogs and menus.
ArrowUpArrowDownChange a quantity in the Fulfill items sheet.
BackspaceIn an empty tag field, removes the last tag.
EscapeIn an empty comment composer, leaves it so it folds back to one line.
  • Status pills pair words with a progress glyph, so payment, fulfillment and shipment states never rely on color. The risk meter is a meter with a text value.
  • Disabled actions stay focusable, show their reason in a tooltip and link it with aria-describedby. Disabled menu items show the reason under the label.
  • A polite live region announces fulfillments, comments, holds released and archive changes, and failures. Comment and note errors use role="alert" next to the item.
  • Every card is a labelled section and the page has one h1. The timeline is an ordered list per day; each event reads as one sentence (title, details, then the full date from a time element), icons are hidden from screen readers, Show details buttons name their event and report expanded state, and comments are articles labelled with their author. Copy buttons announce “Copied to clipboard” and tracking and map links say they open a new tab.

Construído com

Os componentes gratuitos do HextaUI de que Order Detail é feito. Cada um é instalado separadamente.

Código

9 arquivos, adicionados a components/blocks/order-detail.