Build with Lovable
This is the fastest path: Lovable handles secrets and deployment for you, so there's no local setup at all.
Prerequisites
- A Lovable project
- A Supabase project connected to it (free tier is fine) — Lovable will prompt you to connect one if you haven't already
- Your Volt sandbox or production credentials: API Key, User ID, Merchant ID
Step 1 — Copy the Prompt
Copy the full prompt from the Prompt section of the main guide into Lovable's chat. It already contains the full architecture, API wiring, page behavior, and a deliverable checklist — Lovable doesn't need anything else to get started.
Step 2 — Enable Cloud
Since this app needs a backend (Edge Functions + database), Lovable will prompt you to enable Cloud for the project right after you paste the prompt. Click Allow / Enable when asked — without this, Lovable can't deploy the Edge Functions or create the bookings table, and the build will stall.

Enable cloud in Lovable
Step 3 — Add Your Volt Credentials
Lovable detects that the prompt needs secrets and will prompt you for them right in the chat. When asked, please enter the credentials in the chat:

Add your VOLT Credentials in Lovable
These get stored as Lovable Secrets (synced to Supabase Edge Function secrets behind the scenes) — never as frontend env variables. Sandbox hosts present in Secrets should be swapped to production hosts only when you're ready to go live.
Step 4 — Let Lovable Build
Lovable will create the Supabase migration, deploy the Edge Functions, and scaffold the React frontend automatically. This takes a few minutes — no manual deploy steps required.
Step 5 — Customize (Optional)
The prompt ships with sensible defaults (white + blue UI). Common tweaks before pasting:
- Change VITE_APP_NAME / VITE_PRIMARY_COLOR for your brand
- You can share a website screenshot for design reference.
- Adjust the Routes table if you want different page names or extra steps
- Add a Flights or Holidays flow on top of the same Edge Function pattern (the prompt scaffolds Hotels only by default)
Step 6 — Test the Flow
Once Lovable finishes building, walk through the full journey end to end:
Search → Fare Rules → Create itinerary → Save Passenger Information → Re-Price → Book → Confirmation → My Bookings
Confirm a row lands in the Supabase bookings table for both a successful and a failed booking attempt.
Prompt ✨
Copy everything inside the block below into Lovable Chat.
# Lovable Prompt: VOLT Flight Booking App
Copy everything from the heading below into Lovable Chat.
---
# Lovable Prompt: VOLT Flight Booking App
Build a full-stack **flight** booking web app powered by the **VOLT Flights API**. The React frontend must **never** call VOLT directly or expose credentials. All VOLT traffic goes through **Supabase Edge Functions**. Completed booking attempts are persisted in **Supabase (PostgreSQL)**.
Do **not** build hotels, packages, wallet, hold/release, or airport-search APIs. Flights is the only active product.
---
## Architecture
```
┌─────────────────┐ invoke ┌──────────────────────────┐
│ React (Lovable)│ ──────────────► │ Supabase Edge Functions │
│ + React Router │ │ (VOLT Flights proxy) │
└────────┬────────┘ └────────────┬─────────────┘
│ │
│ read/write │ HTTPS
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ Supabase DB │ │ VOLT Flights API │
│ bookings table │ │ (sandbox or production) │
└─────────────────┘ └──────────────────────────┘
```
| Layer | Role |
|---|---|
| **Frontend** | UI, search state, passenger forms, calls Edge Functions only |
| **Edge Functions** | Auth token cache, VOLT proxy, booking save logic |
| **Supabase DB** | Persist every booking attempt (CONFIRMED, FAILED, PENDING) |
| **VOLT** | Source of truth for availability, pricing, live booking status |
**Do not** use a separate FastAPI backend, Docker, MongoDB, Elasticsearch, Filebeat, or `./run.sh`.
---
## Why Edge Functions Are Required
VOLT credentials (`VOLT_API_KEY`, `VOLT_USER_ID`, `VOLT_MERCHANT_ID`) and the OAuth access token **must stay server-side**. The browser cannot hold these secrets.
Edge Functions must:
1. Authenticate with VOLT (`POST /authentication/internal/service/login`)
2. Cache and refresh the access token in memory (per isolate)
3. Proxy all flight API calls with correct headers
4. Strip Mongo/UI-only meta before booking (`origin`, `destination`, `departureAt`, `totalAmount`, `currency`)
5. Save booking records to Supabase after every book attempt
---
## Why Supabase for Booking Data
Replace MongoDB with a Supabase `bookings` table. **Save every book attempt** — do not skip Failed or Pending outcomes.
VOLT remains the source of truth for live status; Supabase is your app's booking history and confirmation UI cache.
---
## Official VOLT Flights workflow (do not skip, reorder, or invent extra APIs)
1. Search Flight
2. Fare Rules (optional anytime after search — **required UI button on each result card**)
3. Create Itinerary
4. Get Itinerary
5. Save Passenger
6. Reprice (fare-quote)
7. Book Flight
8. Get Booking Details
---
## Supabase Secrets (Edge Function env)
Set these in Supabase project secrets — never in frontend code or Vite env.
**All API base URLs live in secrets only.** Edge Functions must read them via `Deno.env.get(...)` — do not hardcode VOLT hosts in function source or frontend.
```env
VOLT_API_KEY=
VOLT_USER_ID=
VOLT_MERCHANT_ID=
VOLT_AUTH_URL=https://trav-auth-sandbox.travclan.com
VOLT_FLIGHTS_API_URL=https://flight-aggregator-api-sandbox.travclan.com
VOLT_SOURCE=website
```
Do **not** use hotel secrets (`VOLT_SEARCH_API_URL`, `VOLT_HOTEL_HELPER_HOST`). Auth uses `VOLT_AUTH_URL`. All flight proxy calls use `VOLT_FLIGHTS_API_URL`.
### Sandbox URLs (default) — update before go-live
The values above are **sandbox** endpoints. They are correct for development and QA only.
When you paste credentials into the Lovable chat or Supabase dashboard, **also tell the developer explicitly:**
> The VOLT API URLs in Supabase secrets are currently set to **sandbox** URLs. Before taking the app live, update these two secrets to the **production** URLs (credentials team will provide production values):
>
> | Secret | Sandbox (current default) | Production (update before go-live) |
> |---|---|---|
> | `VOLT_AUTH_URL` | `https://trav-auth-sandbox.travclan.com` | `https://trav-auth.travclan.com` |
> | `VOLT_FLIGHTS_API_URL` | `https://flight-aggregator-api-sandbox.travclan.com` | Production flights host from credentials team |
>
> Keep `VOLT_API_KEY`, `VOLT_USER_ID`, and `VOLT_MERCHANT_ID` aligned with the same environment (sandbox keys + sandbox URLs, or production keys + production URLs). Redeploy Edge Functions after changing secrets.
Optional frontend branding (safe in `.env` or Lovable env). These feed `src/config/brand.ts` — see **Branding & Theme** below:
```env
VITE_APP_NAME=SNAP
VITE_PRIMARY_COLOR=#0062E3
VITE_SECONDARY_COLOR=#004BB3
VITE_BG_COLOR=#ffffff
```
---
## Database Schema
Create a `bookings` table:
```sql
create table public.bookings (
id uuid primary key default gen_random_uuid(),
booking_code text,
itinerary_code text,
status text not null default 'PENDING',
pnr text,
trace_id text,
origin text,
destination text,
departure_at text,
amount numeric,
currency text default 'INR',
upstream_error text,
upstream_saved boolean default true,
created_at timestamptz not null default now()
);
create index bookings_booking_code_idx on public.bookings (booking_code);
create index bookings_created_at_idx on public.bookings (created_at desc);
create index bookings_trace_id_idx on public.bookings (trace_id);
create index bookings_itinerary_code_idx on public.bookings (itinerary_code);
alter table public.bookings enable row level security;
-- Allow anonymous read/write for MVP (tighten with auth later)
create policy "Allow public read bookings"
on public.bookings for select using (true);
create policy "Allow public insert bookings"
on public.bookings for insert with check (true);
```
**Fields to store on every book attempt:**
| Field | Source |
|---|---|
| `booking_code`, `itinerary_code`, `status`, `pnr` | VOLT book response `results.details[0]` (`bmsBookingCode` / `bookingCode`, `bookingStatus`, `pnr`) |
| `trace_id`, `origin`, `destination`, `departure_at` | Request / itinerary meta |
| `amount`, `currency` | Request meta (UI only — **not** sent to VOLT book body) |
| `created_at` | Server timestamp |
| `upstream_error`, `upstream_saved` | Set when VOLT HTTP fails before a normal response |
**Save policy:**
| Outcome | `status` in Supabase |
|---|---|
| VOLT returns CONFIRMED / FAILED / PENDING / etc. | Exact upstream `bookingStatus` (default `PENDING` if missing) |
| VOLT HTTP error (502/504) before response | `FAILED` + `upstream_error`, `upstream_saved: false` |
Do **not** skip save because status is FAILED or PENDING.
---
## Edge Functions
Create one function per route (or a single router function). All must return JSON and set CORS headers for the Lovable frontend origin.
### Shared: VOLT auth helper
```typescript
// Cache token in module scope (per isolate)
let cachedToken: string | null = null;
let expiresAt = 0;
async function getVoltToken(): Promise<string> {
if (cachedToken && Date.now() < expiresAt) return cachedToken;
const res = await fetch(
`${Deno.env.get("VOLT_AUTH_URL")}/authentication/internal/service/login`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization-Type": "Bearer",
source: Deno.env.get("VOLT_SOURCE") ?? "website",
},
body: JSON.stringify({
api_key: Deno.env.get("VOLT_API_KEY"),
user_id: Deno.env.get("VOLT_USER_ID"),
merchant_id: Deno.env.get("VOLT_MERCHANT_ID"),
}),
}
);
if (!res.ok) throw new Error(`VOLT auth failed: ${res.status}`);
const data = await res.json();
cachedToken = data.AccessToken;
expiresAt = Date.now() + (3600 - 300) * 1000; // 1h minus 5min buffer
return cachedToken!;
}
```
### Shared: VOLT request helper
Headers for outbound calls:
- `Authorization-Type: external-service`
- `source: website` (from env `VOLT_SOURCE`)
- `Authorization: Bearer {token}`
- `Content-Type: application/json` on POST only — **never on GET**
On 401: refresh token once and retry.
On non-success: return JSON with HTTP status matching the failure class and a structured body the frontend can parse:
```json
{
"error": {
"code": 502,
"upstreamStatus": 504,
"message": "Upstream service error",
"errorCode": 6
}
}
```
- `code` — Edge Function / normalized error code (use upstream HTTP status when available, else 502)
- `upstreamStatus` — optional; original upstream HTTP status when the flights API returned non-2xx
- `errorCode` — optional; supplier `errorCode` when present (6 = search-session timeout, not auth)
- `message` — short internal label for logging only; **never show this string directly in the UI**
Do not leak raw upstream bodies, stack traces, or credentials. Always set CORS headers even on error responses so the browser can read the JSON body.
Pass VOLT success bodies through unchanged. Do not reshape abbreviated search keys (`rI`, `fF`, `cr`, `sg`, `outboundFlights`, `inboundFlights`).
### Edge Function routes
| Function | Method | VOLT upstream | Description |
|---|---|---|---|
| `flights-search` | POST | `POST {FLIGHTS}/api/v2/flights/search` | Flight search |
| `flights-fare-rules` | POST | `POST {FLIGHTS}/api/v2/flights/fare-rules` | Fare rules |
| `flights-itinerary-create` | POST | `POST {FLIGHTS}/api/v2/flights/itinerary` | Create itinerary |
| `flights-itinerary-get` | GET | `GET {FLIGHTS}/api/v2/flights/itinerary/{itineraryCode}?traceId=` | Get itinerary |
| `flights-passengers` | POST | `POST {FLIGHTS}/api/v2/flights/itinerary/{itineraryCode}/passenger-collections` | Save passengers |
| `flights-reprice` | POST | `POST {FLIGHTS}/api/v2/flights/itinerary/{itineraryCode}/fare-quote` | Reprice / fare-quote |
| `flights-book` | POST | `POST {FLIGHTS}/api/v2/flights/itinerary/{itineraryCode}/book` | Book + **save to Supabase** |
| `flights-booking-get` | GET | `GET {FLIGHTS}/api/v2/flights/bookings/{bookingCode}` | Live booking details |
| `bookings-saved` | GET | — | List rows from `bookings` table (newest first) |
| `bookings-saved-one` | GET | — | One row by `booking_code` |
`{FLIGHTS}` = `VOLT_FLIGHTS_API_URL`.
Frontend calls these via:
```typescript
const { data, error } = await supabase.functions.invoke("flights-search", {
body: { origin: "DEL", destination: "BOM", /* ... */ },
});
```
Or `fetch(`${SUPABASE_URL}/functions/v1/flights-search`, { ... })` with the anon key.
**Out of scope — do not invent:** wallet, hold/release, airport autocomplete, nearby airports.
### Booking create function — critical logic
1. Accept payload with VOLT `traceId` + UI meta (`origin`, `destination`, `departureAt`, `totalAmount`, `currency`)
2. Strip meta before calling VOLT — send **only** `{ "traceId": "..." }`
3. Call VOLT `POST /api/v2/flights/itinerary/{itineraryCode}/book`
4. **Always insert** into `bookings` table (success or failure)
5. Return VOLT response to frontend
### Passengers function
Pass through unchanged. `duplicatesFound: true` plus `duplicateBookings[]` is a **warning, not a failure** (HTTP 200). Return the body as-is.
---
## Frontend Tech Stack
- React 18 + TypeScript
- React Router v6
- Zustand for client state
- Tailwind CSS
- Supabase JS client for Edge Function invocations and saved bookings
- Native fetch or `supabase.functions.invoke`
**Do not** use a separate FastAPI backend, Docker, or MongoDB.
---
## Routes
| Route | Page |
|---|---|
| `/` | Landing — white page + paper search form |
| `/flights/search` | Route/date steps + results list + selected-flight summary with Continue |
| `/flights/itinerary/:itineraryCode` | Get itinerary details |
| `/flights/passengers` | Save passenger forms from `paxRules` |
| `/flights/reprice` | Fare-quote / confirm before book |
| `/flights/book/confirmation` | Booking confirmation + live details |
| `/bookings` | My bookings (from Supabase `bookings` table) |
---
## URL Query Params (search page)
Sync search state to the URL on `/flights/search`:
| Param | Example | Required |
|---|---|---|
| `origin` | `DEL` | yes |
| `destination` | `BOM` | yes |
| `depart` | `2026-10-09` | yes |
| `return` | `2026-10-15` | yes if `journeyType=2` |
| `journeyType` | `1` or `2` | yes |
| `adults` | `1` | yes |
| `children` | `0` | no (default 0) |
| `infants` | `0` | no (default 0) |
| `cabin` | `2` | yes (`flightCabinClass`) |
| `direct` | `true` / `false` | no (default false) |
Trigger flight search when all required params for the journey type are present.
**No multi-city** (`journeyType` 3).
**Do not re-fetch** if `searchStore` already has `response` for the same query key (`lastQueryKey`). React 18 StrictMode double-invokes effects in development — never use a `cancelled` boolean from effect cleanup to drop results (that leaves loading stuck). Use an `inflight` request-key ref and only apply the response when `inflight.current` still matches. Same pattern on itinerary GET, reprice, book, booking-details, and fare-rules: one request per user action / URL key.
---
## Visual Design (paper UI)
Simple **paper UI** — white page, hairline borders (`#e5e7eb`), no navy hero, no shadows, no gradients, no pills/chips, no rounded marketing cards. Workable controls only.
- Page background white; text `#111827`; borders `#e5e7eb`
- Primary `#0062E3` / `VITE_PRIMARY_COLOR` on buttons and underlines only
- Stop labels are plain text, not teal chips
- Not a default admin dashboard
- Not a Skyscanner-style navy search bar
`tailwind.config` theme tokens:
```js
colors: {
primary: "var(--app-primary, #0062E3)",
ink: "#111827",
line: "#e5e7eb",
}
```
`src/index.css` sets `--app-primary` on `:root` from brand config and defines:
- `.paper` = `border border-line bg-white p-4`
- `.field` = full-width bordered input; focus border uses `--app-primary`
- `.btn-primary` = `bg-primary` + white text, `disabled:opacity-50`
- `.btn-plain` = bordered white button
- `.link` = primary color + underline
Primary CTAs: primary color background + white text — never white-on-white.
---
## Branding & Theme (single change point)
**All product name and color theming must flow from one file** so the owner can rebrand without hunting through components.
Create `src/config/brand.ts`:
```typescript
export const brand = {
name: import.meta.env.VITE_APP_NAME ?? "SNAP",
colors: {
primary: import.meta.env.VITE_PRIMARY_COLOR ?? "#0062E3",
secondary: import.meta.env.VITE_SECONDARY_COLOR ?? "#004BB3",
background: import.meta.env.VITE_BG_COLOR ?? "#ffffff",
text: "#111827",
border: "#e5e7eb",
},
tagline: {
line1: "Book a flight",
line2: "",
},
} as const;
```
**Rules:**
1. **Never hardcode** the product name or theme colors in individual components — import from `@/config/brand`.
2. Wire Tailwind to brand tokens and set `body` background from `brand.colors.background`.
3. Navbar logo text, heading, and document `<title>` use `brand.name`.
4. Search CTA, primary buttons, active tab, and link accents use `brand.colors.primary`.
| What to change | Where |
|---|---|
| Product name | `.env` → `VITE_APP_NAME` **or** default in `src/config/brand.ts` |
| Primary color | `.env` → `VITE_PRIMARY_COLOR` **or** default in `src/config/brand.ts` |
| Secondary color | `.env` → `VITE_SECONDARY_COLOR` |
| Background color | `.env` → `VITE_BG_COLOR` |
**Recommended for non-developers:** edit the `VITE_*` values in Lovable **Project Settings → Environment Variables**, then refresh preview.
---
## Hard rules — keys and APIs
1. **Never invent JSON keys.** Map only keys documented in VOLT Search / Fare Rules / Itinerary / Booking.
2. **Do not reshape** search results. Keep `rI`, `fF`, `cr`, `sg`, `outboundFlights`, `inboundFlights` as returned.
3. **No airport autocomplete API.** From/To use **`src/data/airports.ts`** (static popular list: city name + IATA). User must pick from the list.
4. **No wallet, hold/release, nearby airports, “add a place to stay”, CO2 banner, “N deals from”, favourite heart.**
5. **No multi-city** (`journeyType` 3).
6. Fare rules on a card come from **`flights-fare-rules`**, not from a `fareRule` field on the search flight object.
7. Send passenger `nationality` — never the docs typo `nationalty`.
8. Mandatory passenger fields = itinerary **`paxRules`** (`isVisible` + `isMandatoryIfVisible`). Do not hardcode passport/GST as always required.
9. Never render a supplier response or error object with `JSON.stringify`. Pull the fields you need and show plain text.
10. A 200 response is never a dead-end. Advisory flags like `duplicatesFound` inform the user and still advance the flow.
---
## UI Error Rules (all pages)
**Never expose provider or infrastructure names in the UI.** Do not show "VOLT", "upstream", "Edge Function", or similar anywhere users can see — including error text, loading copy, empty states, or dev fallbacks.
| Context | User-facing message | Show HTTP status? |
|---|---|---|
| Flight search failure | "We couldn't load flights right now. Please try again." | No |
| Search session expired (`errorCode` 6) | "This search session has expired. Search again to see live fares." | No |
| Fare rules failure | "Unable to load fare rules. Please try again." | No |
| Create / get itinerary failure | "Unable to load this itinerary. Please try again." | No |
| Save passengers failure | "Unable to save passenger details. Please review the form and try again." | No |
| Reprice failure | "Unable to verify the latest fare. Please try again." | No |
| Booking failure | "Booking could not be completed. Please try again or start a new search." | No |
| Booking details failure | "Unable to load booking details. Please try again." | No |
| 502 / supplier unreachable | "We couldn't reach the flight supplier. Check your connection and try again." | No |
| 422 incomplete | "Some details look incomplete. Review the form and try again." | No |
Implement a shared helper (e.g. `parseEdgeFunctionError` / `friendlyApiMessage`) that maps invoke errors to the table. **Never** display `error.message` from `supabase.functions.invoke` verbatim.
---
## Shared UI Components
### Navbar
- Brand name on the left — use `brand.name`
- **Product header tabs** (visible on **all pages**):
| Tab | Label | MVP behavior |
|---|---|---|
| Placeholder | **Hotels** | **Not clickable** — muted, `cursor-default`, `aria-disabled="true"` |
| Active product | **Flights** | Clickable — hotel-flow inverse. Visually active. |
| Placeholder | **Packages** (or Holidays) | **Not clickable** — same as Hotels |
**Header tab rules (strict):**
1. All three labels must always appear.
2. Only **Flights** is interactive in this build.
3. Do **not** make Hotels or Packages clickable, hover-linked, or routable. No `href="#"`, no toast "coming soon".
4. Extract into `HeaderProductTabs.tsx` so clickability can be toggled later.
5. Menu control on the right → support drawer (placeholder contact info).
### Search Bar (paper form)
Reusable on the **home** page only. Do **not** render it on `/flights/search`. White bordered box (`.paper`): stacked fields, hairline borders, no navy strip, no segmented “one bar” chrome.
- **Journey type** toggle: One-way / Return (`journeyType` 1 / 2). Return field hidden on one-way.
- **From** — filter static airports; store 3-letter IATA (`origin`)
- **Swap** — plain button; swap origin and destination
- **To** — same picker (`destination`)
- **Depart** — date (`preferredDepartureTime` → `YYYY-MM-DDT00:00:00`)
- **Return** — date when round-trip (`preferredReturnDepartureTime`)
- **Travellers and cabin class** — summary like `1 Adult, Economy`; opens popover
- **Search** — primary button. Never white-on-white.
Below the form: **Direct flights** checkbox → `directFlight`.
**Do not** render “Add nearby airports” or “Add a place to stay”.
On Search click: validate → `setParams` → `clearResults()` → `setLoading(true)` → **navigate immediately** to `/flights/search` (do not await API in the search bar). Results page shows “Searching flights…” while `searchStore.loading` is true.
Actual fetch runs on `FlightSearch` `useEffect`, with in-flight dedupe.
#### Travellers popover (Apply to commit)
| Label | `flightCabinClass` |
|---|---|
| All | `1` |
| Economy | `2` |
| Premium Economy | `3` |
| Business | `4` |
| Premium Business | `5` |
| First | `6` |
- **Adults** — Aged 18+. Min 1. Maps to `adultCount`.
- **Children** — Aged 2 to 11. Min 0. Maps to `childCount`.
- **Infants** — Aged 0 to 2. Min 0. Maps to `infantCount`. Infant count must not exceed adult count (block Apply with inline error).
- Minus disabled at minimum; plus as needed (reasonable cap e.g. 9 adults).
- Full-width primary **Apply**.
#### Airport picker (static)
`src/data/airports.ts`: array of `{ iata, city, airportName, country }`. Filter as the user types (city, airport, IATA). Selecting a row sets IATA only. Typing a free string without a pick is invalid.
Include a practical popular set (DEL, BOM, BLR, HYD, MAA, CCU, GOI, AMD, PNQ, COK, JAI, DXB, AUH, SHJ, LHR, LGW, JFK, EWR, SFO, SIN, BKK, KUL, HKG, CDG, FRA, DOH, MCT, IST, SYD, MEL, etc.). Do not call any airport API.
**Dropdown clipping:** never put `overflow-hidden` on a parent of the airport list. The list is `absolute left-0 right-0 top-full z-50 mt-1`. The open field is `relative z-50`.
#### Validation (required before search)
- Origin and destination must be picked IATA codes.
- **Origin IATA ≠ destination IATA.**
- Depart date required.
- Round-trip: return date required; **return date must not equal depart date**; **difference must be at least 2 calendar days** (`return >= depart + 2 days`).
- Show inline errors; do not call search until valid.
---
## VOLT search response mapping (required)
Backend is pass-through. Parse **`body.response`** (root `response` object from VOLT Search). Do **not** assume Hotels `results.data`.
### `response` object
| Key | Use |
|---|---|
| `origin` | Search origin IATA |
| `destination` | Search destination IATA |
| `journeyType` | 1 / 2 |
| `traceId` | All later calls |
| `isDomestic` | Branch domestic vs international return UI |
| `paxCount` | Display only |
| `results.outboundFlights` | Outbound list |
| `results.inboundFlights` | Domestic return: **sibling** inbound list |
### Flight object (abbreviated keys — display only; keep raw in store)
| UI | Key |
|---|---|
| Fare-rules + itinerary id | `rI` → request `resultIndex` |
| **Price (required)** | **`fF` (final fare) only** — this is the amount shown on the card |
| **Currency (required)** | **`cr`** (string, e.g. `"INR"`) — always format with `fF` via `formatMoney(fF, cr)` |
| Refundable cue | `iR` — plain text “Refundable” / “Non-refundable” |
| Stop count | `sC` — `0` → **Direct**; `sC > 0` → `{sC} stop(s)` — never show Direct when `sC !== 0` |
| Fare name | `fareIdentifier.name` (plain text, no color chip) |
| LCC cue | `iL` |
**Do not** display `fFWAM`. Do not invent CO2 or deal-count.
`formatMoney(amount, code)` in `utils/money.ts`: `Intl.NumberFormat` with `style: "currency"` when `code` is a 3-letter ISO code; if Intl throws, fall back to `"CODE 3,555"`. Missing/NaN amount → `"—"`.
### Segments and layovers
**One-way and domestic:** segments are `sg` (array).
**International round-trip (`journeyType === 2` && `isDomestic === false`):**
- Outbound segments may be on **`outboundFlight`** (array of segments), **not** `sg`.
- `inboundFlights` is **nested on each outbound**: `outboundFlights[i].inboundFlights` is a list of options; each option is a single-element array wrapping an inbound flight that has `sg` and fare fields.
- Top-level `fF` on the outbound is the cheapest inbound “from” price — label it as starting-from until the user picks an inbound.
**Helpers in `flightKeys.ts`:** `getSegments(flight, { isDomestic, journeyType, leg })` must check `sg` first, then `outboundFlight`. Include `nestedInboundOptions()`.
Each segment:
| Key | Use |
|---|---|
| `al.alN` | Airline name |
| `al.alC` | Airline code (text fallback; do not require a VOLT logo key) |
| `al.fN` | Flight number |
| `or.dT` | Depart ISO datetime |
| `or.aC` / `or.cN` / `or.aN` / `or.tr` | Origin airport / city / name / terminal |
| `ds.aT` | Arrive ISO datetime |
| `ds.aC` / `ds.cN` / `ds.aN` / `ds.tr` | Destination |
| `aD` | Total duration minutes (last segment’s `aD` for the leg) |
| `dr` | Air time minutes |
| `gT` | Ground / layover minutes |
| `sO` / `sP` / `sD` | Stopover flag / points / duration minutes |
| `bg` / `cBg` | Checked / cabin baggage (details, not the stop label) |
**Layover UI (`LayoverDetails` modal):** when `sC > 0` or `sg.length > 1`, the card shows stop count as a **text link** (“View layover details”). **Do not** dump layover city/wait inline on the card.
- Modal: `open` + `onClose`; `fixed inset-0 z-50`; backdrop click closes.
- For each connection, show layover **city** from the arriving segment’s `ds.cN` / `ds.aC`.
- Show wait as formatted `gT` (and `sD` when `sO` is true / `sP` when non-empty).
Next-day arrival: if calendar date of `ds.aT` is after `or.dT` of the first segment, show `+1` (or `+N`) next to arrival time.
### Result card layout (paper)
Hairline-bordered white card (`border border-line`).
Left (~70%): airline code/name, bold `HH:mm` from `or.dT` / `ds.aT`, IATA under times, duration + Direct/stops in the middle. Layover details only via the modal link.
Right (~30%): `fareIdentifier.name`, refundable text, **fareRule** underline button (required), **price = `formatMoney(fF, cr)`**, primary **Select**.
**fareRule button:** loading state in modal; POST fare-rules with that card’s `rI` and search `traceId`. Deduplicate with a request-key ref (`traceId:resultIndex`). Render `results[].fareRuleDetail` as HTML (`airline`, `origin`, `destination` as headings when present).
### Selection + Continue (required)
- Selecting a card only stores its `rI`; it must **never** call create-itinerary.
- One-way: select one outbound, review it in the sidebar, then **Continue** creates the itinerary with that `rI`.
- Round-trip is sequential: show departing flights first; choosing one advances to the return step.
- Domestic return list comes from the sibling `response.results.inboundFlights`.
- International return list comes from the selected outbound’s nested `outbound.inboundFlights`, unwrapped with `nestedInboundOptions()`.
- Changing the departing flight clears the prior return selection (`setSelectedOutbound` must clear inbound).
- Round-trip **Continue** stays disabled until both outbound and return are selected, then sends two items in order: outbound `rI`, inbound `rI`.
**Totals:**
- one-way total = outbound `fF`
- domestic round-trip total = outbound `fF` + inbound `fF`
- international nested-return total = selected inbound `fF` when provided, else selected outbound `fF`
**Continue opens itinerary in a new tab** (results page stays put). Popup blockers drop `window.open` after `await`, so:
1. Guard with `createInFlight` ref — at most one create-itinerary POST per click.
2. Synchronously `window.open("about:blank", "_blank")` **before** the API call.
3. If blocked, show “Allow pop-ups…” and do not POST.
4. On success, set `detailWindow.location.href = /flights/itinerary/{itineraryCode}?traceId=...`.
5. On error, close the blank tab.
Do **not** `navigate()` the current tab to itinerary after Select or Continue.
---
## Page Behavior
### Home
- navbar + search form on a white page (`min-h-screen bg-white`)
- heading e.g. `Book a flight`
- primary color only on buttons / links
### Flight Search
- **Hide the search form.** Show a summary only: `Showing flights from {origin} to {destination}`, dates, `Change search` link to `/`
- Loading: “Searching flights…” + skeleton cards
- fetch in `useEffect` from URL params
- skip fetch when `response` exists and `lastQueryKey` matches
- `inflight` request key `${queryKey}:${retry}` — apply results only if the key still matches
- empty + error states with Retry
- structure:
1. top route/date step buttons: departing and (for round-trip) return
2. results list on the left
3. sticky **Selected flights** summary on the right (`SelectedFlights.tsx`)
4. on mobile, selected summary appears immediately below the route/date steps
- selected summary shows each chosen leg’s airline, time, route, stops, total, and one **Continue** button
- selecting never calls an API; only **Continue** calls create-itinerary
`searchStore.setResults(body, queryKey)` must store `lastQueryKey`. `clearResults` clears `lastQueryKey`.
### Itinerary (`/flights/itinerary/:itineraryCode`)
On load:
1. The results page does **not** keep create-itinerary `results` for this tab — the new tab loads Get Itinerary by code.
2. Always **Get Itinerary** once via `flights-itinerary-get`.
3. Deduplicate with `requestKeyRef = itineraryCode:traceId`.
Render from itinerary `results` (not search abbreviated keys). **There is no top-level `results.origin` / `results.destination` / `results.currency` on itinerary.** Read:
| Key | Use |
|---|---|
| `itineraryCode` | Route / later calls |
| `traceId` | Later calls |
| `totalAmount` / `previousTotalAmount` | Price — format with itinerary currency |
| `isPriceChanged` / `isBaggageChanged` | Banners |
| `isDomestic` | Context |
| `adultCount` / `childCount` / `infantCount` / `paxCount` | Pax summary |
| `itineraryItems[].itemFlight` | `airlineName`, `flightNumber`, **`origin`**, **`destination`**, `departureAt`, `arrivalAt`, `isRefundable`, `isLCC`, `fareIdentifier`, `stopCount.stops`, `fareQuote.finalFare` / `baseFare` / `taxAndSurcharge` / **`fareQuote.currency`** |
| `itemFlight.segments` | Nested segment arrays — still use `or` / `ds` / `gT` / `al` inside |
| `paxRules` | Drive passenger page |
| `traceIdDetails.remainingTime` | Optional session hint |
`itineraryStore.currency`: prefer `results.currency` if present; else first `itineraryItems[].itemFlight.fareQuote.currency`. All money uses `formatMoney(..., currency)`.
Continue → `/flights/passengers`.
### Passengers
- One form per passenger: N adults + M children + K infants from itinerary counts.
- First adult: `isLeadPax: true`; use **`paxRules.leadPax`**. Other adults: **`paxRules.adult`**. Children/infants: use `paxRules.child` / `paxRules.infant` **if present**; if those nodes are missing, show title/first/last/`paxType` (1 adult, 2 child, 3 infant) plus any fields that exist on `adult` that are visible.
- Render a field only when `isVisible === true`. Require it when `isMandatoryIfVisible === true`.
- `cellCountryCode`: digits only, strip `+` (default `91`).
- POST `flights-passengers` with `{ traceId, passengers }`.
- `duplicatesFound: true` is a **warning, not a failure** — save already succeeded (HTTP 200). Store `duplicateBookings` in `itineraryStore.duplicates` and **still navigate** to `/flights/reprice`. Never dead-end the user on this page and never dump the raw response as JSON.
- Continue → `/flights/reprice` in every success case.
### Reprice (fare confirmation step)
This is the “do you want to book?” screen: latest fare + explicit **Confirm and book**. Nothing books automatically.
- POST `flights-reprice` with `{ traceId }` — once per `itineraryCode:traceId`.
- If `itineraryStore.duplicates` is non-empty, show a readable notice (booking code / PNR / route / date per entry) above the fare. Plain text lines, **not** `JSON.stringify`. It informs; it does not block booking.
- Show `results.isPriceChanged`, `previousTotalAmount`, `totalAmount` with `formatMoney` and itinerary/reprice `currency`. If price changed, user must confirm before book.
- `totalAmount` is flight fare only (add-ons billed separately — no SSR UI unless `paxRules` made seat/meal/baggage visible **and** you only send keys from the save-passenger schema).
- Confirm book → POST `flights-book` with `{ traceId }` plus optional meta for Supabase (`origin`, `destination`, `departureAt`, `totalAmount`, `currency`). Guard with a booking ref so double-click / StrictMode cannot POST twice.
Book response: `results.details[0]` — `bookingStatus`, `pnr`, `bmsBookingCode` / `bookingCode`, `isSuccessful`.
- Show a pending state on the button (“Booking…”) while the book call is in flight.
- If `FAILED` or `isSuccessful === false` → error, stay on page.
- If no `bmsBookingCode` / `bookingCode` comes back → error, stay on page. Never navigate to confirmation without a code.
- `PENDING` / `CONFIRMED` → confirmation route with booking code.
- Do not implement HOLD/RELEASE.
### Confirmation
- GET `flights-booking-get` once (`requestKeyRef = bookingCode`).
- **Do not** read `origin` / `destination` from itinerary store top-level or from `bookingDetails.origin`. Parse:
```
booking_details.flight_itinerary.responseData.results.itineraryItems[].itemFlight
```
(`itineraryItems` is camelCase, not `itinerary_items`. Wrapper key is `booking_details`.)
Iterate `itineraryItems`; each `itemFlight` has `origin` and `destination`. Build the route by chaining IATA codes (skip an origin that matches the previous destination): one-way `DEL → BOM`, return `DEL → BOM → DEL`.
| UI | Source |
|---|---|
| Status | `booking_details.bookingStatus` |
| Booking code | URL / `bmsBookingCode` |
| PNR | `itemFlight.pnr` (not `booking_details.pnr`) |
| Amount | `responseData.results.totalAmount` via `formatMoney` |
| Currency | `itemFlight.fareQuote.currency` |
| Itinerary | `responseData.results.itineraryCode` |
Session store is fallback only. Prefer API so refresh / new tab still works.
### My Bookings (`/bookings`)
Query Supabase `bookings` table (or `bookings-saved` Edge Function). Show newest first with status (CONFIRMED / FAILED / PENDING), route, amount, booking code.
---
## Client State (Zustand)
| Store | Holds |
|---|---|
| `searchStore.ts` | params, raw search `response`, `traceId`, `isDomestic`, `loading`, `lastQueryKey`, `clearResults`, selected outbound/inbound `rI` |
| `itineraryStore.ts` | `itineraryCode`, create + get itinerary `results`, `paxRules`, reprice result, **`currency`** from `fareQuote.currency`, **`duplicates`** from save-passenger |
| `sessionStore.ts` | book + booking-details payload, `bookingCode`, `pnr`, `status`, `amount`, `currency`, `origin`, `destination` |
---
## API Payload Examples
### Flight search (one-way)
```json
{
"adultCount": 1,
"childCount": 0,
"infantCount": 0,
"directFlight": false,
"journeyType": 1,
"origin": "DEL",
"destination": "BOM",
"preferredDepartureTime": "2026-10-09T00:00:00",
"flightCabinClass": 2
}
```
Round-trip: `"journeyType": 2` and `"preferredReturnDepartureTime": "2026-10-15T00:00:00"`. Omit `preferredReturnDepartureTime` on one-way.
Store `traceId` from the search response (`response.traceId`).
### Fare rules
```json
{
"traceId": "<from search>",
"resultIndex": "<flight.rI>"
}
```
### Create itinerary
```json
{
"traceId": "<from search>",
"items": [
{ "type": "FLIGHT", "resultIndex": "<outbound rI>" }
]
}
```
Round-trip: **two** items — outbound `rI` then inbound `rI`. Do not invent item types other than `"FLIGHT"`.
### Book
```json
{
"traceId": "...",
"origin": "DEL",
"destination": "BOM",
"departureAt": "2026-10-09",
"totalAmount": 1580,
"currency": "INR"
}
```
Meta fields (`origin`, `destination`, `departureAt`, `totalAmount`, `currency`) are for Supabase only — strip before VOLT call. VOLT receives `{ "traceId": "..." }` only.
---
## Folder Structure
```
src/
├── config/
│ └── brand.ts
├── integrations/supabase/
│ ├── client.ts
│ └── types.ts
├── lib/
│ └── volt-api.ts # wrappers around supabase.functions.invoke
├── data/
│ └── airports.ts
├── store/
│ ├── searchStore.ts
│ ├── itineraryStore.ts
│ └── sessionStore.ts
├── pages/
│ ├── Home.tsx
│ ├── FlightSearch.tsx
│ ├── Itinerary.tsx
│ ├── Passengers.tsx
│ ├── Reprice.tsx
│ ├── BookConfirmation.tsx
│ └── MyBookings.tsx
├── components/
│ ├── Navbar.tsx
│ ├── HeaderProductTabs.tsx # Hotels (disabled) · Flights (active) · Packages (disabled)
│ ├── SearchBar.tsx
│ ├── TravellersPopover.tsx
│ ├── AirportPicker.tsx
│ ├── FlightCard.tsx
│ ├── SelectedFlights.tsx
│ ├── FareRulesModal.tsx
│ ├── LayoverDetails.tsx
│ └── SupportDrawer.tsx
├── types/
│ └── flights.ts
├── utils/
│ ├── flightKeys.ts
│ ├── dates.ts
│ ├── money.ts
│ └── edge-function-errors.ts # parseEdgeFunctionError, friendlyApiMessage
├── index.css
├── App.tsx
└── main.tsx
supabase/
├── functions/
│ ├── _shared/
│ │ ├── volt-auth.ts
│ │ ├── volt-client.ts
│ │ └── cors.ts
│ ├── flights-search/index.ts
│ ├── flights-fare-rules/index.ts
│ ├── flights-itinerary-create/index.ts
│ ├── flights-itinerary-get/index.ts
│ ├── flights-passengers/index.ts
│ ├── flights-reprice/index.ts
│ ├── flights-book/index.ts
│ ├── flights-booking-get/index.ts
│ ├── bookings-saved/index.ts
│ └── bookings-saved-one/index.ts
└── migrations/
└── 001_bookings.sql
```
---
## Security Rules
1. **Never** put VOLT credentials in frontend code or Vite env
2. **Never** expose VOLT access tokens to the browser
3. Edge Functions are the only layer that talks to VOLT
4. Tighten RLS on `bookings` when user auth is added (policy: `auth.uid() = user_id`)
5. Return sanitized JSON error bodies to the client (with `code` / `upstreamStatus` / `errorCode`) — no raw upstream stack traces
6. **UI must not** display VOLT, Edge Function, or invoke error strings — use the **UI Error Rules** table
---
## VOLT Sandbox Notes
- Omit unused return date on one-way (do not send `preferredReturnDepartureTime` as null)
- Round-trip: return date ≠ depart date and ≥ 2 calendar days later
- `VOLT_SOURCE` drives the `source` header on login and all outbound calls
- Trace/search-session TTL: `errorCode` 6 → “search session expired” UX
- `totalAmount` on reprice/get-itinerary is **flight fare only**
- Save-passenger may return `duplicatesFound: true` plus `duplicateBookings[]` — pass through; still advance
- Booking status: `CONFIRMED` (PNR issued), `PENDING`, `FAILED`. HOLD / RELEASED not supported
- Airport list is static — there is no locations search API for flights
---
## Deliverable Checklist
The app is complete when:
1. All Edge Functions deploy and proxy VOLT correctly
2. `bookings` table exists with RLS policies
3. Full journey works: Home → Search → Select → Continue (new tab itinerary) → Passengers → Reprice → Book → Confirmation
4. Every book attempt is saved in Supabase (all statuses)
5. `/bookings` lists saved booking history
6. No mocked flight data (static **airports** list is allowed)
7. UI matches brand config (name + colors) — not hardcoded per component
8. Primary buttons are always visible (never white-on-white)
9. Result cards have **fareRule**, **`formatMoney(fF, cr)`**, stop count, layover **modal**
10. `/flights/search` shows route/date steps, list, sticky selected summary, Continue-only itinerary create
11. Domestic and international inbound shapes both work
12. Passengers → reprice never dead-ends on `duplicatesFound`; no raw JSON in the UI
13. Confirmation **Route** comes from `itineraryItems[].itemFlight.origin/destination`
14. All user-facing errors are generic with **no** VOLT/provider names
15. All VOLT API URLs are read from Supabase secrets (sandbox defaults documented; production URL swap documented)
16. Branding flows from `src/config/brand.ts` + `VITE_*` env vars
17. Header shows **Hotels**, **Flights**, **Packages** on every page; only Flights is clickable
18. Paper UI only — no navy hero, shadows, gradients, or colored chips
19. No unbounded / StrictMode-loop API calls (request-key refs + `lastQueryKey`)
20. **Final step:** prompt user for product name and colors (see end of prompt)
---
## Setup Steps for Developer
1. Create Supabase project
2. Run migration for `bookings` table
3. Set VOLT secrets in Supabase dashboard — **credentials and both API URLs** (`VOLT_AUTH_URL`, `VOLT_FLIGHTS_API_URL`)
4. Deploy all Edge Functions
5. Connect Lovable project to Supabase
6. Paste VOLT **credentials** into Supabase secrets (not frontend env). Confirm **sandbox URLs** are set initially.
7. **Before production go-live:** replace sandbox URL secrets with production URLs and production-aligned API keys; redeploy Edge Functions.
8. Test search → select outbound/return → Continue (new tab) → passengers → reprice (duplicates notice if any) → book → confirm row in `bookings`
---
## Mapping from Original Volt Flights Stack
| Original (Cursor/Volt) | Lovable equivalent |
|---|---|
| FastAPI backend | Supabase Edge Functions |
| `backend/.env` Travclan keys | Supabase secrets (`VOLT_*`) |
| MongoDB `bookings` collection | Supabase `bookings` table |
| `GET /api/v1/flights/bookings/saved` | `bookings-saved` function or direct Supabase query |
| Elasticsearch + Filebeat (logs) | Supabase Edge Function logs |
| `./run.sh` + Docker | Lovable deploy + Supabase deploy |
| `VITE_API_BASE_URL=http://localhost:8000` | `supabase.functions.invoke` + anon key |
| `POST /api/v1/flights/search` | `flights-search` |
| `POST /api/v1/flights/fare-rules` | `flights-fare-rules` |
| `POST /api/v1/flights/itinerary` | `flights-itinerary-create` |
| `GET /api/v1/flights/itinerary/{code}` | `flights-itinerary-get` |
| `POST .../passengers` | `flights-passengers` → `/passenger-collections` |
| `POST .../reprice` | `flights-reprice` → `/fare-quote` |
| `POST .../book` | `flights-book` |
| `GET /api/v1/flights/bookings/{code}` | `flights-booking-get` |
---
## Final Step — Prompt User for Branding (required)
After the app is built and working, **stop and ask the user** for their branding preferences before considering the project complete. Use a clear, friendly message like this:
---
**Customize your app branding**
Your flight booking app is ready. To match your product identity, please share:
1. **Product name** — shown in the navbar, browser tab, and heading (default: `SNAP`)
2. **Primary color** — main buttons, search CTA, links (default: `#0062E3`)
3. **Secondary color** — hovers, accents (default: `#004BB3`)
4. **Background color** — page background (default: `#ffffff`)
You can reply with hex codes, e.g.:
```
Product name: FlyEasy
Primary: #0062E3
Secondary: #004BB3
Background: #ffffff
```
**Where these live (easy to change anytime):**
| Setting | Quick change (no code) | Code default |
|---|---|---|
| Product name | Lovable **Settings → Environment Variables** → `VITE_APP_NAME` | `src/config/brand.ts` |
| Primary color | `VITE_PRIMARY_COLOR` | `src/config/brand.ts` → `colors.primary` |
| Secondary color | `VITE_SECONDARY_COLOR` | `src/config/brand.ts` → `colors.secondary` |
| Background color | `VITE_BG_COLOR` | `src/config/brand.ts` → `colors.background` |
After the user replies, apply their values to the env vars (and `brand.ts` defaults if env is not wired yet) and confirm: *"Branding updated — refresh the preview to see your colors."*
**Do not** scatter brand values across components; all UI must read from `src/config/brand.ts` and/or the `VITE_*` env vars above.
Troubleshooting
Refer to the troubleshooting page if you encounter any issues after building your application with Lovable.
Updated 4 days ago
