Skip to content

InvoiceDetail

Pure presentation of a single invoice. It never fetches; the caller passes the invoice it owns (via useInvoice).

import { InvoiceDetail } from "@evolve-storefront/feature-invoice/invoice-detail";

Renders inside InvoiceProvider.

Design notes

A Server Component, like every part it composes: the parts take the invoice as a prop rather than reading a context, so nothing here crosses the RSC boundary. Download and pay actions are composed in via actions — the pay page itself is owned by the site.

Preview

OpenOpen in Storybook ↗

Usage

Open, payable invoice spanning two orders: download and pay, and each billed order links to its order page through renderOrder.

const { data: invoice } = useInvoice(invoiceId);
<InvoiceDetail
invoice={invoice}
actions={
<>
<InvoiceDownloadButton invoice={invoice} />
<InvoicePayButton invoice={invoice} payHref={invoicePayPath(invoice.id)} />
</>
}
renderOrder={(orderNumber) => (
<Link href={orderPath(orderNumber)}>View order {orderNumber}</Link>
)}
/>;

Props

PropTypeDescription
invoiceInvoiceSummary

The resolved invoice. The caller owns the query; the view only renders.

actions?ReactNode

Download / pay actions — composed by the caller (site owns the pay page).

renderOrder?((orderNumber: string) => ReactNode)

Render one billed order in the standard layout, e.g. a link to the order page.

children?ReactNode

Compose the pieces (InvoiceDetailHeader / InvoiceAmounts / InvoiceOrderRefs) for a bespoke layout, or omit for the standard one.

Any other props go to the root element, like on a plain HTML element.

Change the layout

Pass children to InvoiceDetail to arrange its parts yourself. Every part is exported from @evolve-storefront/feature-invoice/invoice-detail.

const { data: invoice } = useInvoice(invoiceId);
<InvoiceDetail
invoice={invoice}
actions={
<>
<InvoiceDownloadButton invoice={invoice} />
<InvoicePayButton invoice={invoice} payHref={invoicePayPath(invoice.id)} />
</>
}
renderOrder={(orderNumber) => (
<Link href={orderPath(orderNumber)}>View order {orderNumber}</Link>
)}
/>;
InvoiceDetailLayoutThe standard arrangement of the detail parts — what InvoiceDetail renders when given no children.
PropTypeDescription
invoiceInvoiceSummary
actions?ReactNode
renderOrder?((orderNumber: string) => ReactNode)
InvoiceAmountsThe invoice's amount breakdown: total, amount paid, amount due.
PropTypeDescription
invoiceInvoiceSummary

Any other props go to the root element, like on a plain HTML element.

InvoiceDetailHeaderInvoice detail header: number, dates, status, and actions.
PropTypeDescription
invoiceInvoiceSummary
children?ReactNode

Download / pay actions, composed by the caller.

Any other props go to the root element, like on a plain HTML element.

InvoiceDownloadButtonDownload link for the invoice document, where the issuer publishes one (invoiceUrl).
PropTypeDescription
invoiceInvoiceSummary
isPending?boolean

The action is running. The button keeps its label, so its width and accessible name hold, shows a spinner and is aria-busy. It stays focusable rather than disabled, so focus is not dropped mid-save; clicks and the form submit it would trigger are ignored. Not supported with asChild.

type?"reset" | "button" | "submit"
variant?ButtonVariant
tone?ButtonTone
isIconOnly?boolean

The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that.

More

Not inferred from the children. A kebab menu is as often a glyph or an SVG as an <Icon>, and a visually-hidden label — the accessible thing to add — would break any :only-child predicate silently.

data-component?undefined

A button's identity is not a caller's to rename — see buttonProps. Typed away so a call site renaming it fails to compile rather than passing an attribute that is silently discarded.

Any other props go to the root element, like on a plain HTML element.

InvoiceOrderRefsThe orders an invoice bills.

An order number, not a reference. An invoice is a payment record and carries no order ids, so a caller that wants a link builds the href from the number itself. renderOrder is that seam, and it is how this feature reaches the Orders feature without importing it.

PropTypeDescription
invoiceInvoiceSummary
renderOrder?((orderNumber: string) => ReactNode)

Render one billed order (e.g. a link into the Orders feature).

Any other props go to the root element, like on a plain HTML element.

InvoicePayButtonPay action for an invoice.

Gated on payable, never on the balance. An invoice with a charge already underway still shows an amount due, and only the engine knows a payment is in flight — deriving the control from amountDue > 0 is how the same amount gets collected twice. Paid, part-paid and credited invoices all render; only a payable one grows a control.

The label names the balance, not the total. A part-paid invoice is charged what is left, and a button reading the total beside a smaller charge reads as a bug. The figure comes from amountDue — the storefront never computes it, and never sends it: the engine derives the charge from the same amounts.

PropTypeDescription
invoiceInvoiceSummary
isPending?boolean

The action is running. The button keeps its label, so its width and accessible name hold, shows a spinner and is aria-busy. It stays focusable rather than disabled, so focus is not dropped mid-save; clicks and the form submit it would trigger are ignored. Not supported with asChild. In-flight state for the handoff.

type?"reset" | "button" | "submit"
variant?ButtonVariant
tone?ButtonTone
isIconOnly?boolean

The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that.

More

Not inferred from the children. A kebab menu is as often a glyph or an SVG as an <Icon>, and a visually-hidden label — the accessible thing to add — would break any :only-child predicate silently.

data-component?undefined

A button's identity is not a caller's to rename — see buttonProps. Typed away so a call site renaming it fails to compile rather than passing an attribute that is silently discarded.

onPay?(() => void)

Start the pay handoff. The site owns /invoice/:id/pay.

payHref?string

Or link straight to the site's pay page instead of a callback.

Any other props go to the root element, like on a plain HTML element.

InvoicePayFormThe picker and the pay control, with the chosen method between them.

This is where the client boundary sits. "use client" is contagious, so putting the state on the surface that composes this would ship the whole invoice-detail subtree — the amounts, the billed orders, the download link — to a consumer who renders none of it interactively.

The control names the balance. A part-paid invoice is charged what is left, and a button reading the total beside a smaller charge reads as a bug. The figure is amountDue and it is never sent: the engine derives the charge from the same amounts, so a client naming a figure is a client that can name the wrong one.

The chosen method is local and never leaves. Nothing is written while choosing, so abandoning the page loses nothing and there is no half-finished state to resume.

PropTypeDescription
invoicePick<InvoiceSummary, "amountDue" | "payable">
methodsreadonly InvoicePaymentMethod[]
onPay(method: InvoicePaymentMethodInput) => void
isPaying?boolean
error?ReactNode

Shown above the control when starting the payment failed.

emptyState?ReactNode

Rendered when no method is offered for this invoice, buyer and device.

renderPaymentMethodIcon?((method: InvoicePaymentMethod) => ReactNode)

Each method's logo, passed on to PaymentMethods' renderIcon.

Any other props go to the root element, like on a plain HTML element.

InvoiceStatusBadgeStatus badge.

Tolerates a status it does not know, because TONE[status] looks total to tsc while a storefront's union lags the framework schema routinely. Without t.has, t(status) logs MISSING_MESSAGE and renders its own key path — a shopper reads Invoice.status.WRITTEN_OFF in the badge. The ?? is belt and braces: StatusBadge defaults tone itself, and this keeps the fallback from depending on that.

PropTypeDescription
statusInvoiceStatus
data-component?undefined

A badge's identity and its published axes are not a caller's to rename. Typed away so a call site renaming one fails to compile rather than silently shadowing the selector a brand was told to use.

data-tone?undefined
data-status?undefined

Any other props go to the root element, like on a plain HTML element.

Loading and empty states

Render these while the data loads or when there is nothing to show, so the page does not jump when it arrives.

LoadingInvoiceDetailSkeletonLoading state for InvoiceDetail — the same flex flex-col gap-8 outer section with a header, the amounts block and the billed-orders list.

Examples

ComposedThe parts as children, for a bespoke page.
// Children replace the standard layout: only what you compose renders.
<InvoiceDetail invoice={invoice}>
<InvoiceDetailHeader invoice={invoice}>
<InvoiceDownloadButton invoice={invoice} />
</InvoiceDetailHeader>
<InvoiceAmounts invoice={invoice} />
</InvoiceDetail>;
Open in Storybook ↗