Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions GoogleTagManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class GoogleTagManager extends BaseModule
public const GOOGLE_TAG_VIEW_ITEM = 'google_tag_view_item';
public const GOOGLE_TAG_VIEW_LIST_ITEM = 'google_tag_view_list_item';
public const GOOGLE_TAG_TRIGGER_LOGIN = 'google_tag_trigger_login';
public const GOOGLE_TAG_PURCHASE = 'google_tag_purchase';

/**
* @throws PropelException
Expand Down
55 changes: 55 additions & 0 deletions Hook/Theme/GoogleTagManagerThemeHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace GoogleTagManager\Hook\Theme;

use GoogleTagManager\Service\DataLayerProvider;
use GoogleTagManager\Service\GtmConfig;
use Thelia\Core\Hook\Theme\ThemeHookInterface;
use Twig\Environment;

final readonly class GoogleTagManagerThemeHook implements ThemeHookInterface
{
public function __construct(
private Environment $twig,
private GtmConfig $config,
private DataLayerProvider $dataLayerProvider,
) {
}

public function supports(string $hookName): bool
{
return \in_array($hookName,
[
'layout.head.bottom',
'layout.body.top',
'layout.body.bottom',
'product.bottom'
], true);
}

public function render(string $hookName, array $parameters): string
{
$containerId = $this->config->getContainerId();

if( empty($containerId) ) {
return '';
}

$productId = $parameters['product']['id'] ?? null;
return match ($hookName) {
// The dataLayer pushes must come before the container script loads gtm.js.
'layout.head.bottom' => $this->dataLayerProvider->renderHead()
.$this->twig->render('@GoogleTagManagerModule/theme-hook/script.html.twig', [
'containerId' => $containerId,
]),
'layout.body.top' => $this->twig->render('@GoogleTagManagerModule/theme-hook/noscript.html.twig', [
'containerId' => $containerId
]),
'layout.body.bottom' => $this->dataLayerProvider->renderJsInit(),
'product.bottom' => $productId ? $this->dataLayerProvider->trackProduct($productId) : '',
default => '',
};
}
}
25 changes: 25 additions & 0 deletions Listener/GoogleTagListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Thelia\Core\Event\Customer\CustomerCreateOrUpdateEvent;
use Thelia\Core\Event\Customer\CustomerLoginEvent;
use Thelia\Core\Event\Loop\LoopExtendsParseResultsEvent;
use Thelia\Core\Event\Order\OrderEvent;
use Thelia\Core\Event\TheliaEvents;
use Thelia\Model\CurrencyQuery;
use Thelia\Model\Lang;
Expand All @@ -32,13 +33,37 @@ public static function getSubscribedEvents(): array
GoogleTagManager::GOOGLE_TAG_VIEW_ITEM => ['getViewItem', 128],
TheliaEvents::CUSTOMER_LOGIN => ['triggerLoginEvent', 128],
TheliaEvents::CUSTOMER_CREATEACCOUNT => ['triggerRegisterEvent', 128],
// Runs after the core Order::create (priority 128) which sets the placed order.
TheliaEvents::ORDER_PAY => ['trackPurchase', 64],
TheliaEvents::getLoopExtendsEvent(
TheliaEvents::LOOP_EXTENDS_PARSE_RESULTS,
'product'
) => ['trackProducts', 128]
];
}

/**
* Stores the placed order id in session, consumed by DataLayerProvider::renderHead
* on the confirmation page to push the purchase/payment/shipping dataLayer events.
* The Flexy checkout confirmation page carries no order_id in the request.
*/
public function trackPurchase(OrderEvent $event): void
{
$request = $this->requestStack->getCurrentRequest();

// Tracking must never break the payment flow: ORDER_PAY can be dispatched outside a
// web context (CLI, payment callback), where RequestStack::getSession() would throw.
// getPlacedOrder() is safe here — Order::create either sets it or throws at priority 128.
if (null === $request || !$request->hasSession()) {
return;
}

$request->getSession()->set(
GoogleTagManager::GOOGLE_TAG_PURCHASE,
$event->getPlacedOrder()->getId()
);
}

/**
* @throws \JsonException|PropelException
*/
Expand Down
90 changes: 55 additions & 35 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,53 +7,73 @@ This module is made to use the Google Tag Manager / Google Analytics 4.
### Composer

```
composer require thelia/google-tag-manager-module:~2.1.0
composer require thelia/google-tag-manager-module:^4.0
```

## Usage

You need to configure the id from your Google tag manager account in the thelia administration panel.\
It should look like ```GTM-XXXX```.
It should look like ```GTM-XXXX```. Nothing is rendered while this id is empty.

This module renders its scripts through Twig functions you place in your front theme.
The module injects its scripts through **theme hooks**: as long as your front theme declares the
hook points below, there is nothing to add to your templates.

| Function | Where | What it outputs |
|----------|-------|-----------------|
| `{{ gtm_head() }}` | in `<head>` | GTM container script + dataLayer pushes (page view, view_item, purchase…) |
| `{{ gtm_body() }}` | right after `<body>` | GTM `<noscript>` fallback |
| `{{ gtm_js_init() }}` | before `</body>` | `select_item` / `add_to_cart` event listeners |
| `{{ gtm_track_product(product.id) }}` | product page template | registers the viewed product (enables the `view_item` event) |
| Theme hook | Where the theme declares it | What the module renders |
|------------|-----------------------------|-------------------------|
| `layout.head.bottom` | in `<head>` | GTM container script + dataLayer pushes (`page_view`, `view_item`, `view_item_list`, `login`, `view_cart`, `begin_checkout`, `purchase`…) |
| `layout.body.top` | right after `<body>` | GTM `<noscript>` fallback |
| `layout.body.bottom` | before `</body>` | `select_item` / `add_to_cart` event listeners |
| `product.bottom` | product page, with a `product` parameter | registers the viewed product (enables the `view_item` event) |

### Adding the functions to your front template

In your base layout (e.g. `templates/frontOffice/<your-theme>/base.html.twig`):
The `work` front-office theme declares all four out of the box:

```twig
<head>
{{ gtm_head() }}
{# ... #}
</head>
<body>
{{ gtm_body() }}
{# ... #}
{{ gtm_js_init() }}
</body>
{# templates/frontOffice/work/base.html.twig #}
{{ theme_hook('layout.head.bottom', {breadcrumb}) }}
{{ theme_hook('layout.body.top') }}
{{ theme_hook('layout.body.bottom') }}

{# templates/frontOffice/work/product.html.twig #}
{{ theme_hook('product.bottom', {product: product}) }}
```

In your product page template (e.g. `templates/frontOffice/<your-theme>/product.html.twig`),
inside the body — this is required for the `view_item` event:
`product.bottom` must receive the product, otherwise the `view_item` event is silently skipped.

```twig
{{ gtm_track_product(product.id) }}
```
### Themes without theme hooks

If your theme does not declare those hook points, the same rendering is available as Twig functions
you place yourself:

| Function | Where | Equivalent to |
|----------|-------|---------------|
| `{{ gtm_head() }}` | in `<head>` | the dataLayer pushes of `layout.head.bottom` |
| `{{ gtm_js_init() }}` | before `</body>` | `layout.body.bottom` |
| `{{ gtm_track_product(product.id) }}` | product page template | `product.bottom` |

**Do not use both mechanisms at once.** `gtm_head()` is already called by the template rendered on
`layout.head.bottom`, so adding it to a theme that declares that hook pushes `page_view` twice.

To track products added to the cart, you need to implement this js event on the "Add to cart" buttons.
```js
const event = new CustomEvent("addPseToCart", {
detail: {
pse: pseId,
quantity
},
});
document.dispatchEvent(event);
Two caveats on this fallback: `gtm_head()` outputs the dataLayer pushes only — you have to add the
GTM container `<script>` and the `<noscript>` iframe to your layout yourself — and there is no Twig
function for the `<noscript>` fallback.

### Tracking add to cart / remove from cart

The `add_to_cart` and `remove_from_cart` events are driven by two JS custom events you dispatch from
your "Add to cart" / "Remove from cart" buttons:

```js
document.dispatchEvent(new CustomEvent('addPseToCart', {
detail: { pse: pseId, quantity },
}));

document.dispatchEvent(new CustomEvent('removePseFromCart', {
detail: { pse: pseId, quantity },
}));
```

### Tracking select_item on listings

On listing views (`category`, `brand`, `search`, `folder`, `content`, `page`) the module binds the
`select_item` event to product links matching `a.ProductCard, .ProductCard a`. Adapt
`templates/frontOffice/default/assets/js/getItem.js` if your theme uses different markup.
37 changes: 22 additions & 15 deletions Service/DataLayerProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,22 @@
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Thelia\Domain\Taxation\TaxEngine\TaxEngine;
use Twig\Environment;

/**
* Decides which dataLayer events must be pushed for the current page (based on the
* current `_view`), and produces the corresponding markup using {@see GtmTagRenderer}.
* This holds the orchestration logic that used to live in the front hook.
*/
class DataLayerProvider
final readonly class DataLayerProvider
{
public function __construct(
private readonly GoogleTagService $googleTagService,
private readonly GtmTagRenderer $renderer,
private readonly TaxEngine $taxEngine,
private readonly RequestStack $requestStack,
private readonly EventDispatcherInterface $eventDispatcher,
private GoogleTagService $googleTagService,
private GtmTagRenderer $renderer,
private TaxEngine $taxEngine,
private RequestStack $requestStack,
private EventDispatcherInterface $eventDispatcher,
private Environment $twig,
) {
}

Expand All @@ -41,7 +43,6 @@ public function renderHead(): string
$request = $this->requestStack->getCurrentRequest();
$session = $request?->getSession();
$view = $request?->attributes->get('_view', $request->query->get('_view', $request->request->get('_view')));

$html = $this->renderer->dataLayerPush($this->googleTagService->getTheliaPageViewParameters());

if (\in_array($view, ['category', 'brand', 'search'], true)) {
Expand All @@ -57,19 +58,24 @@ public function renderHead(): string
$session->set(GoogleTagManager::GOOGLE_TAG_TRIGGER_LOGIN, null);
}

if ('order-delivery' === $view) {
$view = $request?->attributes->get('_route', $request->query->get('_route', $request->request->get('_route')));

if ('checkout_delivery' === $view) {
$cart = $session?->getSessionCart($this->eventDispatcher);
$country = $this->taxEngine->getDeliveryCountry();

$html .= $this->renderer->dataLayerPush($this->googleTagService->getCartData($cart?->getId(), $country));
$html .= $this->renderer->dataLayerPush($this->googleTagService->getCheckOutData($cart?->getId(), $country));
}

if ('order-placed' === $view
&& $orderId = $request?->attributes->get('order_id', $request->query->get('order_id', $request->request->get('order_id')))) {
// Only on the /pay page for now. The placed order id is captured in session by
// GoogleTagListener::trackPurchase (the request carries no order_id here).
if ('checkout_pay' === $view
&& null !== $orderId = $session?->get(GoogleTagManager::GOOGLE_TAG_PURCHASE)) {
$html .= $this->renderer->dataLayerPush($this->googleTagService->getPurchaseData((int) $orderId));
$html .= $this->renderer->dataLayerPush($this->googleTagService->getPaymentInfo((int) $orderId));
$html .= $this->renderer->dataLayerPush($this->googleTagService->getShippingInfo((int) $orderId));
$session->set(GoogleTagManager::GOOGLE_TAG_PURCHASE, null);
}

return $html;
Expand All @@ -79,22 +85,23 @@ public function renderJsInit(): string
{
$request = $this->requestStack->getCurrentRequest();
$view = $request?->attributes->get('_view', $request->query->get('_view', $request->request->get('_view')));

$html = '';

if (\in_array($view, ['category', 'brand', 'search'], true)) {
$html .= $this->renderer->renderSelectItem();
if (\in_array($view, ['category', 'brand', 'search', 'folder', 'content', 'page'], true)) {
$html .= $this->twig->render('@GoogleTagManagerModule/theme-hook/getItems.html.twig');
}

return $html.$this->renderer->renderAddToCart();
// Always loaded: a listing page may also carry an add-to-cart button.
return $html.$this->twig->render('@GoogleTagManagerModule/theme-hook/addToCart.html.twig');
}

/**
* Stores the viewed product id in session, consumed by GoogleTagListener::getViewItem
* to resolve the [google_tag_view_item] ShortCode at response time.
*/
public function trackProduct(int|string|null $productId): void
public function trackProduct(int|string|null $productId): string
{
$this->requestStack->getCurrentRequest()?->getSession()?->set(GoogleTagManager::GOOGLE_TAG_VIEW_ITEM, $productId);
return '';
}
}
3 changes: 1 addition & 2 deletions Service/GoogleTagService.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ public function getTheliaPageViewParameters(): false|string

$event = new GoogleTagPageViewEvent($result, $user, $view);
$event = $this->dispatcher->dispatch($event);

return json_encode($event->getResult(), JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
}

Expand Down Expand Up @@ -366,7 +365,7 @@ public function getPaymentInfo(int $orderId): false|string|null
'payment_type' => $paymentType,
'items' => $this->getOrderProductItems($order, $order->getOrderAddressRelatedByInvoiceOrderAddressId()->getCountry())
]
], JSON_THROW_ON_ERROR);
], JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
}

/**
Expand Down
Loading