Implementation guide
Seven steps for the confirmation — the URL that is the whole contract, why a cold deep link has to work, and what an agency can build on top of it: a lookup form, and its own ledger reading the same booking.
Mount it at the URL with the reference in it
/booking-confirmation?pnr=…is the contract: read the reference out of the query string and pass it asbookingRef. In the App Router,useSearchParamsneeds aSuspenseboundary — that is the only wrinkle, and the boundary never actually shows.app/booking-confirmation/page.tsx 'use client'; import { Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import { BOOKING_REF_PARAM, BookingConfirmation } from '@sheriph/fk-ui/confirmation'; /** * The confirmation route: /booking-confirmation?pnr=<record locator> * * That URL *is* the contract. Read the reference out of it, hand it to the * stage, and let the stage ask the server for the booking — which is what makes * this page work from a cold deep link, days later, on another machine. */ function Confirmation() { // The parameter name comes from the package, so the links it builds and the // pages that read them cannot disagree about it. const reference = useSearchParams().get(BOOKING_REF_PARAM) ?? ''; return ( <BookingConfirmation bookingRef={reference} // Where a new search starts, for the "nothing to show" states. Omitted, // they explain themselves without a call to action. searchHref="/" /> ); } export default function Page() { return ( <main className="mx-auto max-w-5xl px-4 py-8"> {/* useSearchParams needs a Suspense boundary in the App Router. The boundary resolves in a microtask, so it never shows. */} <Suspense fallback={null}> <Confirmation /> </Suspense> </main> ); }Expect a cold deep link, not a warm flow
This is the one stage users bookmark, screenshot and email to themselves, so it must render from a URL alone — empty store, no tab, another machine, next month — and show what the airline holds then. That is why the reference is the request, and why the stage reads the store only to decide which booking to show, never what is in it.
The practical consequence for you: do not render the booking from your own checkout draft, and do not keep its content in the store to save a request. A price is a fact that expires; a booking is a document, so this stage refreshes it with a short
staleTimeand never retries a “not found”.A lookup form is an input and this URL
There is no retrieval component in the package, on purpose: this page is the retrieval, so publishing a component for one input plus a redirect would be surface area for nothing. If you want “find my booking” in your header, it is the snippet below — and the reference normalisation (trim, upper-case, encode) is already in the helper.
app/find-booking.tsx — the whole lookup 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { bookingConfirmationHref } from '@sheriph/fk-ui/confirmation'; /** * "Find my booking" — the whole thing. * * This is why the package has no retrieval component: a lookup is an input and a * navigation, and the confirmation page is already the retrieval. The helper * builds the URL, and it normalises the reference for you — trimmed, upper-cased * and encoded — so a traveller who types " fkd101 " still finds their booking. */ export function FindBooking() { const router = useRouter(); const [reference, setReference] = useState(''); return ( <form onSubmit={(event) => { event.preventDefault(); router.push(bookingConfirmationHref(reference)); }} > <label htmlFor="pnr">Booking reference</label> <input id="pnr" name="pnr" value={reference} onChange={(event) => setReference(event.target.value)} autoComplete="off" /> <button type="submit">Find booking</button> </form> ); }Skip the fetch when you already have the booking
Pass
bookingand the stage renders it without asking the API — the right shape for an agency with its own booking backend, or a page that resolved the booking server-side. AddhideActionsfor an embedded context or an email preview, andsupportEmailso the support actions point at you.The two action overrides replace behaviour rather than decorate it:
onDownloadPdfandonSendEmailare for an agency that emails through its own provider or stores tickets itself. Without them the stage does the obvious thing itself.Brand it with one colour
primaryColorsets the masthead band, the section rules, the table headers and the receipt total, with the tint and shade derived from it — one colour, not a palette. Your own primary is the right value here, and an agency that chose a colour in its dashboard still wins over it: they picked theirs deliberately, which is more considered than a theme default.Keep it reasonably dark, because the confirmation is also printed white. And resist the urge to colour the status strip: a confirmed booking is green in every tenant, because status is a fact about the booking, not a brand.
The actions are honest about what they did
Worth knowing before you wire them: the PDF is generated when the button is pressed, never on mount, and a failed render surfaces inline with print this page instead as the fallback. The share dialog says your mail app is opening unless you inject a sender — it does not claim an email was sent, because it cannot know. And the calendar file is built from the raw segments, never from the formatted labels on screen: the times we hold are local wall clocks, and a
Zwould move the flight by the reader's offset.Reuse the mapping for your own ledger
A support screen or a bookings dashboard needs the same facts this page shows, and the interpretation is exported for exactly that reason: one booking described once. Read the server's answer through
toBookingRecordrather than picking fields out of the payload yourself, and label the status with the same helpers — includingisInactive, which is what decides that cancelled and expired bookings disable their actions with the reason shown.your ledger — the same booking, the same words import { statusLabel, statusTone, toBookingRecord } from '@sheriph/fk-ui/confirmation'; /** * The agency's ledger, or a support screen: the same booking, labelled the same * way as the receipt labels it. * * toBookingRecord is the whole of the display logic — pure, no renderer, no * network — so a second surface reads the server's answer through the same * interpretation the confirmation page used, instead of its own. */ const record = toBookingRecord(retrieved); console.log(record.pnr); // the record locator console.log(statusLabel(record.status)); // 'Confirmed' — not the raw enum console.log(statusTone(record.status)); // the tone the chip would use
BookingConfirmation — props
- bookingRef
- string
The record locator to render — normally straight from the URL. Omitted, the stage falls back to the reference a just-completed checkout left on the store, which is what makes the post-booking redirect work without passing anything.
- booking
- BookingRecord
A booking you already retrieved, which skips the fetch entirely. For an agency that looks the booking up on its own backend, or a page that already has it server-side.
- supportEmail
- string
Contact address for the support actions. Yours, not the traveller's.
- hideActions
- boolean=
falseHides the post-booking actions card. For an embedded context or an email preview, where "download the PDF" is not something the reader can do.
- onDownloadPdf
- (bookingRef) => void | Promise<void>
Overrides the download. Without it the stage calls POST /bookings/pdf itself and saves the bytes. The PDF is generated on the action, never on mount.
- onSendEmail
- (email, bookingRef) => void | Promise<void>
Overrides 'email the itinerary'. Without it the share dialog hands off to the reader's mail client and says so rather than claiming a send.
- searchHref
- string
Where a search starts, for the states that invite one. Only you know this route, so the component cannot default it.
- primaryColor
- string
The brand colour the confirmation is set in: masthead band, section rules, table headers and the receipt total. Your own primary goes here; an agency that set its own colour in the dashboard wins over both. Printed white, so keep it reasonably dark.
- className
- string
Merged onto the page container.
Exported alongside it — the URL convention and the mapping
- bookingConfirmationHref
- (bookingRef, base?) => string
Builds the URL this stage is mounted at, normalising the reference. The optional base is for a consumer mounting it somewhere else — it keeps the parameter name and the encoding in one place.
- BOOKING_REF_PARAM
- 'pnr'
The parameter name. Read it from here rather than writing the string, so your lookup form and the links cannot drift.
- toBookingRecord
- (retrieved: RetrievedBooking) => BookingRecord
The server's answer as the receipt view model. Pure and exported, so a second surface shows the same booking rather than a second interpretation of it.
- statusLabel · statusTone · isInactive
- (status) => string · tone · boolean
The status copy the receipt uses, for a dashboard that labels a booking the same way. isInactive is true for cancelled and expired — the states where the actions are disabled, with the reason shown.
- BookingRecord
- type
The receipt: status, legs, passengers, payment. Deliberately never the fetch payload — the ticket PDF body is that shape only structurally, so the page and the printed ticket cannot describe one booking two ways.
Code samples
The route whole, then the two things an agency builds on top of it.
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { BOOKING_REF_PARAM, BookingConfirmation } from '@sheriph/fk-ui/confirmation';
/**
* The confirmation route: /booking-confirmation?pnr=<record locator>
*
* That URL *is* the contract. Read the reference out of it, hand it to the
* stage, and let the stage ask the server for the booking — which is what makes
* this page work from a cold deep link, days later, on another machine.
*/
function Confirmation() {
// The parameter name comes from the package, so the links it builds and the
// pages that read them cannot disagree about it.
const reference = useSearchParams().get(BOOKING_REF_PARAM) ?? '';
return (
<BookingConfirmation
bookingRef={reference}
// Where a new search starts, for the "nothing to show" states. Omitted,
// they explain themselves without a call to action.
searchHref="/"
/>
);
}
export default function Page() {
return (
<main className="mx-auto max-w-5xl px-4 py-8">
{/* useSearchParams needs a Suspense boundary in the App Router. The
boundary resolves in a microtask, so it never shows. */}
<Suspense fallback={null}>
<Confirmation />
</Suspense>
</main>
);
}'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { bookingConfirmationHref } from '@sheriph/fk-ui/confirmation';
/**
* "Find my booking" — the whole thing.
*
* This is why the package has no retrieval component: a lookup is an input and a
* navigation, and the confirmation page is already the retrieval. The helper
* builds the URL, and it normalises the reference for you — trimmed, upper-cased
* and encoded — so a traveller who types " fkd101 " still finds their booking.
*/
export function FindBooking() {
const router = useRouter();
const [reference, setReference] = useState('');
return (
<form
onSubmit={(event) => {
event.preventDefault();
router.push(bookingConfirmationHref(reference));
}}
>
<label htmlFor="pnr">Booking reference</label>
<input
id="pnr"
name="pnr"
value={reference}
onChange={(event) => setReference(event.target.value)}
autoComplete="off"
/>
<button type="submit">Find booking</button>
</form>
);
}import { statusLabel, statusTone, toBookingRecord } from '@sheriph/fk-ui/confirmation';
/**
* The agency's ledger, or a support screen: the same booking, labelled the same
* way as the receipt labels it.
*
* toBookingRecord is the whole of the display logic — pure, no renderer, no
* network — so a second surface reads the server's answer through the same
* interpretation the confirmation page used, instead of its own.
*/
const record = toBookingRecord(retrieved);
console.log(record.pnr); // the record locator
console.log(statusLabel(record.status)); // 'Confirmed' — not the raw enum
console.log(statusTone(record.status)); // the tone the chip would use