Build with Lovable

This is the fastest path: Lovable handles secrets, payments and deployment for you, so there's no local setup at all.

Prerequisites

  1. A Lovable project
  2. Lovable Cloud enabled for the project (Lovable will prompt you to enable it after you paste the prompt)
  3. Your Volt Trusted Getaways sandbox or production API Key

Step 1 — Copy the Prompt

Copy the full prompt from the Prompt section below into Lovable's chat. It already contains the full architecture, API wiring, pricing rules, page behaviour and a deliverable checklist — Lovable doesn't need anything else to get started.

Step 2 — Enable Cloud

Since this app needs a backend (server 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 server functions or create the bookings table, and the build will stall.

lovable cloud enable image

Enable cloud in Lovable

Step 3 — Add Your Volt Credentials

Lovable detects that the prompt needs secrets and will ask for them right in the chat. When asked, enter:

SecretValue
TG_API_KEYYour Volt Trusted Getaways API key
TG_API_BASE_URLhttps://holidays-volt-sandbox.travclan.com (sandbox)

These are stored as Lovable Secrets — never as frontend env variables. Switch FD_API_BASE_URL and FD_API_KEY to production (https://holidays-volt-tp.travclan.com) only when you're ready to go live.

Step 4 — Enable Payments (Test Mode)

The prompt uses Lovable's built-in payments for checkout. When Lovable asks, enable payments in test mode, so you can complete bookings end to end without real money.

Step 5 — Let Lovable Build

Lovable will create the database tables, deploy the server functions and build the React website automatically. This takes a few minutes — no manual deploy steps required.

Step 6 — Customize (Optional)

The prompt ships with sensible defaults (white + blue Airbnb-style UI, Thailand, Bali / Indonesia and Kerala as destinations). Common tweaks before pasting:

  • Change VITE_APP_NAME / VITE_PRIMARY_COLOR for your brand
  • Add or remove destinations in src/config/brand.ts (destination codes are listed in the FAQs)
  • Adjust the Routes table if you want different page names or extra steps

Step 7 — Test the Flow

Once Lovable finishes building, walk through the full journey end to end:

Home → Packages → Package Details (date, travellers, add-ons) → Review → Test payment → Confirmation → My Bookings

Confirm a row lands in the bookings table for both a successful and a failed booking attempt.


Prompt ✨

Copy everything inside the block below into Lovable Chat.

# Lovable Prompt: Trusted Getaways — Holiday Package Booking Website

Build **Trusted Getaways**, a holiday-package booking website. Customers browse real trusted getaways holiday packages from our supplier (group trips with fixed dates that bundle flights, hotels, transfers and activities), open a trip, customise it (date, travellers, upgrades, add-ons), pay online, and get a booking they can see again later.

- Use **Lovable Cloud** for the database and server logic (server functions).
- Use **Lovable's built-in payments in test mode** for checkout (hosted checkout page, one-time payment in INR).
- **No sign-in for now.** Every customer books as a guest; their bookings are linked to their browser with a private cookie.
- The browser never calls the supplier and never sees the supplier key. All supplier calls happen on the server.

Customer journey: **Home → packages for a destination → trip detail (date, travellers, upgrades, add-ons) → review (traveller details, price re-check) → hosted checkout → confirmation → My bookings.**

---

## Architecture

```
┌──────────────────┐  calls   ┌───────────────────────────────┐  HTTPS  ┌──────────────────────────┐
│ Website (React)  │ ───────► │ Lovable Cloud server functions │ ──────► │ Supplier Trusted Getaways│
│                  │          │ (supplier proxy, pricing,      │ ◄────── │ API (sandbox/production) │
└───────┬──────────┘          │  checkout, webhooks, sync)     │ webhook └──────────────────────────┘
        │ hosted checkout     └──────┬──────────────┬─────────┘
        ▼                            │              │ payment webhook (verified)
┌──────────────────┐                 ▼              │
│ Lovable payments │ ───────────────────────────────┘
│ (test mode)      │          ┌──────────────────────────────┐
└──────────────────┘          │ Lovable Cloud database        │
                              │ bookings, fd_listings_cache   │
                              └──────────────────────────────┘
```

| Layer | Role |
|---|---|
| **Website** | Browsing, trip customisation, live price display, traveller details. Calls server functions only. |
| **Server functions** | Supplier proxy with the secret key, response validation, re-pricing, checkout creation, payment webhook, supplier booking, supplier webhook, daily sync |
| **Payments** | Hosted checkout in test mode; the verified payment webhook is the only thing that marks a booking paid |
| **Database** | Bookings (every attempt), cached package data |
| **Supplier** | Source of truth for packages, departures, seats, prices and bookings |

---

## Secrets (server only)

Store in Lovable Cloud secrets — never in frontend code or `VITE_*` env.

```env
TG_API_KEY=                     # supplier key, sent as "x-api-key: Bearer <key>"
TG_API_BASE_URL=https://holidays-volt-sandbox.travclan.com
TG_ENV=sandbox                  # sandbox | production
CRON_SECRET=                    # random string; required header for /api/public/fd-sync
TG_WEBHOOK_SECRET=              # empty until the supplier shares signature details
GUEST_KEY_PEPPER=               # random string used when hashing guest access keys
```

Payments keys are managed by Lovable's built-in payments; keep payments in **test mode**.

**Sandbox only until go-live.** Tell the developer explicitly: *`TG_API_BASE_URL` and `TG_API_KEY` are sandbox values. Before going live, switch both to the production values from the supplier, set `TG_ENV=production`, switch payments to live mode, and redeploy. A test-mode payment must never create a production supplier booking: if payments are in test mode and `TG_ENV=production`, refuse to book.*

---

## Database (Lovable Cloud)

```sql
create type booking_status as enum
  ('pending_payment','paid','received','confirmed','failed','refund_needed','cancelled_unpaid');

create table public.bookings (
  id uuid primary key default gen_random_uuid(),
  reference text not null unique,             -- our booking reference, e.g. TG-20261117-4F8K2; sent as buyer_booking_reference
  guest_key_hash text not null,               -- hash of the private guest cookie value
  idempotency_key uuid not null unique,       -- from the Review page; retries return the same booking
  status booking_status not null default 'pending_payment',
  supplier_booking_code text,                 -- provisional code from the supplier Book call
  supplier_final_code text,                   -- final code from the supplier webhook
  supplier_internal_status text,              -- e.g. PENDING
  supplier_http_status int,
  supplier_error_code text,                   -- e.g. PRICE_CHANGED, NO_SEATS
  listing_code text not null,
  departure_code text not null,
  package_title text,
  hero_image_url text,
  travel_start_date date,
  travel_end_date date,
  occupancy jsonb not null,                   -- rooms exactly as sent to the supplier
  travellers jsonb not null,                  -- lead traveller {name,email,phone} + names (and ages) of all travellers
  selections jsonb not null default '{}',     -- hotel_upgrades and add-on codes
  price_breakdown jsonb not null,             -- room lines + extra lines as shown on Review
  amount numeric not null,
  currency text not null default 'INR',
  payment_id text,                            -- checkout session / payment id
  payment_status text,                        -- created | paid | failed | refunded
  is_test boolean not null default true,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
create index bookings_guest_idx on public.bookings (guest_key_hash, created_at desc);

create table public.fd_listings_cache (
  listing_code text primary key,
  destination_code text not null,
  listing jsonb not null,      -- validated item of data.listings
  detail jsonb,                -- validated Detail data
  day_wise jsonb,              -- validated Day-wise data
  synced_at timestamptz not null default now()
);
create index fd_cache_dest_idx on public.fd_listings_cache (destination_code);

alter table public.bookings enable row level security;          -- no policies: browser cannot read or write
alter table public.fd_listings_cache enable row level security;
create policy "public read cache" on public.fd_listings_cache for select using (true);
```

**Access rules**
- The browser **never** reads or writes `bookings` directly. Server functions read a guest's bookings by hashing the guest cookie and matching `guest_key_hash`.
- On the first checkout, the server sets an **HttpOnly, Secure, SameSite=Lax** cookie `tg_guest` with 32 random bytes (1-year expiry) and stores only its hash. Later bookings from the same browser reuse it.
- Only server functions create or update bookings.

---

## Server functions

All return JSON with CORS for the site origin, also on errors.

### Shared request helper

```typescript
async function fd(path: string, init: RequestInit = {}) {
  const res = await fetch(`${Deno.env.get("TG_API_BASE_URL")}${path}`, {
    ...init,
    headers: {
      Accept: "application/json",
      "x-api-key": `Bearer ${Deno.env.get("TG_API_KEY")}`,
      ...(init.method === "POST" ? { "Content-Type": "application/json" } : {}),
    },
  });
  const body = await res.json().catch(() => null);
  return { status: res.status, body };
}
```

On a non-2xx response, return a structured body the frontend can parse. Never pass raw upstream bodies, stack traces or the key to the browser. The one exception is the 409 price change, whose `data` block is passed through:

```json
{ "error": { "code": 409, "kind": "PRICE_CHANGED", "expected_amount": 85000, "requested_amount": 80000, "difference": 5000, "currency": "INR" } }
```

**Validate every supplier response** with zod schemas before using it (`src/lib/fd-schemas.ts`, shared with the server). Invalid or missing fields are dropped, not invented: hide the UI element instead.

### Routes

| Function / route | Method | Supplier call | Purpose |
|---|---|---|---|
| `fd-listings` | GET | `GET /api/v1/fixed-departure/listings?destination_code=&page=&page_size=` | Packages for a destination (serve cache if synced < 24h, else live) |
| `fd-detail` | GET | `GET /api/v1/fixed-departure/listings/{code}` | Trip detail (cache, or `?live=true`) |
| `fd-day-wise` | GET | `GET /api/v1/fixed-departure/listings/{code}/day-wise-itinerary` | Day-by-day plan |
| `quote` | POST | live Detail | Re-check before payment (see flow) |
| `checkout-create` | POST | live Detail | Create the booking row + hosted checkout session |
| `payments-webhook` | POST | `POST /api/v1/fixed-departure/listings/{code}/book` | Verified payment → mark paid → book with supplier |
| `booking-get` | GET | — | One booking by reference, only if the guest cookie matches |
| `bookings-mine` | GET | — | All bookings for this browser's guest cookie |
| `/api/public/fd-webhook` | POST | — (called by supplier) | Final booking code |
| `/api/public/fd-sync` | POST | Listing + Detail + Day-wise | Daily cache refresh; requires header `x-cron-secret: CRON_SECRET` |

### Checkout and booking flow

1. **Review → `quote`.** The site sends `{ listingCode, departureCode, occupancy, selections }`. The server fetches the **live** Detail, checks the departure is still open (today within `booking_start_date`–`booking_end_date`) and has enough seats, checks the room, minimum-adult and seat rules, and re-prices with the pricing module. It returns `{ amount, currency, priceBreakdown, changed, previousAmount }`.
2. **If the price changed** (or the date closed, or seats ran out), the site shows the new price or problem in a dialog and asks the customer to confirm before continuing. Nothing is charged.
3. **Pay → `checkout-create`.** Body: the same trip data + `travellers` (lead name, email, phone; names and ages of all travellers) + `idempotencyKey` (UUID created when the Review page opens). The server re-prices again, then:
   - creates or reuses the guest cookie,
   - creates **one** `bookings` row with status `pending_payment`, a new unique `reference`, the re-checked `amount`, `is_test` = payments test mode,
   - creates a hosted checkout session (INR, one-time, full trip total, `metadata.reference`), stores `payment_id`, and returns the checkout URL.
   - The same `idempotencyKey` returns the existing booking and session; double clicks never create two bookings or two charges.
4. **Customer pays** on the hosted page and returns to `/book/confirmation/{reference}` (success or cancel URL).
5. **`payments-webhook`** (signature verified with Lovable payments): on a successful payment, set `status = paid`, `payment_status = paid`. Then the **server** books with the supplier:
   - Refuse if `is_test` and `TG_ENV=production`.
   - Re-check with live Detail; build the Book body (below) with `buyer_booking_reference = reference`; call Book once (guard with the row status so a repeated webhook never books twice).
   - **200** → `status = received` (seats blocked, provisional booking), save `supplier_booking_code`, `supplier_internal_status`.
   - **409** (price changed) or **400 / 404 / 500** → `status = refund_needed`, save `supplier_http_status` and `supplier_error_code`. The confirmation page tells the customer clearly that the payment will be refunded.
   - Payment failed or expired → `status = cancelled_unpaid`.
6. **Never** mark a booking paid from the browser, and never call Book from the browser.

### Supplier Book request body

Send every field, `[]` when nothing was chosen:

```json
{
  "departure_code": "fddjk5f",
  "buyer_booking_reference": "TG-20261117-4F8K2",
  "occupancy": [
    { "num_of_adults": 2, "child_with_bed_ages": [8], "child_without_bed_ages": [], "infant_ages": [] },
    { "num_of_adults": 1, "child_with_bed_ages": [], "child_without_bed_ages": [4], "infant_ages": [1] }
  ],
  "hotels": ["fhsjk49", "fhsjk4a"],
  "hotel_upgrades": [{ "base_hotel": { "code": "fhsjk49" }, "upgrade_hotel": { "code": "fhsjkyg" } }],
  "sightseeings": ["ssgjzls", "ssgjzxo", "ssgjzxe"],
  "sightseeing_addon_codes": ["fsajky9"],
  "transfers": ["fdtjk4w", "fdtjk4i", "fdtjk40"],
  "transfer_addon_codes": [],
  "flights": ["flsjkyz"],
  "flight_origin_addon_codes": [],
  "total_amount": 452000,
  "currency": "INR"
}
```

| Field | Source |
|---|---|
| `departure_code` | `data.departures[].code` of the selected departure |
| `buyer_booking_reference` | **Mandatory.** Our own unique reference, also used as the payment reference. The webhook sends it back. |
| `occupancy` | One item per room, from the travellers picker, with each child's and infant's age |
| `hotels` | `data.hotels[].code` of every included stay |
| `hotel_upgrades` | One item per chosen upgrade: `base_hotel.code` = `hotels[].code`, `upgrade_hotel.code` = `hotels[].upgrades[].code` |
| `sightseeings` | `data.sightseeings[].sightseeing.code` |
| `sightseeing_addon_codes` | `data.addons[].code` of chosen optional activities |
| `transfers` | `data.transfers[].transfer.code` |
| `transfer_addon_codes` | `data.transfers[].addons[].code` of chosen transfer add-ons |
| `flights` | `data.flights[].code` |
| `flight_origin_addon_codes` | `data.flights[].outbound.origin_addons[].code` of the chosen start city |
| `total_amount`, `currency` | Our calculated total; `data.departures[].currency` |

The Book API does **not** take traveller documents or traveller details. Do not send them. Traveller details are stored only in our `bookings.travellers`; after a successful booking the confirmation page tells the traveller that documents will be collected.

### `/api/public/fd-webhook` (supplier → us)

The supplier calls this when a booking is confirmed:

```json
{ "type": "FINAL_BOOKING_CODE_ASSIGNED", "quote_code": "qf9v8", "requirement_code": "lkw76", "booking_code": "tr6gh", "buyer_booking_reference": "TEST-BUY-1" }
```

- **Verify the supplier's signature before changing anything.** Until `TG_WEBHOOK_SECRET` and the signature details are configured, return **501 Not available** and change nothing (bookings stay `received`).
- When verified: find the booking by `reference = buyer_booking_reference`, set `supplier_final_code`, `status = confirmed`, `updated_at`. Unknown references return 200 and are logged.
- This `booking_code` is the **final** code; it differs from the provisional `supplier_booking_code`.

### `/api/public/fd-sync` (daily)

Schedule daily (for example 02:00 IST) with the header `x-cron-secret`. Reject other calls with 401. For every destination in the brand settings, page through Listing, fetch Detail and Day-wise for each listing, validate, upsert `fd_listings_cache`, and delete listings no longer returned. The trip page reads the cache; `quote`, `checkout-create` and the payment webhook always use live Detail.

---

## Supplier response contracts

All responses are `{ "status": 200, "data": ... }`.

### Listing — `data`

| Key | Use |
|---|---|
| `count` | "N packages" |
| `listings[]` | One card each, in API order |
| `listings[].code`, `title`, `destination_name`, `nights_count`, `star_category` | Card text and link |
| `listings[].images[]` (`url`, `is_hero`) | Card carousel; hero first |
| `listings[].hotels[]` (`hotel.area_name`, `rooms[].rooms[].nights_count`) | Route line, e.g. "3N Pattaya → 1N Bangkok" |
| `listings[].departures[]` | Price, seats and dates (see departure rule) |
| `listings[].marketing_prices.adult` (`hotel` + `flight` + `land`) | Struck-through market price |

**Which departure a card shows:** the earliest `travel_start_date` among departures where today is between `booking_start_date` and `booking_end_date` and `total_available > 0`. If none qualifies, show the card as "Sold out".

### Detail — `data` (same keys as a listing item, plus)

| Key | Use |
|---|---|
| `description`, `inclusions`, `exclusions`, `payment_policy`, `cancellation_policy` | HTML: sanitize, then render lists as rows |
| `departures[]` | Date picker. Per departure: `code`, `travel_start_date`, `travel_end_date`, `booking_end_date`, `total_available`, `adult_price`, `single_adult_price`, `child_price_with_bed`, `child_price_without_bed`, `infant_price`, `currency`, `max_age_child_with_bed`, `max_age_child_without_bed`, `max_age_infant`, `min_occupancy`, `max_occupancy` |
| `hotels[]` | `code`, `hotel.name`, `hotel.area_name`, `meal_type`, `day_of_trip`, `rooms[].rooms[]` (`name`, `nights_count`), `upgrades[]` |
| `hotels[].upgrades[]` | `code`, `hotel`, `meal_type`, `upgrade_adult_price`, `upgrade_single_adult_price`, `upgrade_child_price_with_bed`, `upgrade_child_price_without_bed`, `upgrade_infant_price` |
| `sightseeings[]` | `sightseeing.name`, `sightseeing.code`, `sightseeing.images[]`, `type` (SIC, PRIVATE, TICKETS_ONLY), `day_of_trip` |
| `addons[]` | Optional activities: `code`, `sightseeing.name`, `type`, `day_of_trip`, `adult_price`, `child_price`, `infant_price` |
| `transfers[]` | `transfer.code`, `transfer.transfer_type`, `transfer.routes[]` (`transfer_mode.name`), `addons[]` (`code`, `transfer_type`, `adult_price`, `child_price`, `infant_price`) |
| `flights[]` | `code`, `is_primary`, `day_of_trip`, `outbound`, `inbound` (may be `null`), segments with `airline`, `origin`, `destination`, `duration` (minutes), `baggage`, `cabin_class`; `outbound.origin_addons[]` (`code`, `home_origin_city`, `flight_origin`, `adult_price`, `child_price`, `infant_price`) |

`hotels[].addons[]` and `hotels[].upgrades[].addons[]` (types ROOM and MEAL, e.g. "Single sofa cum bed") are **not accepted by the Book API**. Show them view-only under the hotel card, labelled "Request at the hotel" — no Add button, never in the total or the booking request.

### Day-wise Itinerary — `data[]`

One item per day: `dayCount`, `areaName` (city, may be `null`), `components[]`. `itemType: 2` is a transfer (`title`, `pickUpPoint`, `dropPoint`, `mode`, `transferType`, `code`); `itemType: 0` is an activity (`title`, `transferType`, `code`, `additionalDetails.images[]`). Hotels and flights are **not** in this response; add them from Detail.

### Book — success (200)

Keys: `booking_code` (provisional), `buyer_booking_reference`, `status` (`CONFIRMED`), `internal_booking_status` (`PENDING` until TravClan confirms), `booking_amount`, `currency`, `travel_start_date`, `travel_end_date`, `request_body.occupancy`, `listing` (the package). A 200 means **seats are blocked and a provisional booking exists**; it is confirmed later, after the traveller's documents arrive.

### Book — errors

| Status | When |
|---|---|
| `401` | Missing or invalid API key |
| `404` | Listing not found, or not bookable for the organisation |
| `400` | Invalid body, occupancy, components, inventory or currency |
| `409` | Price mismatch — `{ message: "Prices have changed", data: { requested_amount, expected_amount, difference, currency } }` |
| `500` | Unexpected error |

---

---

## Pricing and booking rules (shared module)

Put all of this in **one pure TypeScript module** used by the website (`src/lib/pricing.ts`) and by the server functions `quote`, `checkout-create` and `payments-webhook` (`_shared/pricing.ts`, identical code). No UI code inside. Cards, totals, Review and the booking request must all come from this module so they never disagree. Short names: SAP single adult price, AP adult, CWBP child with bed, CWOBP child without bed, IP infant — all from the selected departure.

### Traveller age bands (per departure)

- Infant: 0 to `max_age_infant` (a 2-year-old is an infant when `max_age_infant` is 2)
- Child with bed: `max_age_infant + 1` to `max_age_child_with_bed`
- Child without bed: `max_age_infant + 1` to `max_age_child_without_bed` (can be lower than with bed — read both)
- Adult: above `max_age_child_with_bed`

### Room rules (each room checked on its own)

1. Every room needs at least one adult.
2. A room holds up to `max_occupancy` travellers (DOUBLE = 2, TRIPLE = 3), infants included.
3. Exception — one extra infant with two or more adults: DOUBLE allows 2 adults + 1 infant; TRIPLE allows 3 adults + 1 infant, 2 adults + 1 child with bed + 1 infant, 2 adults + 1 child without bed + 1 infant.
4. Any other mix is invalid.

### Booking checks

- Total adults across all rooms ≥ `min_occupancy` (DOUBLE = 2, TRIPLE = 3). Children and infants do not count.
- Seats needed = adults + children (infants take no seat) ≤ `total_available`.

### Room price

- 2+ adults: `adults × AP + cwb × CWBP + cwob × CWOBP + infants × IP`
- 1 adult, alone: `SAP`
- 1 adult with others: the **lower** of
  - Option A: `SAP + cwb × CWBP + cwob × CWOBP + infants × IP`
  - Option B: `2 × AP + everyone else at their own price`, where one companion is charged AP instead of their own price — a child with bed if any, otherwise a child without bed, otherwise an infant.

### Hotel upgrade price (per room)

The same rule as the room price, using the upgrade rates (`upgrade_adult_price` as AP, `upgrade_single_adult_price` as SAP, and so on). The lower-of-two choice is made separately for the upgrade.

### Add-ons (per traveller)

`adults × adult_price + (children with and without bed) × child_price + infants × infant_price`, for each chosen optional activity, transfer add-on and start-from-another-city add-on.

### Grand total

`Σ rooms (room price + upgrade price) + Σ chosen add-ons`

### Unit tests (required — must pass)

| Case | Prices | Expected |
|---|---|---|
| 1 room, 2 adults | AP 48,000 | 96,000 |
| Room 1: 2 adults · Room 2: 1 adult + 1 child with bed + 1 infant | SAP 63,000, AP 48,000, CWBP 48,000, IP 8,000 | 96,000 + min(1,19,000, 1,04,000) = **2,00,000** |
| Room 1: 2 adults + child with bed · Room 2: 1 adult + child without bed + infant | SAP 55,000, AP 40,000, CWBP 30,000, CWOBP 25,000, IP 5,000 | 1,10,000 + 85,000 = **1,95,000** |
| Room 1: 1 adult + child with bed + infant, with upgrade · Room 2: 2 adults | AP 40,000, SAP 55,000, CWBP 30,000, IP 5,000; upgrade AP 8,000, SAP 12,000, CWBP 5,000, IP 1,000 | 85,000 + 17,000 + 80,000 = **1,82,000** |
| 2 adults + 1 child + 1 infant take an activity | adult 3,000, child 2,000, infant 1,000 | **9,000** |
| 1 adult in 1 room, DOUBLE departure | — | Booking fails (min adults) |
| Room with 2 adults + 1 child with bed, DOUBLE departure | — | Invalid room |

---
---

## Frontend

Use the project's default Lovable stack (React + TypeScript + Tailwind + shadcn/ui) with: TanStack Query for data, Zustand for trip state, zod for validation, Embla for carousels, DOMPurify for supplier HTML.

### Routes

| Route | Page |
|---|---|
| `/` | Home — hero with destination search, trust row, destination cards, featured trips |
| `/packages?destination=<code>` | Packages for a destination |
| `/package/$code` | Trip detail — photos, booking box, highlights, day-wise plan with add-ons, inclusions, things to know |
| `/book/review` | Review — trip, rooms and prices, extras, lead traveller and traveller names, re-check, Pay |
| `/book/confirmation/$ref` | Booking status after checkout (polls `booking-get` until it leaves `paid`) |
| `/bookings` | My bookings in this browser |

Trip state is kept in the URL on the trip page so it can be shared: `?departure=fddjk5f&rooms=2-1-0-0,1-0-1-1&addons=fsajky9&upgrade=fhsjk49:fhsjkyg` (rooms are `adults-childWithBed-childWithoutBed-infants`).

**Only real supplier data on listing and trip pages.** Never show sample or demo prices, dates or photos. If a field is missing, hide that element.

**Every page sets its own `<title>` and meta description** (and Open Graph title, description and image for sharing), e.g. "4N Pattaya Bangkok EX-DEL — 17 Nov · Trusted Getaways".

---

## Visual Design — Airbnb-like design sense

Aim for a calm, photo-first, premium travel feel in the spirit of Airbnb: white background, generous white space, large rounded photos, rounded cards with soft shadows, quiet typography and **one strong blue** for buttons and chips. Take the principles, not the brand (no Airbnb logo, name or illustrations).

**Blue chips and blue buttons always use white text — never black.**

### Principles

1. **Photos lead.** Package images are the largest element on every browsing screen. Text sits below images, never on top, except small badges.
2. **One clear action per screen.** The primary button (brand primary colour, white text) is the only saturated element; everything else is neutral.
3. **Quiet hierarchy.** Few font sizes, weight for emphasis rather than colour, grey for secondary text.
4. **Soft, consistent shapes.** Cards and images 12–16px radius, pills fully rounded, inputs 10px.
5. **Trust signals close to the price.** Seats left, market price struck through, "Free day" and policy summaries near the booking panel.
6. **Nothing jumps.** Skeletons with the same size as the content, images with fixed aspect ratios, state changes update in place (sheets and dialogs never re-mount or replay animations when a count changes).

### Design tokens

| Token | Value |
|---|---|
| Font | Inter (or system UI stack) — 400, 500, 600, 700 |
| Type scale | 12 · 14 · 16 (body) · 18 · 22 · 28 · 36/44 (hero) |
| Line height | 1.5 body, 1.2 headings |
| Spacing scale | 4 · 8 · 12 · 16 · 24 · 32 · 48 · 64 · 96 |
| Radius | 8 (chips), 12 (cards), 16 (images, panels), 999 (pills) |
| Shadow | Card hover `0 6px 16px rgba(0,0,0,.12)`; floating panel `0 6px 20px rgba(0,0,0,.18)` |
| Neutrals | Text `#222222`, secondary `#6A6A6A`, border `#DDDDDD`, surface `#F7F7F7` |
| Accent | `brand.colors.primary` for CTAs, selected states, links |
| Status | Seats-left orange `#C2410C` on `#FFF1E6`; success `#1E8A4C`; error `#C13515` |
| Motion | 150–250ms ease-out; respect `prefers-reduced-motion` |
### Responsive layout

Mobile-first. Breakpoints `sm 640`, `md 768`, `lg 1024`, `xl 1280`. Content max width 1280px; side padding 16px (mobile), 24px (tablet), 40–80px (desktop). **No sideways scrolling at 360, 768, 1024 and 1440 px.** Touch targets at least 44 × 44px.

| Element | Phone (< 768) | Tablet (768–1023) | Desktop (≥ 1024) |
|---|---|---|---|
| Header | Logo + menu; "Where to?" pill | Logo, search pill, My bookings | Logo left, destination search pill centre, "My bookings" right; compact and sticky on scroll |
| Package grid | 1 column | 2 columns | 3 columns (lg), 4 (xl) |
| Trip photos | **Only the large photo**, with a "1 / N" count; tap opens all photos | Large photo + 2 small | **Photo grid:** one large photo left, up to 4 smaller right, "Show all N photos" button |
| Booking box | **Bar fixed to the bottom of the screen**: trip total, selected date, traveller count, Book now | Bottom bar | **Sticky booking box** beside the photos and content (top offset 96px) |
| Pop-ups (date, travellers, upgrades) | Open from the bottom (sheet, max 88% height, scrolls inside) | From the bottom | Centred dialog (max 560px), or popover anchored to the booking box for date and travellers |
| Review page | Single column, pay bar at the bottom | Single column | Two columns: details left, sticky price card right |

### Components

**Package card** (listing grid and featured trips)
- Hero photo (4:3, radius 16) with carousel dots when there are several photos
- Badges: first tag (e.g. "Honeymoon") top-left; **🔥 Only N seats left** top-right when 5 or fewer seats remain
- Title (16, 600), "4N · 5D · ⭐ 4-star", route line ("3N Pattaya → 1N Bangkok")
- **Next departure** ("Next: Tue, 17 Nov")
- **"from ₹48,000 / adult"** plus the market price struck through
- Whole card links to the trip; cards with no open departure show "Sold out"

**Home**
- Hero: large rounded image band, headline and sub-line from brand settings, destination search pill ("Where do you want to go?" → destination list → "Explore")
- **Trust row**: 3–4 short points with emoji, e.g. "✈️ Flights included", "🏨 Handpicked hotels", "🧭 Fixed dates, small groups", "🔒 Secure payment"
- Destination cards (photo from the first listing of each destination, name, number of trips)
- Featured trips: earliest open departures across destinations

**Trip detail — first screen** (on desktop everything below fits above the fold without scrolling)
- Breadcrumb: Home › Thailand › 4N Pattaya Bangkok EX-DEL
- Title (28, 600) and a summary line: **"4 nights · 6 days · 4-star stays"** (days from the selected departure's dates)
- Photo grid as in the responsive table. Use all supplier photos, hero first. "Show all N photos" and clicking **any** photo opens a full-screen gallery (keyboard and swipe navigation, close on Escape).
- **Booking box** (sticky on desktop): price per adult (market price struck through), the **selected departure** with seats left and "Change", the **travellers and rooms** with "Change", a **"Your extras"** list (each line removable), the **trip total**, and **Book now**. Rule messages appear above Book now and disable it.
- **Phone bottom bar**: trip total, selected date, traveller count, Book now.

**Trip detail — content**
- Emoji highlights: ✈️ Flights, 🏨 Hotels, 🚌 Transfers, 🎟️ Activities, 🍳 Breakfast — only those actually included
- **"Your getaway"**: the sanitized description, clamped to 4 lines with "Show more"
- **Day-wise plan** built by `buildItinerary()` with add-ons on their days (sections below)
- **"What's included"**: ✅ each included line, ❌ each not-included line (first 5 of each + "Show all")
- **"Things to know"**: 💳 Payment policy and ↩️ Cancellation policy (accordions)

**Choose departure (popup only)**
- **Never list all departure dates on the page.** The page shows only the selected departure inside the booking panel / bottom bar. On first load, preselect the earliest departure that is open for booking with seats left (the same rule as the listing card).
- Tapping the Departure field or "Change" opens the **departure picker**: a **bottom sheet** on mobile and tablet, a **popover anchored to the booking panel** (or a centred dialog, max 560px) on desktop.
- Inside: month tabs (only months that have departures), then one option per departure showing the date range, weekday, seats left ("Only 3 seats left" in orange when ≤ 5) and price per adult. Sold-out or closed departures are shown greyed out and disabled with "Sold out" / "Booking closed".
- Selecting an option highlights it; **"Confirm date"** applies it and closes the popup. Closing without confirming keeps the old date.
- Changing the departure re-runs prices, age bands, seat checks and the itinerary dates; if the current travellers break the new departure's rules, show the rule message in the panel.
- Sync the chosen departure to the URL (`?departure=`).

**Travellers picker**: per room — Adults, Children with bed, Children without bed, Infants, each with its age band label from the departure (e.g. "3 to 12 years"); age dropdown for each child and infant; "Add another room" (max 3); live rule messages ("Room 2 needs at least one adult", "This departure needs at least 2 adults", "Only 10 seats left"). Updating a count changes state in place — never re-open or re-animate the sheet.

### Day-wise itinerary assembly (`src/lib/itinerary.ts`)

The plan merges two responses: **Day-wise Itinerary** gives the days, cities, transfers and activities; **Detail** gives the hotels, flights and add-ons. Build it in one pure function, unit-tested, and render only its output.

```typescript
type DayItem =
  | { kind: "flight"; flightCode: string; leg: "outbound" | "inbound" | "other"; segments: Segment[]; originAddons: OriginAddon[] }
  | { kind: "transfer"; code: string; title: string; from: string; to: string; mode: string; type: string; addons: TransferAddon[] }
  | { kind: "hotel_checkin"; stayCode: string; hotel: Hotel; roomName: string; mealType: string; nights: number; upgrades: Upgrade[] }
  | { kind: "hotel_stay"; stayCode: string; hotelName: string; night: number; nights: number }   // continuing nights
  | { kind: "activity"; code: string; title: string; type: string; image?: string }
  | { kind: "optional_activity"; addon: SightseeingAddon }
  | { kind: "leisure" };

type DayVM = { dayCount: number; date: string; city: string | null; items: DayItem[] };

function buildItinerary(detail, dayWise, departure): DayVM[]
```

**Steps**

1. **Days.** One `DayVM` per item of Day-wise `data`, sorted by `dayCount`. `date = travel_start_date + (dayCount − 1)` from the selected departure. `city = areaName`; when `null`, hide the city.
2. **Transfers and activities.** Copy each `components[]` entry in the order returned: `itemType 2` → `transfer` (title, `pickUpPoint` → `dropPoint`, vehicle from `mode`, shared or private from `transferType`, number of cabs from `totalCabs`, `code`) — shown as "Bus (occupancy 10) · Shared · 1 cab", `itemType 0` → `activity` (title, `transferType`, first `additionalDetails.images[]`).
3. **Attach transfer add-ons.** For each `transfer` item, find `detail.transfers[]` whose `transfer.code` equals the component `code`; attach its `addons[]`.
4. **Hotels.** For each `detail.hotels[]` stay: `nights = rooms[0].rooms[0].nights_count`.
   - On day `day_of_trip`, add a `hotel_checkin` item (hotel name, room name, meal plan, nights) and attach `upgrades[]`. Place it **after the transfer whose `dropPoint` matches the hotel name**; if there is no such transfer, after the last transfer of that day; if there are no transfers, first in the day.
   - On each following night of the stay (`day_of_trip + 1` … `day_of_trip + nights − 1`), add a compact `hotel_stay` row ("Staying at Mind Resort · night 2 of 3") at the end of the day.
   - If two stays overlap or `day_of_trip` is outside the days, keep the stay on its `day_of_trip` (clamp to the first or last day) and never drop it.
5. **Flights.**
   - **Primary flight** (`is_primary: true`): the outbound goes **first on Day 1**, with `outbound.origin_addons[]` attached. The inbound goes **last on the last day**, only when `inbound` is not `null` and has at least one segment.
   - **Non-primary flights** (`is_primary: false`): the outbound goes on the day in `day_of_trip`, first in that day; if `inbound` has segments, place it on the last day after the primary inbound.
   - **Several segments:** one flight card with the segments in order. Between segments, show the layover ("Layover at KUL · 1h 40m" from `stop_over_point` / `ground_time`). Never split one flight across days and never repeat a flight.
   - Card fields: airline name + flight number, origin and destination airport codes with terminal, departure and arrival times, "+1 day" when arrival is earlier than departure, duration (`duration` minutes → "4h 05m"), stops, check-in and cabin baggage, cabin class.
6. **Optional activities.** For each `detail.addons[]`, add an `optional_activity` item on its `day_of_trip`, after that day's included activities.
7. **Leisure days.** A day is a **leisure day** when it has no transfer, no activity, no flight and no `hotel_checkin`. Add a `leisure` item first ("Day at leisure — free time to explore {city}"). If optional activities fall on that day, show them **below the leisure note** as suggestions ("Make the most of your free day"). `hotel_stay` rows still show at the end.
8. **Order within a day:** outbound flight → transfers and activities in API order, with the hotel check-in inserted as in step 4 → optional activities → `hotel_stay` → inbound flight.

**Unit tests for `buildItinerary`**: using the 4N Pattaya Bangkok EX-DEL data — XJ231 first on Day 1 with its start-city add-on; Mind Resort check-in on Day 2 after the "Arrival" transfer, "night 2 of 3" and "night 3 of 3" rows on Days 3 and 4; Day 4 has no included items so it shows the leisure note with the optional activity below it; Cross Vibe check-in on Day 5 after "Inter hotel transfer", with the transfer add-on attached; XJ230 last on Day 6. A primary flight with `inbound: null` adds nothing to the last day. A two-segment flight renders as one card with one layover.

### Add-ons in the day-wise plan (UI)

Add-ons live **on the day and item they change**, never in a separate block. Show an add-on control only when that add-on exists in the response.

| Add-on (source) | Where it appears | Control | Picker content |
|---|---|---|---|
| Start from another city (`flights[].outbound.origin_addons[]`) | Primary outbound flight card, Day 1 | Link "Start from another city ›" | Radio list: the included city (`outbound.origin`, "Included") and each `home_origin_city` ("Connects to the {flight_origin} flight") with extra price per adult, child, infant. One choice. |
| Hotel upgrade (`hotels[].upgrades[]`) | The hotel check-in card on its day | Link "See upgrade options ›" | Radio list: the included hotel ("Included") and each upgrade hotel with photo, area, room name, **meal plan chip** (from `upgrades[].meal_type`, e.g. "Half board"), and extra price per adult, child with bed, child without bed, infant. One choice per stay. |
| Transfer add-on (`transfers[].addons[]`) | The matching transfer card on its day | Link "Upgrade transfer ›" | Radio list: the included transfer ("Shared (SIC)", vehicle) and each add-on (`transfer_type`, e.g. "Private transfer — just your group") with extra price per adult, child, infant. One choice per transfer. |
| Optional activity (`addons[]`) | Its own card on its `day_of_trip`, also on leisure days | "Add" toggle button on the card | No picker. Card shows the **Optional** tag, name, type (SIC / PRIVATE / TICKETS_ONLY), and price per adult with child and infant prices. |

**Room extras the supplier can't book** (`hotels[].addons[]`, e.g. "Single sofa cum bed"): show a muted line under the hotel card — "🛏️ Single sofa cum bed · Request at the hotel" — with no toggle and no price in the total.

**Pickers**: bottom sheet on mobile and tablet, centred dialog (max 560px) on desktop. The included option is preselected. The confirm button reads "Select upgrade" / "Select city" when an add-on is chosen, and "Keep included" when the included option is chosen. Closing without confirming changes nothing.

**Card states**

| State | Flight / hotel / transfer card | Optional activity card |
|---|---|---|
| Not chosen | Link "… ›" under the card details | Dashed border, **Optional** tag, "Add" outline button |
| Chosen | Card content switches to the chosen option (e.g. hotel name → "Pan Pacific", meal plan updated), plus a green pill "Upgraded" / "Private" / "Starts from Jaipur (JAI)" with a "Change" link | Solid accent border, "Added ✓" filled button; tapping again removes it |

**Prices on add-ons**: show the extra price per adult in bold ("+₹35,000 per adult") and the child / infant prices on a second line. Never show multiplied totals on the card; the effect on the trip appears only in the live total and in the review breakdown.

**State and totals**: selections live in the trip store (`hotelUpgrades: Record<stayCode, upgradeCode>`, `transferAddons: Record<transferCode, addonCode>`, `originAddon: string | null`, `sightseeingAddons: string[]`). Every change updates the live total, the "Your extras" list in the booking panel, the URL, and a short toast ("Added to your trip" / "Removed from your trip"). Changing the departure or travellers keeps the selections and re-prices them. All selections map to the Book body: `hotel_upgrades`, `transfer_addon_codes`, `flight_origin_addon_codes`, `sightseeing_addon_codes`.

**Review booking** lists each chosen add-on on its own line with its total ("Hotel upgrade — Pan Pacific, Pattaya · Half board"), below the room lines.

### Selection states — keep every related card in sync (required)

Every add-on choice is a single piece of state in the trip store. **Every card that shows that add-on, its item or its price must re-render from that state immediately**: no reload, no stale card anywhere on the page, and no re-mount or re-animation of open sheets. Removing an add-on reverts every one of these places.

| When the traveller… | Update these places (all of them) |
|---|---|
| **Selects a hotel upgrade** | The hotel check-in card switches to the upgrade hotel (name, area, room, photo) with the meal plan chip from `upgrades[].meal_type` and a green "⭐ Upgraded" pill with "Change"; **every "Staying at …" row for that stay** shows the upgrade hotel name; the booking panel / bottom bar total and the "Your extras" list ("🏨 Pan Pacific · Half board"); the upgrade sheet reopens with the upgrade preselected; Review shows a "Hotel upgrade" line |
| **Selects a transfer add-on** | That transfer card's subtitle becomes the add-on type ("Private transfer — just your group"), its icon changes 🚌 → 🚗, and a green "Private" pill with "Change" appears; total and "Your extras" update; the sheet reopens with the add-on preselected; Review shows a "Transfer upgrade" line |
| **Selects a start city** | The Day 1 flight card shows a connecting line above the flight ("✈️ Starts from Jaipur (JAI) · connects to DEL") and a green pill with "Change"; total and "Your extras" update; the sheet reopens with that city preselected; Review shows a "Start from another city" line |
| **Adds an optional activity** | The activity card switches to the selected style (solid accent border, filled "Added ✓" button, `aria-pressed="true"`); on a leisure day the leisure note changes to "🌴 Free day — you added {activity}"; total and "Your extras" update; Review shows an "Optional activity" line |
| **Removes any add-on** (from its card, its sheet, or the "Your extras" list) | All of the above revert to the not-chosen state in one update |
| **Changes travellers or departure** | Keep all selections; re-price every add-on line and the total; refresh the dates on every day card; if a rule now fails, show the message in the booking panel and disable "Book now" |

**Visual language for states** (use the same everywhere):

| State | Style |
|---|---|
| Not chosen | Neutral card; add-on offered as a link ("See upgrade options ›") or an outline "Add" button |
| Chosen | Accent border (2px, `brand.colors.primary`) or tinted background, green pill naming the choice, "Change" or "Added ✓" control |
| Updating price | Total shows a short shimmer (≤ 300ms); never blank |
| Disabled (rule broken) | Greyed "Book now" with the rule message above it |

**Implementation rules**

1. One source of truth: `tripStore` holds `hotelUpgrades`, `transferAddons`, `originAddon`, `sightseeingAddons`. Cards, sheets, the booking panel, the bottom bar, the URL and Review all derive from it with selectors; never copy selections into local component state.
2. Derive display data with memoised selectors (`selectItinerary(trip)`, `selectTotal(trip)`, `selectExtras(trip)`) so a change re-renders only the affected cards.
3. Sheets keep their own **draft** choice while open and write to the store only on confirm; cards update the moment the store changes.
4. Accessibility: chosen controls use `aria-pressed` / `aria-checked`; announce changes politely through a live region ("Pan Pacific added to your trip").
5. Show a short toast for each change ("Added to your trip" / "Removed from your trip"), never more than one at a time.
6. Tests: selecting and removing each add-on type updates the itinerary card, the "Staying at" rows (for hotel upgrades), the extras list, the total and the Review lines.

**Review and checkout** (`/book/review`)
- Trip card (photo, title, dates, nights), travellers and rooms with Edit (back to the trip page)
- **Price, room by room** ("Room 1 · 2 adults — ₹96,000"), then one line per extra ("Hotel upgrade — Pan Pacific · Half board — ₹1,90,000"), then the trip total. **Never show the calculation** (no "2 × ₹48,000") — only line totals.
- **Traveller details**: lead traveller full name, email and phone (with country code); then the full name of every traveller, grouped by room, with the age already chosen for each child and infant. Validate all fields before Pay.
- 💳 Payment policy summary and a consent checkbox ("I agree to the payment and cancellation policy").
- **Pay ₹X** calls `quote` first. If anything changed (price, seats, date closed), show a dialog with the new total or the problem and "Confirm new price" / "Change trip". Then `checkout-create` and redirect to the hosted checkout. The button shows a spinner and is disabled while working.

**Confirmation** (`/book/confirmation/$ref`)
- While the booking is `pending_payment` or `paid`: "⏳ Confirming your booking…" and poll every 3 seconds (stop after 2 minutes with a friendly "This is taking longer than usual — we'll email you").
- `received`: **⏳ "Booking received — awaiting confirmation"**. Our documents note: "Our team will contact you for travel documents; your final booking code will appear here."
- `confirmed`: **✅ "Booking confirmed"** with the final booking code.
- `refund_needed`: **⚠️ "We couldn't complete your booking."** "Your payment of ₹X will be refunded. Reference TG-…" and a support contact.
- `cancelled_unpaid`: "Payment wasn't completed" with "Try again" back to Review.
- Always show: reference number, trip summary (photo, title, dates), travellers, extras, amount paid and payment status.

**My bookings** (`/bookings`)
- Bookings made in this browser (from `bookings-mine`): photo, title, dates, status pill (Pending payment, Paid, Received ⏳, Confirmed ✅, Failed / Refund needed ⚠️), amount, reference; each opens its confirmation page.
- Empty state: "No bookings in this browser yet" with a link to explore trips. Explain that bookings are saved on this device.

**Loading and empty states**: skeleton cards and gallery; "No packages for this destination yet" with destination chips; "Sold out" overlay on cards with no bookable departure.

### Indicative icons and emoji (required)

Use a small, consistent set of emoji as visual markers wherever a list or item has a type or a yes/no meaning. Each emoji sits in a fixed-size slot (20px, or a 32px rounded tinted tile on itinerary cards), is marked `aria-hidden="true"`, and is always followed by a text label, so screen readers and colour-blind users lose nothing. Never use an emoji as the only label of a button.

| Where | Emoji | Notes |
|---|---|---|
| **What's included** — every list item | ✅ | Green; one per line, left-aligned, text wraps under the text not under the icon |
| **Not included** — every list item | ❌ | Red; same layout as included |
| Highlights row (detail page) | ✈️ Flights · 🏨 Hotels · 🚌 Transfers · 🎟️ Activities · 🍳 Breakfast | Show only the ones the package has |
| Itinerary — flight card | ✈️ | Tile tint blue |
| Itinerary — transfer card | 🚌 shared (SIC) · 🚗 private | Tile tint green |
| Itinerary — hotel check-in / staying | 🏨 | Tile tint amber |
| Itinerary — activity | 🎭 shows · 🚤 water · 🛕 temples / heritage · 🎟️ tickets only · 📍 other | Pick from the activity name; default 📍 |
| Itinerary — optional activity | ✨ plus the activity emoji | "Optional" tag in purple |
| Itinerary — day at leisure | 🌴 | "Day at leisure — free time to explore {city}" |
| Flight details | 🕒 duration · 🧳 baggage · 🔁 layover | Inline before each value |
| Hotel details | ⭐ star category · 🍳 meal plan · 🛏️ room type | Inline |
| Seats left badge | 🔥 when ≤ 5 seats, otherwise none | "🔥 Only 3 seats left" |
| Payment policy | 💳 | Accordion header |
| Cancellation policy | ↩️ | Accordion header |
| Departure / travellers fields | 📅 departure · 👥 travellers | In the booking panel |
| Booking status | ✅ confirmed · ⏳ received / awaiting confirmation · ⚠️ failed or refund needed | Large, in a round coloured badge; also on status pills in My bookings |
| Price changed (409) | 💱 | With the new total |

Rules: one emoji per line item, same emoji for the same meaning everywhere, never decorative emoji in headings or body copy. Inclusions and exclusions come from supplier HTML: strip any existing bullets or icons from the HTML, then render each `<li>` with ✅ or ❌.

### Accessibility

WCAG AA contrast; visible focus rings (2px primary, offset 2px); all icon buttons labelled; dialogs and sheets trap focus, close on Escape and return focus to the trigger; carousels keyboard-operable; prices announced with currency; form errors linked with `aria-describedby`.

---

---

## Branding & Theme (single change point)

All names, logo, colours and destinations come from `src/config/brand.ts`:

```typescript
export const brand = {
  name: import.meta.env.VITE_APP_NAME ?? "Trusted Getaways",
  logoUrl: "/logo.svg",
  colors: {
    primary: import.meta.env.VITE_PRIMARY_COLOR ?? "#2453C4",
    secondary: import.meta.env.VITE_SECONDARY_COLOR ?? "#16357F",
    background: import.meta.env.VITE_BG_COLOR ?? "#FFFFFF",
    onPrimary: "#FFFFFF",          // text on blue chips and buttons — never black
    text: "#222222",
    muted: "#6A6A6A",
    border: "#DDDDDD",
  },
  hero: { headline: "Group trips with fixed dates,", sub: "planned end to end." },
  destinations: [
    { code: "desjky4", name: "Thailand" },
    { code: "desjk4c", name: "Bali / Indonesia" },
    { code: "desjkyd", name: "Kerala" },
  ],
} as const;
```

Never hardcode the name, logo, colours or destinations in pages. Tailwind `theme.extend.colors.brand.*` reads these values. The server functions (sync) use the same destination list. More destinations are added only here.

---

## UI Error Rules (all pages)

Never show "TravClan", "Volt", "API", "Edge Function", "Supabase", "Lovable" or raw error messages to customers. Use plain, friendly sentences.

| Context | User-facing message |
|---|---|
| Listing failure | "We couldn't load packages right now. Please try again." |
| Detail failure | "We couldn't load this trip. Please try again." |
| Day-wise failure | Show the rest of the page; plan area: "The day-by-day plan isn't available right now." |
| Travellers invalid | The specific rule message from the pricing module |
| 409 on Book | "Prices have changed" page with the new total |
| Other Book failure | "We couldn't complete your booking. Your payment was received — please try again or contact support." |
| Network offline | "You're offline. Check your connection and try again." |

---
| Price re-check found a change | Dialog: "The price for this trip is now ₹X (was ₹Y). Continue with the new price?" |
| Departure closed or sold out at re-check | "This date is no longer available. Please choose another date." |
| Checkout could not start | "We couldn't start the payment. Please try again." |
| Payment cancelled or failed | "Payment wasn't completed. You haven't been charged." |
| Booking failed after payment | "We couldn't complete your booking. Your payment will be refunded." |

---

## Folder Structure

```
src/
  config/brand.ts
  lib/
    fd-schemas.ts            # zod schemas for every supplier response (shared with server)
    pricing.ts               # rules, room price, upgrades, extras, totals
    itinerary.ts             # buildItinerary()
    format.ts                # ₹, dates, durations
    __tests__/ (pricing, itinerary, listing-mapping, traveller-limits, status-flow).test.ts
  api/                       # thin wrappers that call server functions
  store/trip.ts              # Zustand: departureCode, rooms, travellers, selections
  components/
    layout/ Header, Footer, Seo
    package/ PackageCard, PhotoGrid, Gallery
    detail/ BookingBox, MobileBookBar, DeparturePicker, TravellersPicker, DayPlan, DayItem,
            AddonSheet, Highlights, Inclusions, ThingsToKnow
    review/ PriceBreakdown, TravellerForm, PriceChangedDialog
    status/ StatusBadge
  pages/ Home, Packages, PackageDetail, Review, Confirmation, MyBookings
server functions/
  _shared/ fd.ts (request helper), schemas.ts, pricing.ts, guest.ts (cookie + hash), status.ts (booking status changes)
  fd-listings, fd-detail, fd-day-wise, quote, checkout-create, payments-webhook,
  booking-get, bookings-mine, api/public/fd-webhook, api/public/fd-sync
```

---

## Security Rules

1. `TG_API_KEY`, `CRON_SECRET`, `TG_WEBHOOK_SECRET` and payment keys live only in server secrets.
2. Only server functions call the supplier, create or update bookings, or read bookings.
3. The server re-prices and re-checks rules on `quote`, on `checkout-create` and again before calling Book; never trust the browser total.
4. A booking becomes `paid` only from the verified payment webhook. A test payment never reaches the production supplier.
5. One booking and one charge per `idempotencyKey`; the payment webhook books with the supplier at most once per booking.
6. The supplier webhook verifies the signature; until configured it returns 501 and changes nothing.
7. `/api/public/fd-sync` requires `x-cron-secret`.
8. Sanitize all supplier HTML with DOMPurify.
9. Never store card data. Store guest keys only as hashes.

---

## Tests (required — must pass)

- **Listing mapping**: next open departure, "from" price, seats badge (🔥 at ≤ 5), sold-out card, missing fields hidden.
- **Day-wise plan** (`buildItinerary`): flight stitching (primary outbound first on Day 1, inbound last on the final day, `inbound: null`, non-primary by `day_of_trip`, two-segment flight as one card), hotel placement after the matching transfer, "Staying at · night N of M" rows, leisure days with suggested optional activities, real dates per departure.
- **Traveller limits**: room rules and exceptions, minimum adults, seats, age bands.
- **Extras pricing and totals**: every case in the pricing table; selecting and removing each add-on updates totals and the booking body.
- **Server re-pricing**: `quote` detects a price change, a closed date and missing seats.
- **Booking and payment status changes**: pending_payment → paid → received → confirmed; paid → refund_needed on 409/400/500; unpaid → cancelled_unpaid; repeated payment webhook does not book twice; test payment + production supplier is refused.

---

## Deliverable Checklist

1. Home, packages and trip pages show only real supplier data; missing fields are hidden; every page has its own title and description.
2. Trip page first screen matches the spec on desktop (breadcrumb, summary line, photo grid with "Show all N photos", sticky booking box) and phone (large photo, bottom bar with total, date, travellers and Book now).
3. Departure dates appear only inside the departure picker; the first open date is preselected.
4. Travellers picker enforces room rules (with the extra-infant exception), minimum adults, seats and age bands live.
5. Day-wise plan follows `buildItinerary()`; add-ons sit on their days and items; room extras the supplier can't book show "Request at the hotel"; every related card, "Your extras", the total, Review and the booking request update together.
6. Review collects lead traveller and all traveller names; `quote` re-checks before payment and asks the customer to confirm any change.
7. Hosted checkout in test mode; the verified payment webhook marks paid and books with the supplier sandbox; failures after payment become "refund needed" with a clear message.
8. No double bookings or double charges on retries.
9. Confirmation shows ✅ / ⏳ / ⚠️ states with reference, trip, travellers, extras, amount and payment status; My bookings lists this browser's bookings.
10. Supplier webhook returns 501 until signature details are configured; daily sync is protected and runs.
11. Blue chips and buttons use white text; no sideways scrolling at 360, 768, 1024 and 1440 px; emoji rules followed; accessibility rules met.
12. No supplier, platform or raw error text visible to customers.
13. All tests pass.
14. Final step: ask the user for branding (below).

---

## Setup Steps

1. Enable **Lovable Cloud** (database + server functions) and run the schema above.
2. Add the secrets: `TG_API_KEY`, `TG_API_BASE_URL` (sandbox), `TG_ENV=sandbox`, `CRON_SECRET`, `GUEST_KEY_PEPPER`; leave `TG_WEBHOOK_SECRET` empty for now.
3. Enable **Lovable's built-in payments** in **test mode** (INR, hosted checkout) and point its webhook at `payments-webhook`.
4. Schedule `/api/public/fd-sync` daily with the `x-cron-secret` header and run it once now.
5. Test with sandbox data: Thailand → 4N Pattaya Bangkok EX-DEL → two rooms with a child and an infant → hotel upgrade + optional activity → Review with traveller details → test payment → confirmation shows "Booking received".

## Still needed from the supplier

- **Webhook signature details** — until then `/api/public/fd-webhook` returns 501 and bookings stay "Received".
- **Production credentials** (`TG_API_BASE_URL`, `TG_API_KEY`) for going live.

---

## Final Step — Prompt User for Branding (required)

After the site works end to end, stop and ask:

**🎨 Customize your site branding**

1. **Site name** — navbar, browser tab, hero (default: `Trusted Getaways`)
2. **Primary color** — buttons, links, selected states (default: `#2453C4`)
3. **Secondary color** — hovers and accents (default: `#16357F`)
4. **Background color** — page background (default: `#FFFFFF`)

Reply like:

```
Site name: Trusted Getaways
Primary: #2453C4
Secondary: #16357F
Background: #FFFFFF
```

Apply the values to the `VITE_*` env vars (and the `brand.ts` defaults), update the hero tagline if needed, and confirm: *"Branding updated — refresh the preview to see your colours."*

Troubleshooting

Refer to the troubleshooting page if you encounter any issues after building your application with Lovable.


Did this page help you?