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/:

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

Authenticated Arkana (Filament) staff are excluded from GA4 and Microsoft Clarity so internal browsing does not pollute analytics (#2175). When a signed-in staff member hits the /staff route (InternalTrafficOptOutController), the backend issues a first-party opt-out cookie that the GTM container reads client-side to suppress the tags.

The cookie is named volare_internal (renamed from bv_internal in 43d0a3e0) and is configured in backend/config/analytics.php (internal_optout):

  • Written server-side via Set-Cookie (EnsureInternalTrafficCookie middleware) so Safari ITP does not cap it at 7 days
  • 180-day lifetime (cookie_lifetime_days), refreshed on each staff visit
  • Exempt from Laravel’s cookie encryption (see bootstrap/app.php) so JavaScript can read the plaintext value
  • Domain .byvolare.com (via INTERNAL_OPTOUT_COOKIE_DOMAIN) so a cookie set on arkana.byvolare.com is also sent to the public site

Source: backend/config/analytics.php, backend/app/Http/Middleware/EnsureInternalTrafficCookie.php, backend/app/Http/Controllers/InternalTrafficOptOutController.php

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

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_extras_info TransferSelectionPage
6 add_contact_info ClientContactPage
7 add_shipping_info TravelerDataPage
8 add_payment_info SummaryPaymentPage
9 purchase SummaryPaymentPage (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.

Event Fires in Key params
select_flight CheckoutPage handleFlightSelect name (cabin tier: "business" | "tourist"), value (upgrade diff)
select_booking HotelSelectionPage handleToggleUpgrade name (hotel name), value (upgrade diff)
select_experience ActivitySelectionPage handleSelectActivity name (activity name), value (per-person price × full actual_pax_count — see Known gaps)
select_transfer TransferSelectionPage handleToggleAllUpgrades name (transfer name), value
select_insurance InsuranceSelector 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:

Param Source Format
flight getFlightLabel() "Iberia + British Airways"
flight_type getFlightType() "tourist" | "business" (cabin tier)
accomodation getAccommodationLabel() "Hotel A + Hotel B" — selected upgrades, falling back to the base hotels (session.base_accommodation, resolved server-side) when none are chosen
accomodation_type getAccommodationType() "hand_picked" | "exclusive" | "deluxe" (highest selected tier; base default hand_picked)
experience getExperienceList() Array of { experience_name, value }[] when nothing is selected (the param is always present); value is per-person price × full actual_pax_count (see Known gaps)
experience_amount sum of activity_selections[].price × actual_pax_count Number (€) — per-person price × full pax (see Known gaps)
experience_quantity activity_selections.length Number
transfer getTransferLabel() "Transfer A + Transfer B"
insurance insurance_selection.product_name Selected policy name (included or paid); absent when no policy is selected
extras_amount transfers (incl. luxury) + paid insurance retail_price Number (€) — the free included base policy adds nothing, mirroring the backend extras_price
extras_quantity transfer count (+1 luxury, +1 paid insurance) Number
travelers session.actual_pax_count Number

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

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.

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:

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.

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:

  • Activity value / experience_amount no longer always match the backend extras_price. After #2209/#2210 the backend prices each activity by its own per-activity participant count (BookingFinalizationService: quantity = participants ?? number_of_travelers), but the GA4 code still multiplies the per-person activity price by the full actual_pax_count (mappers.ts:237 in getExperienceList(), mappers.ts:317 for experience_amount, ActivitySelectionPage.tsx:287). Parity therefore holds only when every activity’s participant count equals the whole party; when a customer books an activity for fewer travellers, GA4 over-reports its amount. Aligning GA4 to per-activity participants is deferred.
  • 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).