Skip to content

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.

All analytics code lives in frontend/src/shared/analytics/:

FilePurpose
types.tsGA4Item interface, Window.dataLayer augmentation
dataLayer.tspushEcommerceEvent(), pushCustomEvent() with SSR guards
mappers.tsMap domain types (CountryTrip, ExperienceBottomModalTrip, CheckoutSession) to GA4Item
events/ecommerce.tsview_item_list, select_item, view_item, add_to_cart
events/checkout.tsbegin_checkout through purchase (8 step functions)
events/custom.tsselect_country, form_submit, select_insurance
hooks/useTrackCheckoutStep.tsReact hook with sessionStorage dedup per checkout session
index.tsBarrel 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.

  • Ecommerce clearing: Every pushEcommerceEvent() call pushes { ecommerce: null } first to prevent stale data leaking between events (GA4 best practice).
  • SSR guard: getDataLayer() returns null when window is undefined, so all pushes are no-ops during server rendering.
  • Edit mode skip: Checkout step events do not fire when isEditMode is true (user revisiting a step from the summary page).
  • Session dedup: useTrackCheckoutStep uses sessionStorage keys scoped to offerId_started_at so a new checkout for the same offer starts fresh.
EventFires inTrigger
view_itemProductPage.astroInline script on page load
view_item_listCountryFixedCta, PaisExperienceSectionModal opens
select_itemCountryTripsContent, ExperienceBottomModalTrip card click
select_countryDestinationModalDestination click (mobile + desktop)
form_submitFooterNewsletterModuleSuccessful newsletter submission
add_to_cartTripConfiguratorhandleConfigure() after trip configuration

All use useTrackCheckoutStep for once-per-session dedup. Each fires with currency, value, items, and (from step 3 onward) carry-forward extras.

StepEventFires in
2begin_checkoutCheckoutPage
3add_booking_infoHotelSelectionPage
4add_experience_infoActivitySelectionPage
5add_transfer_infoTransferSelectionPage
6add_contact_infoClientContactPage
7add_shipping_infoTravelerDataPage
8add_payment_infoSummaryPaymentPage
9purchaseSummaryPaymentPage (Stripe) or ConfirmationPage (Redsys)

Fired via pushCustomEvent() when the user picks an option within a step. Not deduplicated (fires on every selection change).

EventFires inKey params
select_flightCheckoutPage handleFlightSelectflight (airline names joined with ” + “)
select_bookingHotelSelectionPage handleToggleUpgradeaccomodation (hotel name)
add_experienceActivitySelectionPage handleSelectActivityexperience (activity name)
select_transferTransferSelectionPage handleToggleAllUpgradestransfer (transfer name)
select_insuranceTransferSelectionPage “Añadir seguro” placeholder cardcurrency, 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

From step 3 onwards, getCheckoutExtras(session) adds previous selections as top-level params:

ParamSourceFormat
flightgetFlightLabel()"Iberia + British Airways"
accomodationgetAccommodationLabel()"Hotel A + Hotel B"
experiencegetExperienceList()Array of { experience_name, value }
transfergetTransferLabel()"Transfer A + Transfer B"
travelerssession.actual_pax_countNumber

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).

MapperInput typeUsed by
mapCountryTripToGA4ItemCountryTripCountryTripsContent, CountryFixedCta, trackCountryTripSelect
mapExperienceTripToGA4ItemExperienceBottomModalTripExperienceBottomModal, PaisExperienceSection
mapSessionToGA4ItemCheckoutSessionData + OfferSummaryAll checkout step and purchase events
mapHotelToGA4ItemHotelSelectionItemExported, not yet consumed
mapActivityToGA4ItemActivitySelectionItemExported, not yet consumed
mapTransferToGA4ItemTransferSelectionItemExported, 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.

For a given product, the same item-level fields are identical across every event from view_item onward — view_itemadd_to_cartbegin_checkoutadd_*_infopurchase. 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:

FieldValueSource
item_idProductByMarket 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_category2Region name (localized, e.g. "Centroamérica")Resolved server-side via ProductByMarket::getPrimaryRegionName()
item_category3Country 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_idISO-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.

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:

  • CmsCountryResource exposes isoCode (top-level country page).
  • CmsRegionResource::resolveTrip and CmsCollectionDetailResource::buildTrips include country_code per trip card.
  • Frontend types CountryTrip and ExperienceBottomModalTrip carry an optional country_code (and region_name on the experience modal trip), threaded through to mapCountryTripToGA4Item and mapExperienceTripToGA4Item.

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.

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_id is the numeric ProductByMarket database id (e.g. "29") instead of the SKU (e.g. "ES-32-7-ES1") that view_item and downstream emit. Origin: country-page.data.ts and CmsRegionResource::resolveTrip / CmsCollectionDetailResource::buildTrips produce (string) $product->id for the trip card’s id field, and the listing-side mappers reuse that as item_id.
    • item_variant shape varies by listing source: the country page (country-page.data.ts:formatDuration) emits canonical "{X} noches". CmsRegionResource::resolveTrip emits "7 nights · from 2.500 €" (English, with a price suffix). CmsCollectionDetailResource::buildTrips emits "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 sku and a normalized variant_label through 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_type fields are not available from current session data.
  • location_id depends on cms_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 setting iso_code on newly created countries — new countries will silently emit location_id: null until the field is added.
  • In-checkout selection events (select_booking, add_experience, select_transfer) use pushCustomEvent rather than the typed ecommerce pushes — intentional, to match the agency analytics reference format.
  • select_insurance is wired to a placeholder card only. The real insurance feature is unimplemented.