Flight Kit

Implementation guide

Seven steps to mount this stage — including who owns the fetch, what to do on Book, and how to make a search survive a refresh — then everything it takes and everything it exports for your own surfaces.

  1. Mount it where the search lands

    A search arrives at a results page, so that is where this composite goes: the summary at the top, the list beneath it. The two never talk — the form publishes, the list subscribes — which is why nothing above needs wiring and why re-searching from the summary flips the list to loading on its own.

    app/results-stage.tsx — the canonical layout
    'use client';
    
    import { useRouter } from 'next/navigation';
    import { FlightResultsList } from '@sheriph/fk-ui/results';
    import { FlightSearchSummary } from '@sheriph/fk-ui/search';
    
    /**
     * The results page: the summary on top, the list below it.
     *
     * The summary rather than the hero form, because on this page the criteria are
     * already committed and the results want the vertical space — the traveller
     * edits them from the collapsed read-out.
     *
     * The two composites share **no props and no state**. Submitting the summary
     * publishes a new `searchRequest` (with a fresh `requestId`) to the fk-ui store,
     * and the list is subscribed to it, so it flips to loading and refetches on its
     * own. That is the whole cross-stage contract.
     */
    export function ResultsStage() {
      const router = useRouter();
    
      return (
        <>
          <FlightSearchSummary />
    
          <FlightResultsList
            defaultSort="cheapest"
            onBook={() => {
              // The list has already written the selection to the store, so routing
              // is the whole of your job. Note what is NOT happening: no offer data
              // in the URL, and no booking started here.
              router.push('/checkout');
            }}
          />
        </>
      );
    }
  2. Let it own the fetch

    This is the only stage that calls POST /flights/search. It subscribes to searchRequest, issues the request through your proxy, and owns the waiting, the aborting and the error — including aborting a search the moment a newer one starts. Without that last part a slow first response lands after a faster second one and overwrites the offers the traveller is already reading, which is a bug you would spend an afternoon not reproducing.

    So do not fetch the search yourself on this page. If you do need your own request for a different surface, that is step 6 — but if you are rendering results, mount this.

  3. Route on Book — and only route

    onBook fires after the selection is written to the store, so by the time your handler runs there is nothing left to save. Navigate to your checkout route and stop: the list holds the reference, and checkout re-resolves the priced offer from the server by searchId.

    Do not put the offer in the URL and do not start a booking here. An offer is multi-MB and contains a priced itinerary that will be re-priced anyway — the reference is the handoff.

  4. Make the search survive a refresh and a share

    The library knows nothing about routing: a composite publishes to the store and reports through a callback, and what a URL looks like is yours. So put the search on the query string and publish it back on mount — that is what makes a link shareable and survivable, because a cold load with an empty store still knows what to search for.

    Publish once, and skip it when the store already holds the same search. The normal path is form-publishes-then-routes, so the request is already in flight when the results page mounts; publishing again would burn a second multi-MB request for results that are already coming, and a second requestId means a second load state over them.

    Use replace, not push, when an edit writes a new query string: a search is not a document, and Back should leave the results rather than walk through every query tried. This app keeps the parse, the build and the comparison in src/lib/search-query.ts — one module, because two pages need the same mapping and a format with two implementations is a format with two formats.

    app/publish-from-url.tsx — a link is a search
    'use client';
    
    import { useEffect, useRef } from 'react';
    import { useSearchParams } from 'next/navigation';
    import { useFlightKitStore } from '@sheriph/fk-ui/core';
    
    import { paramsFromQuery, sameSearchParams } from '@/lib/search-query';
    
    /**
     * A link *is* a search: publish what it describes, exactly once.
     *
     * Two things are skipped, and both of them are a second multi-MB request for
     * results already in flight:
     *
     *  1. a query string this page has already published;
     *  2. a search the store already holds — the normal case when the traveller
     *     arrived from the form, which publishes *then* routes.
     */
    export function PublishFromUrl() {
      const query = useSearchParams().toString();
      const published = useRef<string | null>(null);
    
      useEffect(() => {
        const params = paramsFromQuery(new URLSearchParams(query));
        if (!params) return;                       // not a searchable route yet
        if (published.current === query) return;    // this page published it already
        if (sameSearchParams(useFlightKitStore.getState().searchRequest?.params, params)) return;
    
        published.current = query;
        useFlightKitStore.getState().requestSearch(params);
      }, [query]);
    
      return null;
    }
  5. Feed it the live response, never a fixture

    There is deliberately no offers prop and no pre-mapped shortcut. This component calls searchOffers and maps the answer with the shared transformer, and a prop that skipped that would make the transformer optional — exactly the contract it exists to consume.

    The reason is not purity. Travelport books through a reference-payload flow: the commit names an offer the server resolved from a search it actually ran, so an offer invented locally cannot be held, cannot receive a locator, and cannot be evidence that anything works. A results page that renders beautifully and cannot book is not a demo of a booking product. Point apiBase at your real proxy and there is nothing to configure.

  6. Reuse what it exports instead of re-deriving it

    The moment you need the same facts on a second surface — an email, a comparison page, a saved-selection row — reach for the exports rather than re-reading the payload. One interpretation is the point: a list and an email that disagree about a route or a price is two bugs pretending to be one.

    the same itineraries, somewhere else
    import {
      bestItineraryId,
      routeLabel,
      sortItineraries,
      toItinerariesFromResponse,
    } from '@sheriph/fk-ui/results';
    
    // The raw search response, mapped to exactly the itineraries the list shows.
    // `toItinerariesFromResponse` reads the trip type off the response itself, so a
    // three-leg multi-city payload is not silently welded into a fake round trip.
    const itineraries = toItinerariesFromResponse(response);
    
    console.log(routeLabel(itineraries[0]));          // 'LOS ⇄ LHR' — not 'LOS → LOS'
    console.log(describeItinerary(itineraries[0]));   // route · date · carrier
    
    const cheapestFirst = sortItineraries(itineraries, 'cheapest');
    const winner = bestItineraryId(itineraries);      // the 'best' ranking, if you want to mark one yourself
    
    // Sort on the number, never the string: '1000.00' sorts below '900.00'.
    const lowest = Math.min(...itineraries.map((itinerary) => itinerary.price));
  7. What it renders so you do not have to

    Knowing what you are not writing is most of the value, so: every state — idle, loading, reloading, success, empty, error, and the distinct “your filters excluded everything” state with its Clear filters affordance; a blocking TaskProgress surface over skeletons on the first load only, because re-searching keeps the offers you are reading on screen, dimmed; the sorter, the filter panel with its chips and mobile sheet, the pagination, the offer card and its detail tabs.

    Filtering, sorting and paging all run over the one response in memory — in that order, which is not arbitrary: filtering first is what makes the count and the pager describe what is actually on screen. Nothing refetches on a filter change. Page size is 10, and changing the sort, a filter or the grouping returns to page 1 — staying on page 4 of a set that now has 2 pages reads as “nothing found” when in fact the filters found plenty.

    Still queued in the package: grouping similar itineraries, and windowing the pager's page numbers for very long ranges. Neither is a prop you can turn on today.

FlightResultsList — props

searchRequest
SearchRequest

Advanced: render this request instead of reading the store. For a surface that is not the store-driven flow — a replayed search, a saved itinerary.

onBook
(selection: SelectedOffer) => void

Fires after the store is written, so the consumer only has to route. The payload is the reference — searchId, offerRefs, passengers — not the multi-MB offer behind it, because checkout re-resolves that server-side.

emptyState
ReactNode

Replaces the built-in zero-results state. Keep its advice to changing dates, airports or cabin: an empty list is usually a real answer, not a failure.

defaultSort
SortType= 'cheapest'

Seeds the first render only — sort is the list's own state afterwards. One of 'best', 'cheapest', 'fastest', 'earliest'. The package default is cheapest: price is the first question a traveller arrives with.

className
string

Merged onto the results region.

Exported alongside it — for your own surfaces

toItinerariesFromResponse
(response) => Itinerary[]

The raw search response as the itineraries the list would show. Use it for a second surface — a comparison page, an email — instead of hand-parsing the payload.

routeLabel
(itinerary) => string

LOS → LHR, LOS ⇄ LHR, or every leg joined for multi-city. Built from the directions, so a return never prints the origin twice.

describeItinerary
(itinerary) => string

Route, first departure date and carrier as one line — what a stored selection should say.

sortItineraries
(list, sort) => Itinerary[]

The same comparators the list uses, and deterministic — equal offers keep a fixed order rather than reshuffling between renders.

bestItineraryId
(list) => string | undefined

The best ranking on its own — fewest stops, then cheapest. Nothing in the library badges it: the list shows what the traveller asked for, so a 'recommended' tag is yours to add if you want one.

SORT_OPTIONS · DEFAULT_SORT
readonly option[] · SortType

The four sorts with their labels, for a control that has to agree with the list.

Direction · Itinerary
types

The normalised view model. One-way, round trip and multi-city all land on it, which is why there is no single "offer" prop anywhere.

Code samples

The same files whole, in the order a reader would paste them. Copy takes the block as it is, comments included.

app/results-stage.tsx — the summary, the list and the handoff
'use client';

import { useRouter } from 'next/navigation';
import { FlightResultsList } from '@sheriph/fk-ui/results';
import { FlightSearchSummary } from '@sheriph/fk-ui/search';

/**
 * The results page: the summary on top, the list below it.
 *
 * The summary rather than the hero form, because on this page the criteria are
 * already committed and the results want the vertical space — the traveller
 * edits them from the collapsed read-out.
 *
 * The two composites share **no props and no state**. Submitting the summary
 * publishes a new `searchRequest` (with a fresh `requestId`) to the fk-ui store,
 * and the list is subscribed to it, so it flips to loading and refetches on its
 * own. That is the whole cross-stage contract.
 */
export function ResultsStage() {
  const router = useRouter();

  return (
    <>
      <FlightSearchSummary />

      <FlightResultsList
        defaultSort="cheapest"
        onBook={() => {
          // The list has already written the selection to the store, so routing
          // is the whole of your job. Note what is NOT happening: no offer data
          // in the URL, and no booking started here.
          router.push('/checkout');
        }}
      />
    </>
  );
}
app/publish-from-url.tsx — the URL as the third participant
'use client';

import { useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { useFlightKitStore } from '@sheriph/fk-ui/core';

import { paramsFromQuery, sameSearchParams } from '@/lib/search-query';

/**
 * A link *is* a search: publish what it describes, exactly once.
 *
 * Two things are skipped, and both of them are a second multi-MB request for
 * results already in flight:
 *
 *  1. a query string this page has already published;
 *  2. a search the store already holds — the normal case when the traveller
 *     arrived from the form, which publishes *then* routes.
 */
export function PublishFromUrl() {
  const query = useSearchParams().toString();
  const published = useRef<string | null>(null);

  useEffect(() => {
    const params = paramsFromQuery(new URLSearchParams(query));
    if (!params) return;                       // not a searchable route yet
    if (published.current === query) return;    // this page published it already
    if (sameSearchParams(useFlightKitStore.getState().searchRequest?.params, params)) return;

    published.current = query;
    useFlightKitStore.getState().requestSearch(params);
  }, [query]);

  return null;
}
somewhere else — the same itineraries, the same sorts
import {
  bestItineraryId,
  routeLabel,
  sortItineraries,
  toItinerariesFromResponse,
} from '@sheriph/fk-ui/results';

// The raw search response, mapped to exactly the itineraries the list shows.
// `toItinerariesFromResponse` reads the trip type off the response itself, so a
// three-leg multi-city payload is not silently welded into a fake round trip.
const itineraries = toItinerariesFromResponse(response);

console.log(routeLabel(itineraries[0]));          // 'LOS ⇄ LHR' — not 'LOS → LOS'
console.log(describeItinerary(itineraries[0]));   // route · date · carrier

const cheapestFirst = sortItineraries(itineraries, 'cheapest');
const winner = bestItineraryId(itineraries);      // the 'best' ranking, if you want to mark one yourself

// Sort on the number, never the string: '1000.00' sorts below '900.00'.
const lowest = Math.min(...itineraries.map((itinerary) => itinerary.price));