Flight Kit

Flight Kit — reference app

A real agency solution, assembled only from fk-ui composites. Individual parts (trip-type tabs, airport pair field, offer card, filter panel) are internal to the composites and are never mounted directly.

Everything below can be yours: four routes, one proxy file and one stylesheet import. The four stage pages then carry their own guides.

Deploy a booking site in four routes

The whole setup, in the order you would do it — then the four routes themselves, and an honest list of what you do not have to write.

  1. Know what you are assembling

    Four composites, one per stage of the booking flow, and one shared component for the wait. That is the whole public surface — the trip-type tabs, the airport field, the offer card, the filter panel, the traveller form, the timeline and the tables are all internal to the composite that uses them, and are never mounted directly. You are not assembling a component library; you are wiring four stages and choosing four routes.

    The stages hand off through the fk-ui store rather than through props, which is the thing that makes an app this small possible: the form publishes a search, the list is subscribed to it, the list writes a selection, the checkout reads it. No stage needs to know the next one exists.

  2. Import the stylesheet once

    One line, and it is not optional: it pulls in Tailwind, the design tokens, the fk-* utility vocabulary, and the @source directive that stops Tailwind purging the classes used inside the library. Leaving it out is the most common way a Tailwind component library renders unstyled.

    app/globals.css
    /* once, in your global stylesheet */
    @import '@sheriph/fk-ui/tailwind.css';
  3. Keep the key on your server, and copy the proxy

    Every call a composite makes goes to /api/flight-kit/… on your own origin. One route handler attaches the agency key server-side and forwards upstream, which is why the client's default base is that path and why no component ever needs a key or a URL.

    Copy app/api/flight-kit/[...path]/route.ts from this app verbatim — it is written to be copied, and it is the only file in the whole setup that must not be adapted. It also passes the upstream status through unchanged, because the client reads that status to decide between offering a retry and showing the server's own message.

    .env.local
    # .env.local — server-side only.
    #
    # Nothing here carries a NEXT_PUBLIC_ prefix except the agency id, which is not a
    # secret: it is already in the browser as a prop. The API key never gets one —
    # anything prefixed that way is inlined into the JavaScript your visitors
    # download, so anyone could read the key and book on the agency's account.
    FLIGHT_KIT_API_KEY=fk_...
    FLIGHT_KIT_API_URL=https://api.flight-kit.example/api
    
    # Read on the server and passed down as a prop (see the layout below), so one
    # deployment can serve several agencies without a rebuild.
    FLIGHT_KIT_AGENCY_ID=R7JHE5
  4. Wrap the app in the provider, once

    Mount it in the root layout, above every route. The store itself is a module singleton — the provider is what supplies the API client and the query cache, and it is where persisted state is restored, in an effect, never during render.

    Read the agency id on the server and pass it down as a prop, as below. It keeps the id a runtime concern rather than a build-time one, which is what lets one deployment serve more than one agency.

    app/layout.tsx and the two files it needs
    /* ── app/globals.css ───────────────────────────────────────────────────── */
    @import '@sheriph/fk-ui/tailwind.css';
    
    /* ── app/providers.tsx ─────────────────────────────────────────────────── */
    'use client';
    
    import type { ReactNode } from 'react';
    import { FlightKitProvider } from '@sheriph/fk-ui/core';
    
    export function Providers({ children, agencyId }: { children: ReactNode; agencyId: string }) {
      return (
        // A same-origin path: your route handler attaches the API key there, so it
        // never reaches the browser. This is the client's default, and passing it
        // explicitly is how you mount your own.
        <FlightKitProvider agencyId={agencyId} apiBase="/api/flight-kit">
          {children}
        </FlightKitProvider>
      );
    }
    
    /* ── app/layout.tsx ────────────────────────────────────────────────────── */
    import type { Metadata } from 'next';
    import type { ReactNode } from 'react';
    
    import { Providers } from './providers';
    import './globals.css';
    
    /**
     * Read per request, not baked into the build — which is what lets one image
     * serve several agencies. A value read inside a client component is inlined at
     * build time instead, so pointing it at another agency would mean rebuilding.
     */
    export const dynamic = 'force-dynamic';
    
    export const metadata: Metadata = {
      title: 'Your travel agency',
    };
    
    const agencyId = process.env.FLIGHT_KIT_AGENCY_ID ?? process.env.NEXT_PUBLIC_AGENCY_ID ?? '';
    
    export default function RootLayout({ children }: { children: ReactNode }) {
      return (
        <html lang="en">
          <body>
            {/* Mounted once, above every booking stage. */}
            <Providers agencyId={agencyId}>{children}</Providers>
          </body>
        </html>
      );
    }
  5. Four routes, four composites

    This is the entire site. Read it as the answer to “how much code is this?” — the next step is the only part that needs a decision, and step 7 is what you get in return.

    the four routes, whole
    /* ── app/page.tsx — stage 1, the search ────────────────────────────────── */
    'use client';
    
    import { useRouter } from 'next/navigation';
    import { FlightSearchForm } from '@sheriph/fk-ui/search';
    
    import { RESULTS_PATH, queryFromCriteria } from '@/lib/search-query';
    
    export default function SearchPage() {
      const router = useRouter();
    
      return (
        <FlightSearchForm
          onSearch={(criteria) => router.push(`${RESULTS_PATH}?${queryFromCriteria(criteria)}`)}
        />
      );
    }
    
    /* ── app/flights/page.tsx — stage 2, the results ───────────────────────── */
    'use client';
    
    import { useRouter } from 'next/navigation';
    import { FlightResultsList } from '@sheriph/fk-ui/results';
    import { FlightSearchSummary } from '@sheriph/fk-ui/search';
    
    import { RESULTS_PATH, queryFromCriteria } from '@/lib/search-query';
    
    export default function ResultsPage() {
      const router = useRouter();
    
      return (
        <>
          <FlightSearchSummary
            onSearch={(criteria) =>
              // replace, not push: a search is not a document.
              router.replace(`${RESULTS_PATH}?${queryFromCriteria(criteria)}`, { scroll: false })
            }
          />
          <FlightResultsList onBook={() => router.push('/checkout')} />
        </>
      );
    }
    
    /* ── app/checkout/page.tsx — stage 3, the checkout ─────────────────────── */
    'use client';
    
    import { BookingFlow } from '@sheriph/fk-ui/checkout';
    import { bookingConfirmationHref } from '@sheriph/fk-ui/confirmation';
    
    export default function CheckoutPage() {
      return (
        <BookingFlow
          resultsHref="/flights"
          confirmationHref={(ref) => bookingConfirmationHref(ref)}
        />
      );
    }
    
    /* ── app/booking-confirmation/page.tsx — stage 4, the receipt ──────────── */
    'use client';
    
    import { Suspense } from 'react';
    import { useSearchParams } from 'next/navigation';
    import { BOOKING_REF_PARAM, BookingConfirmation } from '@sheriph/fk-ui/confirmation';
    
    function Confirmation() {
      const reference = useSearchParams().get(BOOKING_REF_PARAM) ?? '';
    
      return <BookingConfirmation bookingRef={reference} searchHref="/" />;
    }
    
    export default function ConfirmationPage() {
      return (
        <Suspense fallback={null}>
          <Confirmation />
        </Suspense>
      );
    }
  6. The URL is the one thing you decide

    A composite publishes to the store and reports through a callback; it never navigates and never touches a URL, because the library imports no router at all. So the query string is yours — and it buys two things: a search that survives a refresh on a cold load, and a link a traveller can share or bookmark.

    It is also strictly optional. Skip it — don't write the parameters, don't parse them back — and the flow still works end to end through the store; you only lose shareability and refresh-survival on the results page. /flight-search-form shows the mapping if you want it (about forty lines, in src/lib/search-query.ts here), and /search-result shows how to publish it back on mount without burning a second search.

  7. Brand it with tokens, not with overrides

    Set --fk-color-primary once and the components follow. There is no theme provider to configure, no sx prop, and no reason to restyle anything: if a composite does not look like your brand, the colour is almost always the only thing missing.

    One breakpoint, fk-mobile (768px), mobile-first — a layout difference is a class change inside the same component, never a second one to mount. And an agency that sets a colour in its own dashboard wins over your theme, on the page and on the printed ticket, because they chose it deliberately.

    globals.css — one colour, derived everywhere
    /* ── globals.css — your brand, as tokens ───────────────────────────────── */
    
    /* The one place a hex belongs. Everything downstream derives from it: the
     * shade, the tint, focus rings and the printed ticket. */
    :root {
      --fk-color-primary: #0b4f9e;
    }
    
    /* ── and per agency, without a deploy ──────────────────────────────────── */
    
    /* The agency that owns the booking can override the confirmation's brand colour
     * from its own dashboard — the masthead band, the section rules, the table
     * headers and the ticket's total. An agency that chose a colour deliberately
     * beats a theme default, so there is nothing to reconcile in code:
     *
     *   <BookingConfirmation bookingRef={pnr} primaryColor="#0b4f9e" />
     */
  8. What you did not write

    The claim is less code, so here is the itemised invoice. Across the four stages the composites already own: the airport lookup and the form's validation; the search request itself — issuing it, aborting a superseded one so a slow first response cannot overwrite a faster second, and the blocking first-load surface over skeletons; the offer card, fare rules, sorting, filtering with chips and a mobile sheet, pagination, and the six distinct states of a results list (idle, loading, reloading, empty, error, and “your filters excluded everything”); the traveller form with its per-field errors and focus management; the pricing — the agency's markup and markdown applied server-side, so the fare quoted and the fare booked cannot drift; the commit that returns a real locator; the retrieval, the share dialog, the calendar file and the PDF ticket; and the accessibility of all of it — focus traps, live regions, real tables, keyboard paths.

    None of that is configuration you switch on. It is what happens when you mount the four routes above.

Go deeper, one stage at a time

Each of the four stage pages carries its own implementation guide — steps, the code to paste, the props table, and the traps worth knowing before you mount it.

Every page in this app

The composites mounted in the roles they are actually for, in the order a traveller meets them.