Skip to content

Checkout Flow (Frontend)

The frontend checkout lives in frontend/src/features/checkout/, rendered by the Astro pages under frontend/src/pages/[market]/checkout/[offerId]/. Each step is a React island; navigation between steps uses Astro View Transitions (client-side) so shared state survives the hop.

  • Changing any checkout step UI, selection persistence, or the payment form
  • Understanding why the frontend never computes a price (it doesn’t — see Price Display)

One Astro page per step; the numbered titles come from the pages’ step config:

Route (/[market]/checkout/[offerId]/…) Step Component
/loading Pre-step: “Preparando tu viaje” — live economy re-pricing while the session starts CheckoutLoadingScreen (feature checkout-loading)
(index) 1. Vuelos de ida y vuelta — economy selection + business upgrade search CheckoutPage
/hotels 2. Alojamiento — optional hotel upgrades HotelSelectionPage
/activities 3. Experiencias — optional activities ActivitySelectionPage
/transfers 4. Otros — transfers, luxury transfer, travel insurance TransferSelectionPage + InsuranceSelector
/contact 5. Contacto — booking contact ClientContactPage
/travelers 6. Datos de los viajeros — per-passenger data TravelerDataPage
/summary 7. Resumen final y pago SummaryPaymentPage
/quotation Quotation branch end-screen (non-standard pax) QuotationConfirmation

Quotation branch: when the contact step response returns requires_quotation (non-standard pax/room configuration the backend can’t self-serve), ClientContactPage routes to /quotation instead of travelers; the customer returns later via a continuation link. See Checkout API — Quotation Flow.

Edit mode: from the summary, each section links back to its step with ?edit=summary (utils/editMode.ts); the step then shows CANCELAR/GUARDAR and returns to the summary on save.

Step tracking: every step’s Astro frontmatter calls trackCheckoutStepSSR() (features/checkout/server/), which PATCHes the funnel step to the backend using the booking id stored in Astro.session — reliable, no client JS involved. GA4 events are fired client-side (see GA4 Ecommerce Tracking).

  • Server is the source of truth. Every selection change PUTs to the checkout API (api/checkoutApi.ts) and each step re-hydrates from GET /checkout on mount. There is no client-side cart.
  • store/checkoutTotalStore.ts — the one zustand store: it caches the last backend-committed total_price + price_per_person (always set together so they never drift) and the pax count (display copy only). Because steps navigate via goToStep() (utils/checkoutNavigate.ts, Astro’s navigate()), the JS module graph survives and the header shows the committed price instantly instead of flashing the offer’s advertised 2-pax price. Genuine exits (home, payment gateway, confirmation) stay full page loads.
  • Per-step selection state is local React useReducer/useState, seeded from the session response.
  • Business-flight search results are cached in localStorage for 25 minutes (CheckoutPage).

The frontend never computes prices. Pricing is a single backend responsibility (Checkout API): every price on screen is printed from an API response — the header total and “/persona” figure come from total_price / price_per_person committed together into the store; per-person is never divided on the client. The former client-side delta helpers were deliberately removed (utils/priceCalculations.ts now only contains a flight type guard). Checkout amounts are whole-euro per person (per-person × pax === total), rounded by the backend (Offer::roundToWholeUnit). The only client multiplication is a GA4 analytics value — never a displayed price.

Activities are priced per person, but customers can book them for a subset of the party. ActivitySelectionPage keeps a Map<activityOptionId, participants>; selecting an activity defaults participants to the full pax count, and a stepper in ActivitySelector/DaySection adjusts it, clamped to [MIN_ACTIVITY_PARTICIPANTS, actual_pax_count]. Each change persists {activity_id, day_number, participants} to the API and the backend reprices — nothing is computed on the client. Selections restored from sessions saved before this feature fall back to the full pax count.

SummaryPaymentPage drives payment through api/paymentApi.ts:

  1. createSetupIntent() creates the booking on the backend and returns either a Stripe SetupIntent or a Redsys redirect form, depending on the gateway the backend chose.
  2. Stripe: Summary/StripePaymentForm.tsx lazily loads Stripe.js (@stripe/react-stripe-js Elements + PaymentElement, module-level singleton) inside the shared PaymentModule card UI; on confirmSetup success, confirmPayment() finalizes on the backend, handling a possible 3DS action_url redirect.
  3. Redsys: Summary/RedsysRedirectForm.tsx renders the standard hidden auto-submitting form (signature version, merchant parameters, signature) to the gateway URL; pending-payment state is stashed in the Astro session so the middleware can recover the confirmation if the user closes the tab, and the GA4 purchase payload is parked in sessionStorage to fire after the redirect.
  4. Success exits to /[market]/confirmation/[reference] with a full page load.

The Stripe publishable key arrives as an SSR prop (STRIPE_PUBLISHABLE_KEY env, see frontend/astro.config.mjs); a missing key renders an error alert instead of the form.

  • All prices displayed are backend-stored values; the client renders, never calculates
  • Selections persist server-side on every change, so back-navigation and edit mode never lose choices
  • Inter-step navigation must use goToStep(); exits must be full navigations
  • Activity participants are clamped to the party size and repriced by the backend
  • Checkout API — the backend contract for every step
  • GA4 Ecommerce Tracking — funnel events fired from these components
  • Payment Service — gateway selection and finalization
  • Source: frontend/src/features/checkout/ (components, api/, store/, utils/, server/)
  • Source: frontend/src/pages/[market]/checkout/[offerId]/