All work

Serpentina Transfers

A concept booking site for a fictional airport-transfer company in Brașov: a fixed price in seconds and a booking in under a minute on a phone, in three languages.

Type
Concept
Role
Design direction, build, deploy
When
September 2026
Built with
Next.js 16, React 19, TypeScript, Tailwind CSS 4
Status
Concept
Links
Serpentina Transfers home page: a bold headline and a yellow fare panel with a €105 fixed price.

The job

Serpentina Transfers is a fictional company, and my portfolio concept for what a small transfer business can have online. It drives travellers from four airports (Bucharest Otopeni, Brașov-Ghimbav, Sibiu and Cluj) to Brașov, Poiana Brașov, Bran, Predeal and Sinaia.

In the brief, many travellers are German or Austrian, and many book late at night on a phone. So the first screen has to answer three questions at once: how much, how do I book, and who picks me up. The site had to work in English, Romanian and German, show lei on the Romanian pages, and run on GitHub Pages with no server behind it.

What I built

Claude Design produced two visual directions. I picked “Wayfinding grid”, which borrows from airport signs: yellow and black, numbered section bars, square corners, and the white name sign a driver holds up in arrivals. Then I built it as a Next.js 16 static export.

  • A fixed price on the first screen, per vehicle, in euro, or in lei on the Romanian pages.
  • A name sign that repeats the traveller’s name while they type it.
  • Booking in three steps: a demo flight lookup, passengers and luggage, a vehicle that fits, an optional return trip, and a calendar file at the end.
  • Manage booking: change the pickup time or cancel, with the refund rule shown before you confirm.
  • An owner dashboard: today’s rides with statuses that follow the clock, driver assignment, and a price editor.
  • 20 route pages per language, each with a map drawn from real coordinates.

Nothing is charged and nothing is sent. Bookings stay in the visitor’s browser, and every page says the company is not real.

The owner dashboard: today’s rides with statuses, drivers and a delayed flight.
The owner dashboard. Ride statuses follow the clock.

The hard parts

The owner changes a price and the whole site follows

A static site has no database. Yet the dashboard’s price editor has to change every price on every page: the booking panel, both price tables, the booking steps and the route pages.

The edited table lives in the browser’s localStorage. Components read it through one hook built on React’s useSyncExternalStore. Its server snapshot is the default table, so the prerendered HTML and the first client render always match, and the stored prices replace the defaults right after hydration. The snapshot caches the parsed table by its raw string, because the hook needs the same object back while nothing has changed, or React renders in a loop.

// src/lib/prices.ts
function snapshot(): PriceTable {
  const raw = readRaw();
  if (raw === cache.raw) return cache.table;
  // …
export function usePrices(): PriceTable {
  return useSyncExternalStore(subscribe, snapshot, () => DEFAULT_PRICES);
}

A stored table is used only if every route has a whole-euro price between 10 and 1,000. Every function that shows or charges a price takes the table as an argument, so no component can quietly fall back to the defaults.

One quote, four places on the page

The home page shows the price in the booking panel, in a matrix on desktop and on a board on phones, while the name sign shows the traveller’s name. If each part kept its own state, they would drift apart.

One React context holds the quote: airport, destination, passengers, name and date. Picking a price in either table loads that route into the form and scrolls to it, smoothly unless the visitor has turned motion off. A short announcement tells screen-reader users which route was loaded. “Book this transfer” hands the same state to the booking flow, where tested rules pick a vehicle that fits: a sedan takes three passengers and three suitcases, an estate four passengers and five pieces, and five to eight people need the minibus.

A map drawn from real coordinates

I wanted each route page to show the road, without map tiles or a third-party widget.

Every town and airport on the map has its real longitude and latitude. The code projects them flat and scales longitude by the cosine of 45.6°, a latitude near the middle of the map, so east-west distances are not stretched. Each route is a list of towns along the main road. For a route page, the code takes that route’s bounding box, pads it, widens it to a 4:3 frame, and passes a scale factor so labels and markers keep the same size on screen at every zoom. Each label sits on the side away from the next point on the route.

// src/lib/route-map.ts
const SCALE = 88; // px per degree of latitude
const LON_SCALE = SCALE * Math.cos((45.6 * Math.PI) / 180);
// …
export function project(node: Node): { x: number; y: number } {
  return { x: Math.round((node.lon - WEST) * LON_SCALE * 10) / 10, y: Math.round((NORTH - node.lat) * SCALE * 10) / 10 };
}
A route page with a map of the road from Otopeni airport to Poiana Brașov.
Route pages draw the road from real coordinates, with no map tiles.

A static site that behaves like an app

GitHub Pages serves files and nothing else: no headers, no redirects, no server code.

So the Content Security Policy is a meta tag that still blocks every third-party origin. The root page forwards to the English site with a meta refresh, and unknown URLs get a real 404 page. Pages that read browser storage render after hydration, so the HTML never disagrees with React. One trap came from the build itself: on Windows, Next 16.3 writes prefetch files under the wrong names. So the site is deployed only from the Linux build in GitHub Actions, after formatting, types, lint and tests pass.

Results

  • Live site check: PASS, 0 errors on all 78 pages (27 September 2026).
  • Lighthouse lab runs on the live site (27 September 2026): desktop performance 100, phone performance 73 to 94 between runs, accessibility 100 and best practices 100 in every run.
  • 34 unit tests, run on every push to main and on every pull request.
  • 26 pages in each of 3 languages: home, booking, manage booking, dashboard, a routes overview, 20 route pages and the case study.
  • axe checks every page at 375, 768 and 1440 px, and the booking and cancel flows were tested with the keyboard alone.

What I would do next

  • Add a real Stripe checkout in test mode. That needs a server, for example on Vercel, instead of GitHub Pages.
  • Have the Romanian and German copy reviewed; both are still drafts.
  • Move the browser tests into the repository. Today the Playwright scripts live outside it, and the repository’s gate is the unit tests plus the site check.
Next case studyUrsa