Flight Kit

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.

  1. 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 as bookingRef. In the App Router, useSearchParams needs a Suspense boundary — 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>
      );
    }
  2. 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 staleTime and never retries a “not found”.

  3. 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>
      );
    }
  4. Skip the fetch when you already have the booking

    Pass booking and 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. Add hideActions for an embedded context or an email preview, and supportEmail so the support actions point at you.

    The two action overrides replace behaviour rather than decorate it: onDownloadPdf and onSendEmail are for an agency that emails through its own provider or stores tickets itself. Without them the stage does the obvious thing itself.

  5. Brand it with one colour

    primaryColor sets 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.

  6. 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 Z would move the flight by the reader's offset.

  7. 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 toBookingRecord rather than picking fields out of the payload yourself, and label the status with the same helpers — including isInactive, 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= false

Hides 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.

app/booking-confirmation/page.tsx — the whole page
'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>
  );
}
app/find-booking.tsx — a lookup form, complete
'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>
  );
}
your ledger — toBookingRecord and the status helpers
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