From 4a19a927f66d8234965d54f2028c2d7cb1bc6ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CJuan?= Date: Wed, 6 May 2026 15:45:57 -0300 Subject: [PATCH] [ADD]website_sale_google_analytics_4: Add Google Analytics 4 support for e-commerce tracking --- website_sale_google_analytics_4/README.rst | 155 ++++++++++ website_sale_google_analytics_4/__init__.py | 2 + .../__manifest__.py | 22 ++ .../controllers/__init__.py | 1 + .../controllers/main.py | 42 +++ .../data/ir_cron.xml | 13 + .../models/__init__.py | 5 + .../models/account_move.py | 126 ++++++++ .../models/res_config_settings.py | 12 + .../models/sale_order.py | 185 ++++++++++++ .../models/sale_order_line.py | 25 ++ .../models/website.py | 49 ++++ .../static/src/interactions/ga4_ecommerce.js | 274 ++++++++++++++++++ .../src/interactions/ga4_user_tracking.js | 45 +++ .../views/res_config_settings_view.xml | 29 ++ .../views/website_sale_templates.xml | 61 ++++ 16 files changed, 1046 insertions(+) create mode 100644 website_sale_google_analytics_4/README.rst create mode 100644 website_sale_google_analytics_4/__init__.py create mode 100644 website_sale_google_analytics_4/__manifest__.py create mode 100644 website_sale_google_analytics_4/controllers/__init__.py create mode 100644 website_sale_google_analytics_4/controllers/main.py create mode 100644 website_sale_google_analytics_4/data/ir_cron.xml create mode 100644 website_sale_google_analytics_4/models/__init__.py create mode 100644 website_sale_google_analytics_4/models/account_move.py create mode 100644 website_sale_google_analytics_4/models/res_config_settings.py create mode 100644 website_sale_google_analytics_4/models/sale_order.py create mode 100644 website_sale_google_analytics_4/models/sale_order_line.py create mode 100644 website_sale_google_analytics_4/models/website.py create mode 100644 website_sale_google_analytics_4/static/src/interactions/ga4_ecommerce.js create mode 100644 website_sale_google_analytics_4/static/src/interactions/ga4_user_tracking.js create mode 100644 website_sale_google_analytics_4/views/res_config_settings_view.xml create mode 100644 website_sale_google_analytics_4/views/website_sale_templates.xml diff --git a/website_sale_google_analytics_4/README.rst b/website_sale_google_analytics_4/README.rst new file mode 100644 index 00000000..f51a49f9 --- /dev/null +++ b/website_sale_google_analytics_4/README.rst @@ -0,0 +1,155 @@ +.. |company| replace:: ADHOC SA + +.. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png + :alt: ADHOC SA + :target: https://www.adhoc.com.ar + +.. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png + +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +====================== +GA4 Ecommerce Tracking +====================== + +Self-contained Google Analytics 4 ecommerce tracking module for Odoo 19 websites. +Extends the native Odoo tracking with additional funnel events and adds +server-side event delivery via the GA4 Measurement Protocol — **no Google Tag +Manager required**. + +Features +======== + +Frontend events (via ``gtag.js``) +---------------------------------- + +The following events are fired client-side, complementing the events already +covered by Odoo's native ``website_sale`` tracking (``view_item``, +``add_to_cart``, ``purchase``): + +* ``view_cart`` – fired when the shopping cart page is loaded. +* ``begin_checkout`` – fired when the user clicks "Proceed to Checkout". +* ``add_shipping_info`` – fired when the user selects a delivery method on + the checkout page. +* ``add_payment_info`` – fired when the user submits the payment form. +* ``remove_from_cart`` – fired when a cart line is deleted or its quantity is + decremented. +* ``sign_up`` – fired when the registration form is submitted. +* ``login`` – fired when the login form is submitted. + +Backend events (via GA4 Measurement Protocol) +---------------------------------------------- + +Server-side events are sent directly from Odoo to Google Analytics as a +reliability safeguard (ad-blockers, async/offline payments, etc.): + +* ``purchase`` – sent after ``sale.order._action_confirm()``. +* ``refund`` – sent after a credit note (``out_refund`` invoice) is posted. + +A scheduled action retries failed Measurement Protocol calls every 15 minutes +(up to 5 attempts per event). + +Consent Mode v2 compatibility +------------------------------- + +This module relies on Odoo's native gtag.js injection (``website.google_analytics_key``), +which already configures Consent Mode v2 with denied defaults and grants consent +when the user accepts optional cookies. No additional consent configuration is +required. + +Installation +============ + +#. Install this module. It depends on ``website_sale`` and ``payment``. +#. Ensure the native Google Analytics Measurement ID has been configured in + **Website → Configuration → Settings → Google Analytics** (field + ``google_analytics_key``). This module shares that field. + +Configuration +============= + +#. Go to **Website → Configuration → Settings → Google Analytics**. +#. Set the **Measurement ID** (``G-XXXXXXXXXX``) in the existing + **Google Analytics Key** field — this is the same field used by Odoo + natively to inject the ``gtag.js`` snippet. +#. Set the **GA4 API Secret** in the new **GA4 Measurement Protocol API Secret** + field. This secret is required for server-side event delivery. + + To generate an API secret: in the Google Analytics admin panel go to + **Admin → Data collection and modification → Data Streams → your stream → + Measurement Protocol API secrets → Create**. + + .. warning:: + The API secret must remain server-side only. Never expose it in the + browser or client-side code. + +#. *(Optional)* The scheduled action **GA4: Retry Failed MP Events** is + created automatically and runs every 15 minutes. You can adjust the + frequency in **Technical → Scheduled Actions**. + +Usage +===== + +Once configured, GA4 events are fired automatically with no further action +required from the store operator: + +* **Cart events** are fired on the ``/shop/cart`` page. +* **Checkout funnel events** are fired as the customer progresses through + address → delivery → payment steps. +* **Purchase events** are fired both client-side (via ``gtag.js``) and + server-side (via Measurement Protocol) on order confirmation. GA4 + deduplicates these using the same ``transaction_id`` (the Odoo sale order + numeric ID). +* **Refund events** are fired server-side when a credit note linked to a + web order is confirmed. +* **Authentication events** are fired on the login/registration page. + +GA session data (``_ga`` and ``_ga_`` cookies) is captured from +the browser at the start of the checkout flow and stored on the sale order, +enabling proper session attribution for server-side events. + +Known trade-offs +================ + +* **``value`` includes tax** – the event-level ``value`` parameter in all + events uses ``amount_total`` (inclusive of tax and shipping) to stay + consistent with Odoo's native tracking. The GA4 Measurement Protocol + documentation recommends excluding tax and shipping from ``value``, so + revenue figures may appear inflated if tax rates are significant. The + event-level ``tax`` and ``shipping`` fields are still sent separately so + the data can be reconciled. +* **GA client ID availability** – server-side events (``purchase``, ``refund``) + require the customer to have been on the checkout page with JavaScript + enabled so that the ``_ga`` cookie can be captured. Orders placed via the + backend or via API will not have a ``ga_client_id`` and the MP event will be + silently skipped. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* |company| |icon| + +Contributors +------------ + +Maintainer +---------- + +|company_logo| + +This module is maintained by the |company|. + +To contribute to this module, please visit https://www.adhoc.com.ar. diff --git a/website_sale_google_analytics_4/__init__.py b/website_sale_google_analytics_4/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/website_sale_google_analytics_4/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/website_sale_google_analytics_4/__manifest__.py b/website_sale_google_analytics_4/__manifest__.py new file mode 100644 index 00000000..569665e8 --- /dev/null +++ b/website_sale_google_analytics_4/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "Google Analytics 4 Ecommerce Tracking", + "summary": "Server-side and client-side GA4 ecommerce event tracking", + "version": "19.0.1.0.0", + "author": "ADHOC SA", + "category": "Website", + "license": "AGPL-3", + "depends": ["website_sale", "payment"], + "data": [ + "data/ir_cron.xml", + "views/res_config_settings_view.xml", + "views/website_sale_templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_sale_google_analytics_4/static/src/interactions/ga4_ecommerce.js", + "website_sale_google_analytics_4/static/src/interactions/ga4_user_tracking.js", + ], + }, + "installable": True, + "application": False, +} diff --git a/website_sale_google_analytics_4/controllers/__init__.py b/website_sale_google_analytics_4/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/website_sale_google_analytics_4/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/website_sale_google_analytics_4/controllers/main.py b/website_sale_google_analytics_4/controllers/main.py new file mode 100644 index 00000000..a0b0758d --- /dev/null +++ b/website_sale_google_analytics_4/controllers/main.py @@ -0,0 +1,42 @@ +import logging + +from odoo import http +from odoo.addons.website_sale.controllers.main import WebsiteSale + +_logger = logging.getLogger(__name__) + + +class GA4WebsiteSale(WebsiteSale): + @http.route() + def shop_checkout(self, try_skip_step=None, **query_params): + """Override to capture GA4 browser cookies into the sale order.""" + order = http.request.cart + if order and not order.ga_client_id: + try: + cookies = http.request.httprequest.cookies + website = http.request.website + + # --- Parse _ga cookie → client_id --- + ga_cookie = cookies.get("_ga", "") + client_id = order._parse_ga_client_id(ga_cookie) + + # --- Parse _ga_ cookie → session_id --- + measurement_id = website.google_analytics_key or "" + session_id = "" + if measurement_id: + # G-ABCDEF1234 → cookie name _ga_ABCDEF1234 + container_suffix = measurement_id.replace("G-", "") + session_cookie = cookies.get(f"_ga_{container_suffix}", "") + session_id = order._parse_ga_session_id(session_cookie) + + if client_id: + order.sudo().write( + { + "ga_client_id": client_id, + "ga_session_id": session_id or False, + } + ) + except Exception as exc: + _logger.warning("GA4: failed to capture cookies: %s", exc) + + return super().shop_checkout(try_skip_step=try_skip_step, **query_params) diff --git a/website_sale_google_analytics_4/data/ir_cron.xml b/website_sale_google_analytics_4/data/ir_cron.xml new file mode 100644 index 00000000..e4de7783 --- /dev/null +++ b/website_sale_google_analytics_4/data/ir_cron.xml @@ -0,0 +1,13 @@ + + + + GA4: Retry Failed Measurement Protocol Events + True + + 15 + minutes + + code + env['website'].sudo()._retry_failed_ga4_mp_events() + + diff --git a/website_sale_google_analytics_4/models/__init__.py b/website_sale_google_analytics_4/models/__init__.py new file mode 100644 index 00000000..bd128b0b --- /dev/null +++ b/website_sale_google_analytics_4/models/__init__.py @@ -0,0 +1,5 @@ +from . import website +from . import res_config_settings +from . import sale_order +from . import sale_order_line +from . import account_move diff --git a/website_sale_google_analytics_4/models/account_move.py b/website_sale_google_analytics_4/models/account_move.py new file mode 100644 index 00000000..30953f9e --- /dev/null +++ b/website_sale_google_analytics_4/models/account_move.py @@ -0,0 +1,126 @@ +import logging + +import requests +from odoo import fields, models + +_logger = logging.getLogger(__name__) + +GA4_MP_ENDPOINT = "https://www.google-analytics.com/mp/collect" +GA4_MP_TIMEOUT = 3 + + +class AccountMove(models.Model): + _inherit = "account.move" + + ga4_mp_pending_payload = fields.Json( + string="GA4 MP Pending Payload", + help="Stores a failed refund Measurement Protocol payload for retry.", + copy=False, + ) + + # ------------------------------------------------------------------ + # Post hook – server-side refund event + # ------------------------------------------------------------------ + + def _post(self, soft=True): + res = super()._post(soft=soft) + for move in res.filtered(lambda m: m.move_type == "out_refund"): + try: + move._send_ga4_mp_refund() + except Exception as exc: + _logger.warning( + "GA4 MP refund event skipped for move %s: %s", + move.name, + exc, + ) + return res + + def _send_ga4_mp_refund(self, _is_retry=False): + """Send a ``refund`` Measurement Protocol event for this credit note. + + Returns True on success, False on failure. + """ + self.ensure_one() + # Locate the originating sale order for GA session data. + # Prefer the direct relationship via invoice lines (most reliable), then + # fall back to invoice_origin string match filtered by company to avoid + # cross-company or multi-origin false matches. + sale_order = self.invoice_line_ids.sale_line_ids.order_id.filtered(lambda o: o.company_id == self.company_id)[ + :1 + ] + if not sale_order: + sale_order = ( + self.env["sale.order"] + .sudo() + .search( + [("name", "=", self.invoice_origin), ("company_id", "=", self.company_id.id)], + limit=1, + ) + ) + if not sale_order or not sale_order.ga_client_id: + return False + + website = (sale_order.website_id or self.env["website"].get_current_website()).sudo() + measurement_id = website.google_analytics_key + api_secret = website.ga4_api_secret + if not measurement_id or not api_secret: + return False + + items = [] + for line in self.invoice_line_ids.filtered(lambda l: l.product_id and not l.display_type): + items.append( + { + "item_id": line.product_id.barcode or str(line.product_id.id), + "item_name": line.product_id.name or "-", + "item_category": line.product_id.categ_id.name or "-", + "price": abs(line.price_unit), + "quantity": abs(line.quantity), + } + ) + + params = { + "transaction_id": str(sale_order.id), + "value": abs(self.amount_total), + "currency": self.currency_id.name, + "items": items, + } + if sale_order.ga_session_id: + params["session_id"] = sale_order.ga_session_id + params["engagement_time_msec"] = 100 + + body = { + "client_id": sale_order.ga_client_id, + "events": [{"name": "refund", "params": params}], + } + + try: + response = requests.post( + GA4_MP_ENDPOINT, + params={"measurement_id": measurement_id, "api_secret": api_secret}, + json=body, + timeout=GA4_MP_TIMEOUT, + ) + response.raise_for_status() + _logger.info( + "GA4 MP 'refund' event sent for move %s (origin order %s)", + self.name, + sale_order.name, + ) + return True + except Exception as exc: + _logger.warning( + "GA4 MP refund event failed for move %s (origin order %s): %s", + self.name, + sale_order.name, + exc, + ) + if not _is_retry: + attempts = (self.ga4_mp_pending_payload or {}).get("_attempts", 0) + self.ga4_mp_pending_payload = { + "_attempts": attempts + 1, + } + else: + payload = dict(self.ga4_mp_pending_payload or {}) + payload["_attempts"] = payload.get("_attempts", 0) + 1 + self.ga4_mp_pending_payload = payload + return False diff --git a/website_sale_google_analytics_4/models/res_config_settings.py b/website_sale_google_analytics_4/models/res_config_settings.py new file mode 100644 index 00000000..d680181b --- /dev/null +++ b/website_sale_google_analytics_4/models/res_config_settings.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + ga4_api_secret = fields.Char( + string="GA4 API Secret", + related="website_id.ga4_api_secret", + readonly=False, + groups="base.group_system", + ) diff --git a/website_sale_google_analytics_4/models/sale_order.py b/website_sale_google_analytics_4/models/sale_order.py new file mode 100644 index 00000000..02baf0fb --- /dev/null +++ b/website_sale_google_analytics_4/models/sale_order.py @@ -0,0 +1,185 @@ +import logging + +import requests +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +GA4_MP_ENDPOINT = "https://www.google-analytics.com/mp/collect" +GA4_MP_TIMEOUT = 3 # seconds – must not block payment flow + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + ga_client_id = fields.Char( + string="GA Client ID", + help="Parsed from the _ga browser cookie at checkout start.", + copy=False, + ) + ga_session_id = fields.Char( + string="GA Session ID", + help="Parsed from the _ga_ browser cookie at checkout start.", + copy=False, + ) + ga4_mp_pending_payload = fields.Json( + string="GA4 MP Pending Payload", + help="Stores a failed Measurement Protocol payload for retry by the scheduled action.", + copy=False, + ) + + # ------------------------------------------------------------------ + # Public helpers used by templates + # ------------------------------------------------------------------ + + def prepare_purchase_information(self): + """Return a GA4-compatible purchase dict for this order. + + ``transaction_id`` is ``self.id`` (integer) to match the value + emitted by the native website_sale Tracking interaction so that + GA4 can deduplicate the frontend and backend ``purchase`` events. + + Returns an empty dict when called on an empty recordset so that + QWeb templates can call this method safely in all contexts. + """ + if not self: + return {} + self.ensure_one() + delivery_line = self.order_line.filtered("is_delivery") + # GA4 spec: value = sum(price × qty) for items, shipping and tax separate. + # Use price_total (tax-inclusive) to stay consistent with the item prices + # emitted by prepare_checkout_information() which uses price_reduce_taxinc. + delivery_total = sum(delivery_line.mapped("price_total")) if delivery_line else 0.0 + info = { + "transaction_id": str(self.id), + "affiliation": self.company_id.name, + "value": self.amount_total - delivery_total, + "tax": self.amount_tax, + "currency": self.currency_id.name, + "items": self.order_line.prepare_checkout_information(), + } + if delivery_line: + info["shipping"] = delivery_total + return info + + # ------------------------------------------------------------------ + # Measurement Protocol + # ------------------------------------------------------------------ + + def _send_ga4_mp_event(self, event_name, params, _is_retry=False): + """Send a single event to the GA4 Measurement Protocol. + + Returns True on success, False on failure. + On failure the payload is persisted in ``ga4_mp_pending_payload`` + for later retry by the cron (unless this call IS the retry). + """ + self.ensure_one() + website = (self.website_id or self.env["website"].get_current_website()).sudo() + measurement_id = website.google_analytics_key + api_secret = website.ga4_api_secret + + if not measurement_id or not api_secret: + return False + if not self.ga_client_id: + return False + + event_params = dict(params) + # Required for session-scoped metrics + if self.ga_session_id: + event_params["session_id"] = self.ga_session_id + event_params.setdefault("engagement_time_msec", 100) + + body = { + "client_id": self.ga_client_id, + "events": [{"name": event_name, "params": event_params}], + } + + try: + response = requests.post( + GA4_MP_ENDPOINT, + params={"measurement_id": measurement_id, "api_secret": api_secret}, + json=body, + timeout=GA4_MP_TIMEOUT, + ) + response.raise_for_status() + _logger.info( + "GA4 MP event '%s' sent for order %s", + event_name, + self.name, + ) + return True + except Exception as exc: + _logger.warning( + "GA4 MP event '%s' failed for order %s: %s", + event_name, + self.name, + exc, + ) + if not _is_retry: + attempts = (self.ga4_mp_pending_payload or {}).get("_attempts", 0) + self.ga4_mp_pending_payload = { + "event_name": event_name, + "params": params, + "_attempts": attempts + 1, + } + else: + payload = dict(self.ga4_mp_pending_payload or {}) + payload["_attempts"] = payload.get("_attempts", 0) + 1 + self.ga4_mp_pending_payload = payload + return False + + # ------------------------------------------------------------------ + # Confirmation hook – server-side purchase event + # ------------------------------------------------------------------ + + def _action_confirm(self): + """Send a backend purchase event after confirmation. + + GA4 deduplicates with the frontend event via the same + ``transaction_id`` (order.id). The backend event is a + reliability safeguard for ad-blocker and async-payment cases. + """ + res = super()._action_confirm() + for order in self: + try: + params = order.prepare_purchase_information() + order._send_ga4_mp_event("purchase", params) + except Exception as exc: + _logger.warning( + "GA4 MP purchase event skipped for order %s: %s", + order.name, + exc, + ) + return res + + # ------------------------------------------------------------------ + # Cookie parsing utilities (called from controller) + # ------------------------------------------------------------------ + + @api.model + def _parse_ga_client_id(self, cookie_value): + """Extract client_id from ``_ga`` cookie value. + + ``_ga`` format: ``GA1.1..`` + client_id = ``.`` + """ + if not cookie_value: + return "" + parts = cookie_value.split(".") + if len(parts) >= 4: + return ".".join(parts[-2:]) + return "" + + @api.model + def _parse_ga_session_id(self, cookie_value): + """Extract session_id from ``_ga_XXXXXXXX`` cookie value. + + Cookie format: ``GS1.1.....0.0.0`` + session_id = ```` (third dot-separated component) + """ + if not cookie_value: + return "" + parts = cookie_value.split(".") + if len(parts) >= 3: + return parts[2] + return "" diff --git a/website_sale_google_analytics_4/models/sale_order_line.py b/website_sale_google_analytics_4/models/sale_order_line.py new file mode 100644 index 00000000..00acb4ac --- /dev/null +++ b/website_sale_google_analytics_4/models/sale_order_line.py @@ -0,0 +1,25 @@ +from odoo import models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def prepare_checkout_information(self): + """Return a GA4-compatible items list for this set of order lines. + + Used by QWeb templates to embed cart data as JSON and by + prepare_purchase_information() on sale.order. + """ + result = [] + for line in self.filtered(lambda l: not l.is_delivery): + result.append( + { + "item_id": line.product_id.barcode or str(line.product_id.id), + "item_name": line.product_id.name or "-", + "item_category": line.product_id.categ_id.name or "-", + "currency": line.currency_id.name, + "price": line.price_reduce_taxinc, + "quantity": line.product_uom_qty, + } + ) + return result diff --git a/website_sale_google_analytics_4/models/website.py b/website_sale_google_analytics_4/models/website.py new file mode 100644 index 00000000..0e2dd9a2 --- /dev/null +++ b/website_sale_google_analytics_4/models/website.py @@ -0,0 +1,49 @@ +from odoo import fields, models + + +class Website(models.Model): + _inherit = "website" + + ga4_api_secret = fields.Char( + string="GA4 API Secret", + groups="base.group_system", + help=( + "Measurement Protocol API Secret generated in GA4 admin.\n" + "Required to send server-side events (purchase, refund).\n" + "Generate one at: Admin → Data Streams → your stream → Measurement Protocol API secrets." + ), + ) + + def _retry_failed_ga4_mp_events(self): + """Cron: retry Measurement Protocol payloads that failed on the first attempt. + + Called once (not per website) so each pending record is processed + exactly once regardless of the number of websites. + """ + max_attempts = 5 + + orders = self.env["sale.order"].sudo().search([("ga4_mp_pending_payload", "!=", False)]) + for order in orders: + payload = order.ga4_mp_pending_payload or {} + attempts = payload.get("_attempts", 0) + if attempts >= max_attempts: + order.ga4_mp_pending_payload = False + continue + sent = order._send_ga4_mp_event( + payload.get("event_name", ""), + payload.get("params", {}), + _is_retry=True, + ) + if sent: + order.ga4_mp_pending_payload = False + + moves = self.env["account.move"].sudo().search([("ga4_mp_pending_payload", "!=", False)]) + for move in moves: + payload = move.ga4_mp_pending_payload or {} + attempts = payload.get("_attempts", 0) + if attempts >= max_attempts: + move.ga4_mp_pending_payload = False + continue + sent = move._send_ga4_mp_refund(_is_retry=True) + if sent: + move.ga4_mp_pending_payload = False diff --git a/website_sale_google_analytics_4/static/src/interactions/ga4_ecommerce.js b/website_sale_google_analytics_4/static/src/interactions/ga4_ecommerce.js new file mode 100644 index 00000000..3be7962a --- /dev/null +++ b/website_sale_google_analytics_4/static/src/interactions/ga4_ecommerce.js @@ -0,0 +1,274 @@ +/** @odoo-module **/ +/** + * GA4 Ecommerce Tracking + * + * Extends the native Odoo 19 website_sale Tracking interaction + * (which already handles view_item, add_to_cart and purchase) with + * the events that are not yet covered natively: + * + * view_cart – cart page loaded + * begin_checkout – user clicks "Proceed to Checkout" + * add_shipping_info – user selects a delivery method + * add_payment_info – user submits the payment form + * remove_from_cart – user decrements qty or deletes a cart line + * search – (extends native VPV with proper GA4 search event) + * + * Events view_item, add_to_cart and purchase are intentionally NOT + * re-implemented here – Odoo 19's native tracking.js already fires them + * via window.gtag using the CustomEvents dispatched by variant_mixin.js + * and cart_service.js. + */ + +import { PaymentForm } from '@payment/interactions/payment_form'; +import { registry } from '@web/core/registry'; +import { patch } from '@web/core/utils/patch'; +import { Interaction } from '@web/public/interaction'; +import { CartLine } from '@website_sale/interactions/cart_line'; +import { Tracking } from '@website_sale/interactions/tracking'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Forward a GA4 event via the native gtag function injected by Odoo. + * Silently no-ops when gtag is not available (e.g. ad-blocker or missing key). + * + * @param {string} name GA4 event name + * @param {Object} params GA4 event parameters + */ +function _trackGa4(name, params) { + const ga = window.gtag; + if (typeof ga === 'function') { + ga('event', name, params); + // eslint-disable-next-line no-console + console.debug('[GA4]', name, params); + } +} + +/** + * Parse the JSON stored in a dataset attribute. + * Returns null on error rather than throwing. + * + * @param {string|undefined} raw + * @returns {any|null} + */ +function _parseJson(raw) { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (e) { + console.error('[GA4] JSON parse error:', e, '\nRaw value:', raw); + return null; + } +} + +/** + * Fire remove_from_cart for a given cart product element. + * + * @param {HTMLElement} cartProductEl .o_cart_product div + * @param {number} removedQty quantity being removed + */ +function _fireRemoveFromCart(cartProductEl, removedQty) { + if (!cartProductEl) return; + const ds = cartProductEl.dataset; + const currency = ds.currency || ''; + const price = parseFloat(ds.productPrice || 0); + const itemId = ds.productSku || ds.productId || ''; + const itemName = ds.productName || ''; + const qty = removedQty > 0 ? removedQty : 1; + + _trackGa4('remove_from_cart', { + currency, + value: price * qty, + items: [{ + item_id: itemId, + item_name: itemName, + price, + quantity: qty, + }], + }); +} + +// --------------------------------------------------------------------------- +// Main GA4 Ecommerce Interaction +// --------------------------------------------------------------------------- + +export class GA4EcommerceTracking extends Interaction { + static selector = '.oe_website_sale'; + + dynamicContent = { + /** Delivery method radio – fires add_shipping_info on selection */ + '[name="o_delivery_radio"]': { + 't-on-change': this.onDeliverySelect, + }, + }; + + setup() { + // view_cart: fires once on the cart page + if (this.el.querySelector('.js_cart_lines')) { + this._fireViewCart(); + } + } + + // ------------------------------------------------------------------ + // view_cart + // ------------------------------------------------------------------ + + _fireViewCart() { + const cartProductsEl = this.el.querySelector('#cart_products'); + if (!cartProductsEl) return; + const items = _parseJson(cartProductsEl.dataset.cartInfo); + if (!items) return; + _trackGa4('view_cart', { + currency: cartProductsEl.dataset.currency || '', + value: parseFloat(cartProductsEl.dataset.value || 0), + items, + }); + } + + // ------------------------------------------------------------------ + // add_shipping_info + // ------------------------------------------------------------------ + + onDeliverySelect(ev) { + const radio = ev.target; + if (!radio.matches('[name="o_delivery_radio"]')) return; + + // Carrier name lives in the sibling label → span[name="o_delivery_method_name"] + const label = this.el.querySelector( + `label[for="${radio.id}"] [name="o_delivery_method_name"]` + ); + const shippingTier = label ? label.textContent.trim() : radio.dataset.dmId || ''; + + // Purchase info (items, value, currency) from checkout container + const container = this.el.querySelector('.o_website_sale_checkout_container'); + const purchaseInfo = container + ? _parseJson(container.dataset.purchaseInfo) + : null; + + _trackGa4('add_shipping_info', { + currency: purchaseInfo ? purchaseInfo.currency : '', + value: purchaseInfo ? purchaseInfo.value : 0, + shipping_tier: shippingTier, + items: purchaseInfo ? (purchaseInfo.items || []) : [], + }); + } +} + +registry + .category('public.interactions') + .add('ga4_ecommerce.GA4EcommerceTracking', GA4EcommerceTracking); + +// --------------------------------------------------------------------------- +// Patch: Tracking – begin_checkout +// Extends the native onCheckoutStart VPV to also fire the proper GA4 event. +// --------------------------------------------------------------------------- + +patch(Tracking.prototype, { + onCheckoutStart(ev) { + // Fire proper begin_checkout event with cart data + try { + const cartProductsEl = document.querySelector('#cart_products'); + if (cartProductsEl) { + const items = _parseJson(cartProductsEl.dataset.cartInfo); + if (items) { + _trackGa4('begin_checkout', { + currency: cartProductsEl.dataset.currency || '', + value: parseFloat(cartProductsEl.dataset.value || 0), + items, + }); + } + } + } catch (e) { + console.error('[GA4] begin_checkout error:', e); + } + // Always call the native implementation (fires the VPV) + super.onCheckoutStart(ev); + }, +}); + +// --------------------------------------------------------------------------- +// Patch: PaymentForm – add_payment_info +// Fires before the payment provider's submitForm() logic runs. +// --------------------------------------------------------------------------- + +patch(PaymentForm.prototype, { + async submitForm(ev) { + try { + const container = document.querySelector('.o_website_sale_checkout_container'); + if (container) { + const purchaseInfo = _parseJson(container.dataset.purchaseInfo); + if (purchaseInfo) { + const selectedProvider = document.querySelector( + '#payment_method input[name="o_payment_radio"]:checked' + ); + _trackGa4('add_payment_info', { + currency: purchaseInfo.currency || '', + value: purchaseInfo.value || 0, + payment_type: selectedProvider + ? (selectedProvider.dataset.providerCode || '') + : '', + items: purchaseInfo.items || [], + }); + } + } + } catch (e) { + console.error('[GA4] add_payment_info error:', e); + } + return super.submitForm(...arguments); + }, +}); + +// --------------------------------------------------------------------------- +// Patch: CartLine – remove_from_cart +// Captures quantity decrements and explicit deletions before the RPC fires +// (the page may redirect immediately after deletion, so we fire the event +// before calling super). +// --------------------------------------------------------------------------- + +patch(CartLine.prototype, { + /** + * Override: fire remove_from_cart when the minus button is clicked + * and the quantity will drop (BEFORE calling super so the event + * fires even if the page redirects on full removal). + */ + async incOrDecQuantity(ev, currentTargetEl) { + try { + const isDecrement = currentTargetEl + .querySelector('i') + ?.classList.contains('oi-minus'); + if (isDecrement) { + const input = currentTargetEl + .closest('.css_quantity') + ?.querySelector('input.js_quantity'); + const currentQty = parseFloat(input?.value || 0); + if (currentQty > 0) { + const cartProduct = currentTargetEl.closest('.o_cart_product'); + _fireRemoveFromCart(cartProduct, 1); + } + } + } catch (e) { + console.error('[GA4] remove_from_cart (inc/dec) error:', e); + } + return super.incOrDecQuantity(ev, currentTargetEl); + }, + + /** + * Override: fire remove_from_cart for the full line quantity when + * the delete button is clicked (BEFORE calling super). + */ + async deleteProduct(ev) { + try { + const cartProduct = ev.currentTarget.closest('.o_cart_product'); + const input = cartProduct?.querySelector('.css_quantity > input.js_quantity'); + const qty = parseFloat(input?.value || 0); + if (qty > 0) { + _fireRemoveFromCart(cartProduct, qty); + } + } catch (e) { + console.error('[GA4] remove_from_cart (delete) error:', e); + } + return super.deleteProduct(ev); + }, +}); diff --git a/website_sale_google_analytics_4/static/src/interactions/ga4_user_tracking.js b/website_sale_google_analytics_4/static/src/interactions/ga4_user_tracking.js new file mode 100644 index 00000000..24bb64be --- /dev/null +++ b/website_sale_google_analytics_4/static/src/interactions/ga4_user_tracking.js @@ -0,0 +1,45 @@ +/** @odoo-module **/ +/** + * GA4 User Tracking + * + * Tracks authentication events on the login/signup page: + * sign_up – user submits the registration form + * login – user submits the login form + */ + +import { registry } from '@web/core/registry'; +import { Interaction } from '@web/public/interaction'; + +export class GA4UserTracking extends Interaction { + static selector = '.oe_website_login_container'; + + dynamicContent = { + 'button.ga4_on_user_signup': { + 't-on-click': this.onUserSignup, + }, + '.oe_login_form button[type="submit"]': { + 't-on-click': this.onUserLogin, + }, + }; + + _trackGa4(name, params) { + const ga = window.gtag; + if (typeof ga === 'function') { + ga('event', name, params); + // eslint-disable-next-line no-console + console.debug('[GA4]', name, params); + } + } + + onUserSignup() { + this._trackGa4('sign_up', { method: 'email' }); + } + + onUserLogin() { + this._trackGa4('login', { method: 'email' }); + } +} + +registry + .category('public.interactions') + .add('ga4_ecommerce.GA4UserTracking', GA4UserTracking); diff --git a/website_sale_google_analytics_4/views/res_config_settings_view.xml b/website_sale_google_analytics_4/views/res_config_settings_view.xml new file mode 100644 index 00000000..57cde309 --- /dev/null +++ b/website_sale_google_analytics_4/views/res_config_settings_view.xml @@ -0,0 +1,29 @@ + + + + + res.config.settings.view.form.ga4_ecommerce + res.config.settings + + + + +
+
+
+
+
+
+
+
+
diff --git a/website_sale_google_analytics_4/views/website_sale_templates.xml b/website_sale_google_analytics_4/views/website_sale_templates.xml new file mode 100644 index 00000000..f9ea85d4 --- /dev/null +++ b/website_sale_google_analytics_4/views/website_sale_templates.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + +