No flight selected
This page books a fare you have already chosen. Pick a flight from the results and your selection will be waiting here.
Back to resultsImplementation guide
Seven steps for the checkout — the one page, the destination it redirects to, where the selection comes from, and the two rules that are not negotiable: no traveller PII in persisted state, and no second submit.
Mount it on your checkout route
One page, one submit. There is no wizard, no step indicator and no review page — nothing about the transaction needs the division, and a step whose only job is to restate the previous one is a tax on the traveller. So there is nothing to arrange: you mount it, and it renders the traveller blocks, the contact card, the payment terms and the total.
app/checkout/page.tsx — the whole page 'use client'; import { BookingFlow } from '@sheriph/fk-ui/checkout'; import { bookingConfirmationHref } from '@sheriph/fk-ui/confirmation'; /** * The checkout route. That is the whole page. * * No selection is passed in and nothing is loaded: the results stage wrote the * chosen offer to the store on Book, so this mounts into a selection it already * has — including after a hard refresh, which is what the reference in the store * is for. */ export default function CheckoutPage() { return ( <main className="mx-auto max-w-6xl px-4 py-8"> <BookingFlow // Where the traveller's results live, for the "nothing selected" state. resultsHref="/search-result" // A function of the new reference, because the confirmation page // retrieves the booking *from the URL* — so the reference has to be in // it. Omit this and the stage renders its own terminal "Seats held" card // instead of sending anyone anywhere. confirmationHref={(ref) => bookingConfirmationHref(ref)} /> </main> ); }Give it a destination, not a link
Nobody should have to find their own way from a held booking to its receipt, so the redirect is the stage's job: pass
confirmationHrefand it sends the browser there the moment the booking exists. Pass a function of the new reference when your confirmation page retrieves the booking from the URL — then the reference has to be in that URL, and the function is where it gets there. The package exportsbookingConfirmationHrefso the parameter name lives in one place.It is a hard navigation, deliberately: this package imports no router, because a receipt is the one screen where a full page load costs nothing and every consumer's routing stays in their own app. Omit the prop entirely — you have no confirmation page — and the stage renders its own “Seats held” card instead, with the reference on it. That is the only case that card exists for.
Pass nothing: the selection is already in the store
The Book button wrote it there. What it wrote is the reference —
searchId,offerRefsand the passenger mix — not the offer itself, which is why a hard refresh resumes the booking: the persisted slice is enough for the server to re-resolve and re-price the offer for your agency withPOST /flights/offer.That same call is why the itinerary card and the fare rows here are the results stage's own component and mapping: what is booked is what was chosen, down to the rows. And it is why a stale price is possible at all — if the fare has moved, the server says so, and the message you see is the server's own.
The two small props
resultsHrefgives the “nothing selected” state a way back, andonCompletetells you the booking reference once it exists — for your analytics or an agency-side ledger, not for the redirect.app/checkout/page.tsx — with the optional pair 'use client'; import { BookingFlow } from '@sheriph/fk-ui/checkout'; /** * The two small ones, in the shapes they are actually for. */ export function CheckoutPage({ sessionEmail, onBookingHeld, }: { sessionEmail?: string; onBookingHeld: (reference: string) => void; }) { return ( <BookingFlow // A convenience, not a default: it prefills the contact inbox and the // traveller can still change it. Use the address of the person doing the // booking, never a stored one from a previous passenger. contactEmail={sessionEmail} // Fires after the store is updated, so this reference is the one the // confirmation page will retrieve with. Analytics, an agency-side ledger, // a webhook — your call. Never a second redirect: the stage is already // navigating. onComplete={(result) => onBookingHeld(result.bookingRef)} /> ); }Never let traveller PII reach persisted state
Names, dates of birth, passport numbers and expiry, and the contact details must live in component state and nowhere else — not the store's persisted slice, not a URL, not
localStorageorsessionStorage. This is a compliance requirement, not a style preference: on a shared device, a form that rehydrates from yesterday is a reportable data leak.The consequence is deliberate: a refresh loses the form. The offer survives (that is a reference), the traveller does not. The server's booking ledger keeps the passenger's type and name only — never the document.
Expect the server to refuse, and show why
Two refusals are normal rather than exceptional. The search cache can expire between the results page and the submit, and a
409means a leg is gone — the usual cause is an airline your agency disabled in the pricing dashboard. Both are refused rather than booked: half a round trip is worse than no booking, and the traveller is better served by a sentence naming the carrier than by a locator they will lose.Nothing here retries on its own. A booking attempt is not idempotent — that is what step 7 is about.
Why submit blocks, and what happens after the PNR
Submitting covers the page with a blocking
TaskProgresssurface carrying the itinerary and the total, with every entered value still visible behind it. Blocking is the point and not a style choice: a second submit creates a second PNR, which is a financial event, and a disabled button alone does not stop a second tab, a double-tap or a keyboard repeat. On failure the surface closes and focus returns to Submit, so the error is reachable and nothing typed is lost.There are no add-ons, and no card number. Bags and seats are sold after the PNR exists — that is the confirmation stage's job — and payment here is stated, not taken: the server commits a held, pay-later reservation and has no instrument path, so a field holding a card number would have nowhere to go. The passenger mix is not a prop either: the airline priced the fare by those types, so the form offers no way to disagree with them.
BookingFlow — props
- selectedOffer
- SelectedOffer
Advanced: book this selection instead of the store's one. For a re-book flow or a server-rendered page with its own reference.
- contactEmail
- string
Prefills the contact inbox. The traveller can change it — the address on the booking is theirs, not the session's.
- confirmationHref
- string | ((bookingRef: string) => string)
A destination, not a link: passed it, the stage navigates the browser itself the moment the booking is held. Use the function form when your confirmation page retrieves the booking from the URL, so the new reference is in it. Omitted, the stage shows its own terminal “Seats held” card — the only case that card exists for.
- resultsHref
- string
Where your results page lives. Used only by the “nothing selected” state, so the reader has a way back instead of a dead end.
- onComplete
- (result: BookingResult) => void
Fires after success, once the store holds the reference. For analytics or an agency-side ledger — not for the redirect, which the stage does itself.
- className
- string
Merged onto the page container.
BookingFlow — the types it hands you
- Traveller
- { type, given, surname, birthDate, gender, nationality?, documentNumber?, documentExpiry? }
Exactly the fields the booking API carries — no title, no visa, no document issue country. The passport is collected because the server sends it on the traveller's TravelDocument, which is where the airline files a document and its holder's nationality.
- ContactDetails
- { email, phone }
One inbox and one phone for the whole party; the server sends both on every traveller payload.
- FieldErrors
- Record<string, string>
Keyed by field path, so a field looks up its own error with the same key it uses for aria-describedby — and “focus the first invalid field” is a lookup rather than a search.
- BookingResult
- { bookingRef, status, completedAt }
What a held booking is, as far as your app is concerned. The payment figures stay on the server.
Code samples
The page whole, then the optional props in the shapes they are actually for.
'use client';
import { BookingFlow } from '@sheriph/fk-ui/checkout';
import { bookingConfirmationHref } from '@sheriph/fk-ui/confirmation';
/**
* The checkout route. That is the whole page.
*
* No selection is passed in and nothing is loaded: the results stage wrote the
* chosen offer to the store on Book, so this mounts into a selection it already
* has — including after a hard refresh, which is what the reference in the store
* is for.
*/
export default function CheckoutPage() {
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<BookingFlow
// Where the traveller's results live, for the "nothing selected" state.
resultsHref="/search-result"
// A function of the new reference, because the confirmation page
// retrieves the booking *from the URL* — so the reference has to be in
// it. Omit this and the stage renders its own terminal "Seats held" card
// instead of sending anyone anywhere.
confirmationHref={(ref) => bookingConfirmationHref(ref)}
/>
</main>
);
}'use client';
import { BookingFlow } from '@sheriph/fk-ui/checkout';
/**
* The two small ones, in the shapes they are actually for.
*/
export function CheckoutPage({
sessionEmail,
onBookingHeld,
}: {
sessionEmail?: string;
onBookingHeld: (reference: string) => void;
}) {
return (
<BookingFlow
// A convenience, not a default: it prefills the contact inbox and the
// traveller can still change it. Use the address of the person doing the
// booking, never a stored one from a previous passenger.
contactEmail={sessionEmail}
// Fires after the store is updated, so this reference is the one the
// confirmation page will retrieve with. Analytics, an agency-side ledger,
// a webhook — your call. Never a second redirect: the stage is already
// navigating.
onComplete={(result) => onBookingHeld(result.bookingRef)}
/>
);
}