HextaUI

Order Status

Customer order confirmation, package tracking and returns. Handles split shipments, delayed or failed deliveries, final-sale items and pending payments.

Open in new tab

Order Status is the page a customer lands on after checkout and comes back to while they wait. OrderConfirmation says thanks by name, shows the order number, the email the receipt went to, the expected delivery dates, what they bought and where it’s going. A check mark draws in and the details rise into place in about 800 ms, and content is readable well before that.

OrderTracking follows Baymard’s six tracking essentials. Each package gets a card with a large delivery date, a progress bar that shows before anything ships, the carrier and a linked tracking number you can copy, what’s inside with thumbnails, and the full shipping history newest first. Split orders read Package 1 of 2, and items that haven’t shipped sit in their own Preparing to ship card.

Problems are spelled out rather than hidden. A delayed shipment turns its step amber and says the new date, a missed delivery says when the next attempt is, a cancelled order says whether the payment was released or refunded, and a pending payment says nothing ships until it clears.

ReturnRequest is a two-step return: pick items and quantities, then a reason and where the refund goes. Final-sale items, items still in transit and items past the return window stay visible but disabled, each with the reason. The refund estimate updates as you choose, including the discount and tax share and any return shipping fee, and the confirmation shows a drop-off code and the refund timeline.

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

From the Shopify Customer Account API

Map a Customer Account API order to Order once. Line items, fulfillments with their tracking information and shipment events, and the latest shipment status come across as they are, so the status page reads straight from Shopify.

import type { Order, ShipmentStatus } from "@/components/blocks/order-status/order"
import { OrderTracking } from "@/components/blocks/order-status/order-tracking"

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

type CustomerAccountOrder = {
  id: string
  name: string
  processedAt: string
  email: string | null
  financialStatus: "PAID" | "PENDING" | "PARTIALLY_REFUNDED" | "REFUNDED" | "VOIDED" | "AUTHORIZED"
  fulfillmentStatus: "FULFILLED" | "UNFULFILLED" | "PARTIALLY_FULFILLED" | "ON_HOLD"
  cancelledAt: string | null
  subtotal: ShopifyMoney
  totalShipping: ShopifyMoney
  totalTax: ShopifyMoney
  totalPrice: ShopifyMoney
  lineItems: {
    nodes: {
      id: string
      title: string
      variantTitle: string | null
      quantity: number
      price: ShopifyMoney
      image: { url: string; altText: string | null } | null
    }[]
  }
  fulfillments: {
    nodes: {
      id: string
      createdAt: string
      latestShipmentStatus: string | null
      estimatedDeliveryAt: string | null
      trackingInformation: { company: string | null; number: string | null; url: string | null }[]
      events: { nodes: { status: string; happenedAt: string }[] }
      fulfillmentLineItems: { nodes: { lineItem: { id: string }; quantity: number }[] }
    }[]
  }
}

const shipmentStatus: Record<string, ShipmentStatus> = {
  LABEL_PRINTED: "label_created",
  IN_TRANSIT: "in_transit",
  OUT_FOR_DELIVERY: "out_for_delivery",
  DELIVERED: "delivered",
  ATTEMPTED_DELIVERY: "attempted",
  DELAYED: "exception",
  FAILURE: "exception",
}

const status = (value: string | null): ShipmentStatus =>
  shipmentStatus[value ?? ""] ?? "in_transit"

export function fromCustomerAccount(order: CustomerAccountOrder): Order {
  return {
    id: order.id,
    name: order.name,
    createdAt: order.processedAt,
    currencyCode: order.totalPrice.currencyCode,
    email: order.email ?? undefined,
    lines: order.lineItems.nodes.map((line) => ({
      id: line.id,
      title: line.title,
      variantTitle: line.variantTitle ?? undefined,
      quantity: line.quantity,
      price: line.price,
      image: line.image
        ? { id: line.image.url, src: line.image.url, alt: line.image.altText ?? line.title }
        : undefined,
      fulfilledQuantity: 0,
      refundedQuantity: 0,
    })),
    subtotal: order.subtotal,
    shipping: order.totalShipping,
    tax: order.totalTax,
    total: order.totalPrice,
    discounts: [],
    paymentStatus: order.financialStatus.toLowerCase() as Order["paymentStatus"],
    fulfillmentStatus: order.fulfillmentStatus.toLowerCase() as Order["fulfillmentStatus"],
    cancelledAt: order.cancelledAt ?? undefined,
    fulfillments: order.fulfillments.nodes.map((fulfillment) => {
      const tracking = fulfillment.trackingInformation[0]
      return {
        id: fulfillment.id,
        createdAt: fulfillment.createdAt,
        status: status(fulfillment.latestShipmentStatus),
        carrier: tracking?.company ?? undefined,
        trackingNumber: tracking?.number ?? undefined,
        trackingUrl: tracking?.url ?? undefined,
        estimatedDelivery: fulfillment.estimatedDeliveryAt
          ? { from: fulfillment.estimatedDeliveryAt.slice(0, 10) }
          : undefined,
        lines: fulfillment.fulfillmentLineItems.nodes.map((item) => ({
          lineId: item.lineItem.id,
          quantity: item.quantity,
        })),
        events: fulfillment.events.nodes.map((event) => ({
          status: status(event.status),
          at: event.happenedAt,
          message: event.status.charAt(0) + event.status.slice(1).toLowerCase().replace(/_/g, " "),
        })),
      }
    }),
    refunds: [],
    events: [],
  }
}

export function OrderStatusPage({ order }: { order: CustomerAccountOrder }) {
  return <OrderTracking order={fromCustomerAccount(order)} />
}

Tracking from EasyPost or AfterShip webhooks

Turn a tracker.updated webhook into fulfillment events and a shipment status. The same mapping works for AfterShip checkpoints. Store the result and OrderTracking shows the new date, step and history on the next render.

import type { Fulfillment, ShipmentEvent, ShipmentStatus } from "@/components/blocks/order-status/order"

type EasyPostTracker = {
  tracking_code: string
  carrier: string
  public_url: string
  status: string
  est_delivery_date: string | null
  tracking_details: {
    status: string
    message: string
    datetime: string
    tracking_location: { city: string | null; state: string | null }
  }[]
}

const statuses: Record<string, ShipmentStatus> = {
  pre_transit: "label_created",
  in_transit: "in_transit",
  out_for_delivery: "out_for_delivery",
  delivered: "delivered",
  available_for_pickup: "attempted",
  return_to_sender: "exception",
  failure: "exception",
  cancelled: "cancelled",
}

const toStatus = (value: string): ShipmentStatus => statuses[value] ?? "in_transit"

export function applyTrackerUpdate(fulfillment: Fulfillment, tracker: EasyPostTracker): Fulfillment {
  const events: ShipmentEvent[] = tracker.tracking_details.map((detail) => ({
    status: toStatus(detail.status),
    at: detail.datetime,
    message: detail.message,
    location: [detail.tracking_location.city, detail.tracking_location.state]
      .filter(Boolean)
      .join(", ") || undefined,
  }))
  const status = toStatus(tracker.status)
  return {
    ...fulfillment,
    status,
    carrier: tracker.carrier,
    trackingNumber: tracker.tracking_code,
    trackingUrl: tracker.public_url,
    events,
    estimatedDelivery: tracker.est_delivery_date
      ? { from: tracker.est_delivery_date.slice(0, 10) }
      : fulfillment.estimatedDelivery,
    deliveredAt:
      status === "delivered"
        ? events.findLast((event) => event.status === "delivered")?.at
        : undefined,
  }
}

export async function POST(request: Request) {
  const body = (await request.json()) as { description: string; result: EasyPostTracker }
  if (body.description !== "tracker.updated") return new Response(null, { status: 204 })
  const fulfillment = await loadFulfillmentByTracking(body.result.tracking_code)
  await saveFulfillment(applyTrackerUpdate(fulfillment, body.result))
  return new Response(null, { status: 200 })
}

declare function loadFulfillmentByTracking(code: string): Promise<Fulfillment>
declare function saveFulfillment(fulfillment: Fulfillment): Promise<void>

Returns through your API

ReturnRequest posts the chosen lines, reason and refund method to your endpoint and shows the label it returns. Throw with a message and it appears next to the submit button, with everything the customer picked still in place.

"use client"

import { money } from "../price/money"
import type { Order } from "@/components/blocks/order-status/order"
import { ReturnRequest, type ReturnRequestResult } from "@/components/blocks/order-status/return-request"

export function StartReturn({ order, onClose }: { order: Order; onClose: () => void }) {
  return (
    <ReturnRequest
      order={order}
      windowDays={30}
      returnShippingFee={money("5.95", order.currencyCode)}
      supportHref="/contact"
      onCancel={onClose}
      onDone={onClose}
      onSubmit={async (request) => {
        const response = await fetch(`/api/orders/${order.id}/returns`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(request),
        })
        if (!response.ok) {
          const { message } = (await response.json()) as { message?: string }
          throw new Error(message ?? "We couldn’t start your return. Try again in a minute.")
        }
        return (await response.json()) as ReturnRequestResult
      }}
    />
  )
}

Anatomia

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

ParteDescrição
OrderConfirmationThe thank-you page: greeting, delivery estimate, summary, addresses, payment and an optional account prompt.
OrderTrackingOne card per package with the delivery date, progress, carrier, contents and history.
ReturnRequestA compact return wizard that ends with a drop-off code and refund timeline.
OrderThe shared order model, also used by the dashboard blocks.
orderPackages, returnEligibility, estimateRefundGroup lines into packages, decide what can be returned and why, and work out a refund.

OrderConfirmation

Also takes div props.

PropTipoPadrão
orderThe order to confirm. paymentStatus pending or failed switches the heading and message.
Order–
deliveryEstimateISO dates for the delivery window. null hides it.
{ from: string; to?: string } | nullorder.estimatedDelivery
celebrateA single, quiet confetti burst, once per session. Never fires with reduced motion.
booleanfalse
autoFocusMove focus to the heading on mount.
booleantrue
onCreateAccountShows the guest account prompt. Throw to show the message under the field.
(password: string) => void | Promise<void>–
actionsButtons at the end, such as Track your order.
ReactNode–
localeLocale for dates and money.
Intl.LocalesArgument"en-US"
timeZoneTime zone for dates. Defaults to the viewer’s.
string–
labelsEvery user-facing string.
Partial<OrderConfirmationLabels>–

OrderTracking

Also takes div props.

PropTipoPadrão
orderFulfillments become package cards; unshipped quantities become a Preparing to ship card.
Order–
nowUsed for today and tomorrow in delivery dates.
string | DateDate.now()
defaultExpandedOpen every shipping history instead of the latest two updates.
booleanfalse
actionsButtons beside the heading, such as Start a return.
ReactNode–
localeLocale for dates and money.
Intl.LocalesArgument"en-US"
timeZoneTime zone for event times.
string–
labelsStep names, statuses and messages.
Partial<OrderTrackingLabels>–

ReturnRequest

Also takes div props.

PropTipoPadrão
orderThe order to return items from.
Order–
onSubmitCreate the return. Resolve with an id, code, carrier, dropOffBy, labelUrl or qrCodeUrl. Throw to show the message.
(request: ReturnRequestInput) => Promise<ReturnRequestResult | void>–
windowDaysDays after delivery that an item can be returned.
number30
eligibilityOverride the built-in rules for a line.
(line, order) => ReturnEligibility | undefined–
reasonsReason options. note shows a text box for details.
{ value: string; label: string; note?: boolean }[]–
refundMethodsWhere the refund can go.
("original" | "store_credit")[]["original", "store_credit"]
returnShippingFeeTaken off refunds to the original payment. Store credit returns are free.
Money | nullnull
onCancelShows a Cancel button.
() => void–
onDoneShows a Done button after the return starts.
() => void–
supportHrefLink shown when nothing can be returned.
string–
nowUsed for the return window.
string | DateDate.now()
labelsEvery user-facing string.
Partial<ReturnRequestLabels>–
PropTipoPadrão
linesEach with quantity, price, fulfilledQuantity, refundedQuantity and returnable.
OrderLine[]–
fulfillmentsPackages with status, carrier, trackingNumber, trackingUrl, estimatedDelivery, deliveredAt, lines and events.
Fulfillment[]–
refundsAmounts, lines and restock flags.
Refund[]–
paymentStatuspending, authorized, paid, partially_refunded, refunded, voided or failed.
PaymentStatus–
estimatedDeliveryThe window shown before anything ships.
{ from: string; to?: string }–
cancelledAtSet for cancelled orders.
string–
TeclaAção
TabMoves through tracking links, copy buttons, history toggles and return choices.
SpaceToggles an item in a return, opens a history, or picks a refund method.
ArrowUpArrowDownMoves between reasons in the reason menu and between refund methods.
EnterActivates links and buttons and submits the account form.
  • The confirmation heading takes focus on load, and each return step moves focus to its heading, so screen reader users hear where they are.
  • Each progress bar is an ordered list. The current step has aria-current="step", and every step says whether it’s completed, current or upcoming, with its date.
  • Status is never shown by color alone: delays and failed deliveries have an icon and words, and disabled return items say why. Tracking links say they open the carrier in a new tab.
  • Copying a tracking or order number and changing the return selection are announced in a polite live region. Errors use role="alert". Every animation, including the check draw and confetti, is skipped with reduced motion.

Construído com

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

Código

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