GA4 Ecommerce Tracking
Pushes GA4 ecommerce and custom events into window.dataLayer for Google Tag Manager.
Covers the full user journey: browsing products, configuring a trip, and completing checkout.
No dependencies added. Uses window.dataLayer.push() directly (not gtag()) since GTM is the tag container.
Module structure
Section titled “Module structure”All analytics code lives in frontend/src/shared/analytics/:
| File | Purpose |
|---|---|
types.ts | GA4Item interface, Window.dataLayer augmentation |
dataLayer.ts | pushEcommerceEvent(), pushCustomEvent() with SSR guards |
mappers.ts | Map domain types (CountryTrip, ExperienceBottomModalTrip, CheckoutSession) to GA4Item |
events/ecommerce.ts | view_item_list, select_item, view_item, add_to_cart |
events/checkout.ts | begin_checkout through purchase (8 step functions) |
events/custom.ts | select_country, form_submit, select_insurance |
hooks/useTrackCheckoutStep.ts | React hook with sessionStorage dedup per checkout session |
index.ts | Barrel export |
In-checkout selection events (select_flight, select_booking, add_experience, select_transfer) are emitted via pushCustomEvent() directly from each step’s React component — there is no dedicated events/selections.ts file.
Design decisions
Section titled “Design decisions”- Ecommerce clearing: Every
pushEcommerceEvent()call pushes{ ecommerce: null }first to prevent stale data leaking between events (GA4 best practice). - SSR guard:
getDataLayer()returnsnullwhenwindowis undefined, so all pushes are no-ops during server rendering. - Edit mode skip: Checkout step events do not fire when
isEditModeis true (user revisiting a step from the summary page). - Session dedup:
useTrackCheckoutStepuses sessionStorage keys scoped toofferId_started_atso a new checkout for the same offer starts fresh.
Events reference
Section titled “Events reference”Pre-checkout events
Section titled “Pre-checkout events”| Event | Fires in | Trigger |
|---|---|---|
view_item | ProductPage.astro | Inline script on page load |
view_item_list | CountryFixedCta, PaisExperienceSection | Modal opens |
select_item | CountryTripsContent, ExperienceBottomModal | Trip card click |
select_country | DestinationModal | Destination click (mobile + desktop) |
form_submit | FooterNewsletterModule | Successful newsletter submission |
add_to_cart | TripConfigurator | handleConfigure() after trip configuration |
Checkout funnel events
Section titled “Checkout funnel events”All use useTrackCheckoutStep for once-per-session dedup. Each fires with currency, value, items, and (from step 3 onward) carry-forward extras.
| Step | Event | Fires in |
|---|---|---|
| 2 | begin_checkout | CheckoutPage |
| 3 | add_booking_info | HotelSelectionPage |
| 4 | add_experience_info | ActivitySelectionPage |
| 5 | add_transfer_info | TransferSelectionPage |
| 6 | add_contact_info | ClientContactPage |
| 7 | add_shipping_info | TravelerDataPage |
| 8 | add_payment_info | SummaryPaymentPage |
| 9 | purchase | SummaryPaymentPage (Stripe) or ConfirmationPage (Redsys) |
In-checkout selection events
Section titled “In-checkout selection events”Fired via pushCustomEvent() when the user picks an option within a step. Not deduplicated (fires on every selection change).
| Event | Fires in | Key params |
|---|---|---|
select_flight | CheckoutPage handleFlightSelect | flight (airline names joined with ” + “) |
select_booking | HotelSelectionPage handleToggleUpgrade | accomodation (hotel name) |
add_experience | ActivitySelectionPage handleSelectActivity | experience (activity name) |
select_transfer | TransferSelectionPage handleToggleAllUpgrades | transfer (transfer name) |
select_insurance | TransferSelectionPage “Añadir seguro” placeholder card | currency, value (hardcoded 45), insurance (hardcoded 'Seguro de Viaje') |
select_insurance is wired to a placeholder UI only. The “Añade servicios extra → Seguro de Viaje” card on the transfers step fires the event with hardcoded values so marketing can validate the funnel, but the underlying insurance feature (real Intermundial quote, session integration, contract step) is unimplemented. Local insuranceAdded state prevents double-fires per page mount. The backend has the API endpoints (/checkout/insurance/policies, /quote, /contract) and the frontend has an API client (fetchInsurancePolicies), but they are not yet connected to a real user-facing UI.
Source: frontend/src/features/checkout/components/TransferSelectionPage.tsx, frontend/src/shared/analytics/events/custom.ts
Carry-forward extras
Section titled “Carry-forward extras”From step 3 onwards, getCheckoutExtras(session) adds previous selections as top-level params:
| Param | Source | Format |
|---|---|---|
flight | getFlightLabel() | "Iberia + British Airways" |
accomodation | getAccommodationLabel() | "Hotel A + Hotel B" |
experience | getExperienceList() | Array of { experience_name, value } |
transfer | getTransferLabel() | "Transfer A + Transfer B" |
travelers | session.actual_pax_count | Number |
Purchase event: two payment flows
Section titled “Purchase event: two payment flows”Stripe flow: trackPurchase() fires in SummaryPaymentPage.handlePaymentSuccess() after stripe.confirmSetup() succeeds, before the success state is set.
Redsys flow: Since Redsys redirects to an external payment page, the purchase payload is stored in sessionStorage under key ga4_pending_purchase before redirect. ConfirmationPage reads it on mount and fires trackPurchase() only if the stored transactionId matches the current booking reference (prevents stale data from firing false purchases).
Data mappers
Section titled “Data mappers”| Mapper | Input type | Used by |
|---|---|---|
mapCountryTripToGA4Item | CountryTrip | CountryTripsContent, CountryFixedCta, trackCountryTripSelect |
mapExperienceTripToGA4Item | ExperienceBottomModalTrip | ExperienceBottomModal, PaisExperienceSection |
mapSessionToGA4Item | CheckoutSessionData + OfferSummary | All checkout step and purchase events |
mapHotelToGA4Item | HotelSelectionItem | Exported, not yet consumed |
mapActivityToGA4Item | ActivitySelectionItem | Exported, not yet consumed |
mapTransferToGA4Item | TransferSelectionItem | Exported, not yet consumed |
All mappers set affiliation: 'Volare', google_business_vertical: 'Travel', and item_category: 'Circuitos'.
view_item and add_to_cart build their GA4Item inline (in ProductPage.astro and TripConfigurator.tsx) rather than going through a mapper — they have direct access to the Product shape returned by the API and don’t need indirection.
Funnel-consistency invariants
Section titled “Funnel-consistency invariants”For a given product, the same item-level fields are identical across every event from view_item onward — view_item → add_to_cart → begin_checkout → add_*_info → purchase. The earlier listing events (view_item_list / select_item) emit a stable but different item_id and item_variant shape today; the geographic fields (item_category2, item_category3, item_list_name, location_id) do match the rest of the funnel — see Known gaps for the listing-side details.
The values pushed from view_item onward are:
| Field | Value | Source |
|---|---|---|
item_id | ProductByMarket SKU (e.g. ES-32-7-ES1) | product.sku (inline events); OfferSummary.pbmSku (checkout) |
item_variant | "{nights} noches" | Product page / configurator: trip_duration_days - 1 (the field stores DAYS despite the name). Checkout: Math.ceil((returnDate - departureDate) / 86_400_000) from offer dates — same formula CheckoutNavbar.calculateNights() uses for the on-screen night count, so GA matches what the user sees. |
item_category | "Circuitos" | Constant in mappers and inline events |
item_category2 | Region name (localized, e.g. "Centroamérica") | Resolved server-side via ProductByMarket::getPrimaryRegionName() |
item_category3 | Country name (localized, e.g. "Costa Rica") | Resolved server-side via ProductByMarket::getPrimaryCountryName() |
item_list_name | "Circuitos por {country}" | Composed from country name. Standardized everywhere — earlier code emitted "Circuitos {country}" (without “por”) in some places. |
location_id | ISO-2 country code (e.g. "CR") | Resolved server-side via ProductByMarket::getPrimaryCountryIsoCode(). Depends on cms_countries.iso_code being set. |
value is the only item-level field that may legitimately differ between events. The live economy flight search re-prices an offer between add_to_cart and begin_checkout, so the checkout value can change. This is expected behavior, not a funnel inconsistency.
Pre-checkout listing wiring
Section titled “Pre-checkout listing wiring”The geographic taxonomy is propagated through the listing API resources so view_item_list / select_item can carry the same location_id and item_category2 the rest of the funnel emits:
CmsCountryResourceexposesisoCode(top-level country page).CmsRegionResource::resolveTripandCmsCollectionDetailResource::buildTripsincludecountry_codeper trip card.- Frontend types
CountryTripandExperienceBottomModalTripcarry an optionalcountry_code(andregion_nameon the experience modal trip), threaded through tomapCountryTripToGA4ItemandmapExperienceTripToGA4Item.
Checkout funnel wiring
Section titled “Checkout funnel wiring”Six checkout endpoints (/checkout/{offerId} flights options, /business-flights, /hotels, /activities, /transfers, /contact, /travelers) eager-load productByMarket.cmsCountries.translations + productByMarket.cmsCountries.region.translations and expose four extra fields per offer summary: pbm_sku, country_name, region_name, country_code. The frontend OfferSummary type and the 6 *ApiResponse interfaces / transformers in checkoutApi.ts carry these through to mapSessionToGA4Item. See Checkout API for endpoint details.
Known gaps
Section titled “Known gaps”These are documented for future iteration:
- Listing events (
view_item_list/select_item) don’t fully match the rest of the funnel. The geographic fields (item_category2,item_category3,item_list_name,location_id) do agree, but two fields still diverge:item_idis the numeric ProductByMarket database id (e.g."29") instead of the SKU (e.g."ES-32-7-ES1") thatview_itemand downstream emit. Origin:country-page.data.tsandCmsRegionResource::resolveTrip/CmsCollectionDetailResource::buildTripsproduce(string) $product->idfor the trip card’sidfield, and the listing-side mappers reuse that asitem_id.item_variantshape varies by listing source: the country page (country-page.data.ts:formatDuration) emits canonical"{X} noches".CmsRegionResource::resolveTripemits"7 nights · from 2.500 €"(English, with a price suffix).CmsCollectionDetailResource::buildTripsemits"7 noches"using days as the count (the same days→nights off-by-one we fixed elsewhere, still present in this resource).- Fixing both requires threading a
skuand a normalizedvariant_labelthrough the listing trip cards (ExperienceTrip,ExperienceBottomModalTrip,CountryTrip) and updating the mappers — deferred so the visible card text on regions (“7 nights · from 2.500 €”) doesn’t have to change for a GA-only fix.
coupon,tax,customer_typefields are not available from current session data.location_iddepends oncms_countries.iso_code. The 2026-04-29 backfill migration (backfill_iso_code_for_remaining_cms_countries) populates known production countries, but the Filament admin still has no field for settingiso_codeon newly created countries — new countries will silently emitlocation_id: nulluntil the field is added.- In-checkout selection events (
select_booking,add_experience,select_transfer) usepushCustomEventrather than the typed ecommerce pushes — intentional, to match the agency analytics reference format. select_insuranceis wired to a placeholder card only. The real insurance feature is unimplemented.