Skip to content

QuoteDetail

Presentation of a quote or quote request. It never fetches the record; the caller passes the resolved one.

import { QuoteDetail } from "@evolve-storefront/feature-quote/quote-detail";

Renders inside QuoteProvider.

Design notes

Ordering happens here, on the quote — the thing the buyer is agreeing to is what they should be looking at while they agree to it. The affordance is gated on the ordering grant and on an orderable status inside the validity window, and a quote becomes an order directly rather than via a basket, the backend carrying the negotiated prices a cart would reprice away.

Paying does not happen here. Ordering produces an order, and an order is paid the way every other order is. This view once carried a payment picker; the whole surface is gone.

Preview

Orderable QuoteOpen in Storybook ↗

Usage

A pending quote inside its window. One control, and clicking it turns the quote into an order — there is no method to choose first, because ordering no longer takes a payment.

const { data: quote } = useQuote(quoteId);
<QuoteDetail
quote={quote}
kind="quote"
// A node from your own client island, so this view and its parts stay
// Server Components. Stories pin `now`; a page omits it.
orderAction={<OrderQuoteButton quote={quote} />}
/>;
// OrderQuoteButton is the island `orderAction` receives. It owns the mutation and
// the routing, so nothing else on the page has to be a Client Component.
"use client";
export function OrderQuoteButton({ quote }: { quote: Quote }) {
const placeOrder = usePlaceQuoteOrder();
const router = useRouter();
return (
<QuoteOrderAction
quote={quote}
isPlacing={placeOrder.isPending}
onOrder={() =>
placeOrder.mutate(
{ quote },
{ onSuccess: ({ order }) => router.push(orderPaths(order.id).detail) },
)
}
/>
);
}

Props

PropTypeDescription
quoteQuote | QuoteRequest

The resolved quote or quote request. The caller owns the query.

kindQuoteKind

Which kind — quotes offer the order action, requests don't.

orderAction?ReactNode

The control that turns this quote into an order, where the page offers one. Rendered in the receipt under the total, on a quote and never on a request; omit it and no ordering affordance renders at all.

More

A node rather than a callback. Placing an order is the app's to wire, and a function prop makes every caller a Client Component — use client is contagious, so adopting this layout used to ship QuoteDetailHeader, QuoteLineItems, QuoteMessages, QuoteAmountBreakdown and QuoteDetailAddresses to the browser, none of which has any reason to go there. Nothing signalled it either: the page rendered correctly either way.

Pass QuoteOrderAction from your own client island — it is exported from this path, and it takes onOrder / isPlacing / refusedStatus / hasFailed itself, which is why those are no longer props here.

answerAction?ReactNode

The control that replies to or declines this quote, where the page offers one: QuoteAnswerAction. Rendered under the order action, on a quote only.

now?Date

"Now" for the validity check; defaults to the current time.

renderItem?QuoteLineItemRenderer<Quote | QuoteRequest["lineItems"][number]>

Replace the line rows in the default layout. Ignored when children is given — compose it onto QuoteLineItems yourself there. The item type comes from quote, so an extended query's added fields are typed here with no cast.

requestComment?ReactNode

What the buyer wrote when they asked. A request passes nothing: its own comment is that message and the layout reads it. A quote has to be given it, because it lives on the request record — Quote.comment is the buyer's reply to the offer, a different message at the other end of the thread.

requestedBy?string

Who asked, where the page knows. Not a field on the quote: the requester is the project's to resolve, from its own extension or its own directory.

orderLink?ReactNode

The order this quote became, as a link. A quote is either still orderable — the receipt offers it — or it has been ordered, and then the order is what the buyer came for; the two are never both present, so they share the slot under the total.

More

A node, for the same reason orderAction is one. It was a render prop taking Quote.order, which the caller already has on the quote it passed in — so the function bought nothing and cost the whole subtree its server rendering.

Rendered only where the quote has an order, so a page can hand one over unconditionally.

headingLevel?3 | 1 | 2

The heading level for the record's title. A detail page is usually 1.

children?ReactNode

Compose the pieces (QuoteDetailHeader / QuoteLineItems / QuoteOrderButton) 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 QuoteDetail to arrange its parts yourself. Every part is exported from @evolve-storefront/feature-quote/quote-detail.

// Children replace the standard layout: only what you compose renders.
<QuoteDetail quote={quote} kind="quote">
<QuoteDetailHeader entry={quote} kind="quote" />
<QuoteLineItems items={quote.lineItems} />
<QuoteAmountBreakdown quote={quote} />
</QuoteDetail>;
QuoteDetailLayoutWhat QuoteDetail renders when given no children, in the order detail's shape: the title, the facts, the negotiation (read before the basket, since a total €790 off means nothing until you have read what took it off), the delivery address, then the lines with a sticky receipt.
PropTypeDescription
quoteQuote | QuoteRequest

The resolved quote or quote request. The caller owns the query.

kindQuoteKind

Which kind — quotes offer the order action, requests don't.

now?Date

"Now" for the validity check; defaults to the current time.

orderAction?ReactNode

The control that turns this quote into an order, where the page offers one. Rendered in the receipt under the total, on a quote and never on a request; omit it and no ordering affordance renders at all.

More

A node rather than a callback. Placing an order is the app's to wire, and a function prop makes every caller a Client Component — use client is contagious, so adopting this layout used to ship QuoteDetailHeader, QuoteLineItems, QuoteMessages, QuoteAmountBreakdown and QuoteDetailAddresses to the browser, none of which has any reason to go there. Nothing signalled it either: the page rendered correctly either way.

Pass QuoteOrderAction from your own client island — it is exported from this path, and it takes onOrder / isPlacing / refusedStatus / hasFailed itself, which is why those are no longer props here.

answerAction?ReactNode

The control that replies to or declines this quote, where the page offers one: QuoteAnswerAction. Rendered under the order action, on a quote only.

orderLink?ReactNode

The order this quote became, as a link. A quote is either still orderable — the receipt offers it — or it has been ordered, and then the order is what the buyer came for; the two are never both present, so they share the slot under the total.

More

A node, for the same reason orderAction is one. It was a render prop taking Quote.order, which the caller already has on the quote it passed in — so the function bought nothing and cost the whole subtree its server rendering.

Rendered only where the quote has an order, so a page can hand one over unconditionally.

renderItem?QuoteLineItemRenderer<Quote | QuoteRequest["lineItems"][number]>

Replace the line rows in the default layout. Ignored when children is given — compose it onto QuoteLineItems yourself there. The item type comes from quote, so an extended query's added fields are typed here with no cast.

requestComment?ReactNode

What the buyer wrote when they asked. A request passes nothing: its own comment is that message and the layout reads it. A quote has to be given it, because it lives on the request record — Quote.comment is the buyer's reply to the offer, a different message at the other end of the thread.

requestedBy?string

Who asked, where the page knows. Not a field on the quote: the requester is the project's to resolve, from its own extension or its own directory.

headingLevel?3 | 1 | 2

The heading level for the record's title. A detail page is usually 1.

QuoteAmountBreakdownA quote's amounts as an offer: the items at their regular price, the seller's discount, delivery, then net, VAT and the gross total.

The rows are net throughout and read the amounts rather than summing the lines, so they satisfy subTotal − discountTotal + shippingCosts = total however the seller priced. A reverse-charged or exported quote (vatOutcome) is taxed at 0% and gets a noted zero VAT row.

PropTypeDescription
quoteQuote | QuoteRequest

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

QuoteAnswerActionReply to a pending quote asking for a different offer, or decline it, each confirmed in place.

Shown under the same rule as ordering: the ordering grant and a quote that is pending inside its validity window. A refusal naming a status withdraws both.

PropTypeDescription
quoteQuote
onReplied?((quote: Quote) => void)

Called with the quote as it stands after the reply was sent.

onDeclined?((quote: Quote) => void)

Called with the declined quote.

now?Date

"Now" for the validity check; defaults to the current time.

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

QuoteDetailHeaderThe record's title, with the page's action beside it: "Quote #PO-8823".
PropTypeDescription
entryPick<Quote | QuoteRequest, "purchaseOrderNumber">
kind?QuoteKind

Which kind, so the title says which record this is.

action?ReactNode

The page's action, top right. A node, so the header stays server-renderable.

headingLevel?3 | 1 | 2

The heading level for the record's title. A detail page is usually 1.

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

QuoteDetailInfoThe quote's facts as one table, as the order detail has them: the seller's quote number, when it was requested and by whom, until when it is valid, its status and how it is delivered.
PropTypeDescription
entryQuote | QuoteRequest
requestedBy?string

Who asked, where the page knows. Not a field on the quote: the project resolves it.

now?Date

"Now" for the validity check; defaults to the current time. The same now the ordering action gets, or the badge reads Expired above an enabled Order button.

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

QuoteLineItemsThe quote's priced line items.
PropTypeDescription
itemsQuoteLineItem[]

The quoted lines. Taking the items rather than the whole entry is what carries an extended query's item type through — items={myLines} infers Item, so renderItem sees the added fields with no cast.

renderItem?QuoteLineItemRenderer<QuoteLineItem>

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

QuoteMessagesThe negotiation, in the order it happened.

A quote is an exchange, and rendered as labelled fields it reads as metadata about a record. Aligned and tinted per author it reads as what it is, and the arrangement says who spoke before the label is read. That matters most on the one page it is missing from: a total that is €790 off means nothing until you have read what took it off, which is why this goes above the basket rather than under it.

Renders nothing when there is nothing to show, so a caller passes what it has rather than guarding.

PropTypeDescription
ask?ReactNode

What the buyer wrote when they asked — the message that opened this.

More

It lives on the quote request, so a request passes its own comment and a quote has to be given it: Quote.comment is a different message. A quote whose page cannot reach the request simply omits this and the thread starts at the answer.

answer?ReactNode

What the seller said — Quote.sellerComment.

reply?ReactNode

What the buyer said about the offer that came back — Quote.comment.

buyerName?string

Who the buyer is, where the page knows. Inside a company the useful answer is which colleague wrote it, and that is a name this package cannot look up — the requester is not a field on the quote. Defaults to a role label.

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

QuoteOrderActionTurn the quote into an order.

Renders nothing unless the buyer holds the ordering grant and the quote is orderable inside its validity window — a separate grant from requesting, because ordering is the acceptance. A refusal naming a status withdraws the control; a plain failure keeps it.

PropTypeDescription
quoteQuote
onOrder() => void

Turn the quote into an order. Wire it to usePlaceQuoteOrder and route to the order it hands back — including when that order cannot be paid yet, because an order held for approval is still the buyer's order.

isPlacing?boolean
refusedStatus?QuoteStatus | null

The status a refusal reported, when the attempt was refused because the quote is no longer orderable. Setting it withdraws the affordance: retrying cannot succeed, so only the reason is left on the page.

hasFailed?boolean

A refusal that named no status — a validation failure. The control stays, because retrying is exactly what the buyer should do.

now?Date

"Now" for the validity check; defaults to the current time.

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

QuoteOrderRefusalWhy the attempt was refused.

Keyed on the status the refusal reported, never on its message, which is developer-facing and unlocalized. A refusal with no status gets the generic retry, because a validation failure says nothing about the quote itself.

PropTypeDescription
status?QuoteStatus | null

The status a refusal reported. It can disagree with the quote's own status and the refusal is the authority: the server compares the validity date at conversion, so a quote reading PENDING here can be refused as expired.

hasFailed?boolean

A refusal that named no status — a validation failure.

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

QuoteStatusBadgeQuote / request status badge, labelled from the Quote.status namespace.

Pass validUntil (and optionally a fixed now) and it also shows EXPIRED for a PENDING quote past its date, which the server only reports on the next read.

PropTypeDescription
status"PENDING" | "ACCEPTED" | "DECLINED" | "RENEGOTIATION_REQUESTED" | "EXPIRED" | "CANCELLED" | "SUBMITTED" | "REJECTED"
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
validUntil?string | null

A quote's validity deadline; expiry is derived from it.

now?Date

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.

LoadingQuoteDetailSkeletonLoading state for QuoteDetail — the same flex flex-col gap-8 outer section.