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, select_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_extras_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). All select_* events share a unified payload (tagging guide v2.0): name + value + currency.

EventFires inKey params
select_flightCheckoutPage handleFlightSelectname (cabin tier: "business" | "tourist"), value (upgrade diff)
select_bookingHotelSelectionPage handleToggleUpgradename (hotel name), value (upgrade diff)
select_experienceActivitySelectionPage handleSelectActivityname (activity name), value (whole-party price of that experience: per-person × pax)
select_transferTransferSelectionPage handleToggleAllUpgradesname (transfer name), value
select_insuranceInsuranceSelector handleSelect (transfers/extras step)name (selected policy product_name), value (live retail_price, whole-party total)

select_insurance fires when the user adds a real policy via the InsuranceSelector (the previous hardcoded placeholder card was retired). The name/value come from the live Intermundial quote that backs the selection. See Travel Insurance (Intermundial) for the full integration.

Source: frontend/src/features/checkout/components/InsuranceSelector.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"
flight_typegetFlightType()"tourist" | "business" (cabin tier)
accomodationgetAccommodationLabel()"Hotel A + Hotel B" — selected upgrades, falling back to the base hotels (session.base_accommodation, resolved server-side) when none are chosen
accomodation_typegetAccommodationType()"hand_picked" | "exclusive" | "deluxe" (highest selected tier; base default hand_picked)
experiencegetExperienceList()Array of { experience_name, value }[] when nothing is selected (the param is always present); value is the whole-party amount (per-person price × pax)
experience_amountsum of activity_selections[].price × paxNumber (€) — whole-party total, matching the backend extras_price
experience_quantityactivity_selections.lengthNumber
transfergetTransferLabel()"Transfer A + Transfer B"
insuranceinsurance_selection.product_nameSelected policy name (included or paid); absent when no policy is selected
extras_amounttransfers (incl. luxury) + paid insurance retail_priceNumber (€) — the free included base policy adds nothing, mirroring the backend extras_price
extras_quantitytransfer count (+1 luxury, +1 paid insurance)Number
travelerssession.actual_pax_countNumber

accomodation_type is resolved server-side: CheckoutSessionService stores the upgrade tier (exclusive/deluxe) on each hotel selection, and getAccommodationType() reduces them to the highest tier (hand_picked when there are no upgrades). The select_booking event maps the clicked HotelOption.tier directly via mapHotelTierToAccommodationType().

payment_type ("credit_card") is added on add_payment_info only.

customer_type ("new" | "returning") is resolved server-side in CheckoutSessionService when the contact step saves the client: it matches the email (case-insensitive) against existing clients and checks whether they already have a booking that got past the checkout flow (abandoned / pending-payment / cancelled bookings don’t count). It is attached to the purchase event only.

select_help fires from the checkout navbar phone CTA (CheckoutNavbar passes an onClick to the shared Navbar phone button) with help_context set to the current funnel step.

Still pending — purchase.tax: travel packages use the Spanish special VAT regime (tax on the margin, not the full price) and no tax breakdown is stored, so the value to report is a finance/product decision.

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.
  • tax is not available from current session data (customer_type is — see above). coupon is emitted as a constant "" at the ecommerce level on begin_checkout and purchase (no coupon feature exists; the wiring point is in place for when one ships).
  • 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, select_experience, select_transfer) use pushCustomEvent rather than the typed ecommerce pushes — intentional, to match the agency analytics reference format.
  • select_insurance fires from the real InsuranceSelector, and the paid policy’s retail_price is folded into the carry-forward extras_amount/extras_quantity (the free included base policy is excluded, matching the backend extras_price).