From 61d3aadb49436dbe73fd8a96a75a9f0bd7210eac Mon Sep 17 00:00:00 2001 From: Robin Morel Date: Tue, 21 Jul 2026 15:48:22 +0200 Subject: [PATCH 1/4] add support theme hook --- Hook/Theme/GoogleTagManagerThemeHook.php | 40 ++++++++++++++++++++++++ templates/theme-hook/noscript.html.twig | 5 +++ templates/theme-hook/script.html.twig | 14 +++++++++ 3 files changed, 59 insertions(+) create mode 100644 Hook/Theme/GoogleTagManagerThemeHook.php create mode 100644 templates/theme-hook/noscript.html.twig create mode 100644 templates/theme-hook/script.html.twig diff --git a/Hook/Theme/GoogleTagManagerThemeHook.php b/Hook/Theme/GoogleTagManagerThemeHook.php new file mode 100644 index 0000000..a68a550 --- /dev/null +++ b/Hook/Theme/GoogleTagManagerThemeHook.php @@ -0,0 +1,40 @@ +config->getContainerId(); + + if(empty($containerId)) { + return $containerId; + } + + return match ($hookName) { + 'layout.head.bottom' => $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 + ]), + }; + } +} diff --git a/templates/theme-hook/noscript.html.twig b/templates/theme-hook/noscript.html.twig new file mode 100644 index 0000000..ea5b896 --- /dev/null +++ b/templates/theme-hook/noscript.html.twig @@ -0,0 +1,5 @@ + + + diff --git a/templates/theme-hook/script.html.twig b/templates/theme-hook/script.html.twig new file mode 100644 index 0000000..545db03 --- /dev/null +++ b/templates/theme-hook/script.html.twig @@ -0,0 +1,14 @@ + + + From f4b04b9e2d9a6fae97c93e9ebdc5047d75873430 Mon Sep 17 00:00:00 2001 From: Robin Morel Date: Wed, 22 Jul 2026 15:02:26 +0200 Subject: [PATCH 2/4] add support theme hook --- Hook/Theme/GoogleTagManagerThemeHook.php | 26 ++++- Service/DataLayerProvider.php | 27 +++-- Service/GoogleTagService.php | 3 +- Service/GtmTagRenderer.php | 107 ++---------------- Twig/GoogleTagManagerExtension.php | 16 +-- .../default/assets/js/addToCart.js | 29 +++++ .../frontOffice/default/assets/js/getItem.js | 34 ++++++ templates/theme-hook/addToCart.html.twig | 4 + templates/theme-hook/getItems.html.twig | 4 + templates/theme-hook/push.html.twig | 5 + templates/theme-hook/script.html.twig | 1 + 11 files changed, 123 insertions(+), 133 deletions(-) create mode 100644 templates/frontOffice/default/assets/js/addToCart.js create mode 100644 templates/frontOffice/default/assets/js/getItem.js create mode 100644 templates/theme-hook/addToCart.html.twig create mode 100644 templates/theme-hook/getItems.html.twig create mode 100644 templates/theme-hook/push.html.twig diff --git a/Hook/Theme/GoogleTagManagerThemeHook.php b/Hook/Theme/GoogleTagManagerThemeHook.php index a68a550..1009619 100644 --- a/Hook/Theme/GoogleTagManagerThemeHook.php +++ b/Hook/Theme/GoogleTagManagerThemeHook.php @@ -1,9 +1,12 @@ config->getContainerId(); - if(empty($containerId)) { - return $containerId; + if( empty($containerId) ) { + return ''; } + $productId = $parameters['product']['id'] ?? null; return match ($hookName) { - 'layout.head.bottom' => $this->twig->render('@GoogleTagManagerModule/theme-hook/script.html.twig',[ + 'layout.head.bottom' => $this->twig->render('@GoogleTagManagerModule/theme-hook/script.html.twig', [ 'containerId' => $containerId ]), - 'layout.body.top' => $this->twig->render('@GoogleTagManagerModule/theme-hook/noscript.html.twig',[ + '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 => '', }; } } diff --git a/Service/DataLayerProvider.php b/Service/DataLayerProvider.php index a0811f3..7967c08 100644 --- a/Service/DataLayerProvider.php +++ b/Service/DataLayerProvider.php @@ -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, ) { } @@ -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)) { @@ -80,21 +81,19 @@ 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)) { + return $this->twig->render('@GoogleTagManagerModule/theme-hook/getItems.html.twig'); } - - return $html.$this->renderer->renderAddToCart(); + return $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 ''; } } diff --git a/Service/GoogleTagService.php b/Service/GoogleTagService.php index f436188..b0917d1 100644 --- a/Service/GoogleTagService.php +++ b/Service/GoogleTagService.php @@ -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); } @@ -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); } /** diff --git a/Service/GtmTagRenderer.php b/Service/GtmTagRenderer.php index ab9f9e6..aac65fd 100644 --- a/Service/GtmTagRenderer.php +++ b/Service/GtmTagRenderer.php @@ -15,34 +15,19 @@ namespace GoogleTagManager\Service; -use Thelia\Tools\URL; +use Twig\Environment; /** * Builds the raw GTM markup fragments (container, noscript, dataLayer pushes, JS listeners). * Stateless rendering primitives — the "what to render for the current page" decision lives * in {@see DataLayerProvider}. */ -class GtmTagRenderer +final readonly class GtmTagRenderer { - public function renderContainer(string $containerId): string - { - return '' - ."" - .''; - } - - public function renderNoscript(string $containerId): string - { - return '' - ."" - .''; - } + public function __construct( + private Environment $twig, + ){} /** * dataLayer push of a JSON payload built by {@see GoogleTagService}. * The payloads are JSON-encoded with JSON_HEX_APOS|JSON_HEX_QUOT, so they are safe @@ -54,8 +39,9 @@ public function dataLayerPush(false|string|null $data): string return ''; } - return '"; + return $this->twig->render('@GoogleTagManagerModule/theme-hook/push.html.twig',[ + 'data' => $data + ]); } /** @@ -64,79 +50,8 @@ public function dataLayerPush(false|string|null $data): string */ public function shortCodePush(string $shortCodeTag): string { - return '"; - } - - public function renderSelectItem(): string - { - $url = URL::getInstance()?->absoluteUrl('/googletagmanager/getItem'); - - return << -document.addEventListener("DOMContentLoaded", function(e) { - const products = document.querySelectorAll("a.SingleProduct, .SingleProduct a"); - let processLinkClick = async function(e) { - let targetUrl; - if (window['google_tag_manager']) { - e.preventDefault(); - targetUrl = e.currentTarget.href; - if (undefined === targetUrl) { - targetUrl = e.currentTarget.getElementsByTagName('a')[0].href; - } - const response = await fetch('$url', { - method: "POST", - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ productUrl: targetUrl }) - }) - const resultJson = await response.json(); - window.dataLayer = window.dataLayer || []; - window.dataLayer.push({ ecommerce: null }); - window.dataLayer.push({ - event: 'select_item', - ecommerce: { items: JSON.parse(resultJson) }, - eventCallback: function() { window.location = targetUrl; }, - eventTimeout : 2000 - }); - } - }; - for (let i = 0; i < products.length; i++) { - products[i].addEventListener('click', processLinkClick, false); - } -}); - -HTML; - } - - public function renderAddToCart(): string - { - $url = URL::getInstance()?->absoluteUrl('/googletagmanager/getCartItem'); - - return << -let getCartItem = async function(e) { - const response = await fetch('$url', { - method: "POST", - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ pseId: e.detail.pse, quantity: e.detail.quantity }) - }) - return await response.json(); -} -let addPseToCart = async function(e) { - const resultJson = await getCartItem(e) - window.dataLayer = window.dataLayer || []; - window.dataLayer.push({ ecommerce: null }); - window.dataLayer.push({ event: 'add_to_cart', ecommerce: JSON.parse(resultJson) }); -} -let removePseFromCart = async function(e) { - const resultJson = await getCartItem(e) - window.dataLayer = window.dataLayer || []; - window.dataLayer.push({ ecommerce: null }); - window.dataLayer.push({ event: 'remove_from_cart', ecommerce: JSON.parse(resultJson) }); -} -document.addEventListener('addPseToCart', addPseToCart); -document.addEventListener('removePseFromCart', removePseFromCart); - -HTML; + return $this->twig->render('@GoogleTagManagerModule/theme-hook/push.html.twig', [ + 'data' => '['.$shortCodeTag.']' + ]); } } diff --git a/Twig/GoogleTagManagerExtension.php b/Twig/GoogleTagManagerExtension.php index 1f117af..d90131b 100644 --- a/Twig/GoogleTagManagerExtension.php +++ b/Twig/GoogleTagManagerExtension.php @@ -24,16 +24,12 @@ /** * Exposes the GTM rendering to Twig templates. Call these functions in your front theme * (the module no longer relies on Thelia hooks): - * - {{ gtm_head() }} in - * - {{ gtm_body() }} right after - * - {{ gtm_js_init() }} before * - {{ gtm_track_product(product.id) }} in the product page template */ class GoogleTagManagerExtension extends AbstractExtension { public function __construct( private readonly GtmConfig $config, - private readonly GtmTagRenderer $renderer, private readonly DataLayerProvider $dataLayerProvider, ) { } @@ -42,7 +38,6 @@ public function getFunctions(): array { return [ new TwigFunction('gtm_head', [$this, 'head'], ['is_safe' => ['html']]), - new TwigFunction('gtm_body', [$this, 'body'], ['is_safe' => ['html']]), new TwigFunction('gtm_js_init', [$this, 'jsInit'], ['is_safe' => ['html']]), new TwigFunction('gtm_track_product', [$this, 'trackProduct'], ['is_safe' => ['html']]), ]; @@ -54,16 +49,7 @@ public function head(): string return ''; } - return $this->dataLayerProvider->renderHead().$this->renderer->renderContainer($this->config->getContainerId()); - } - - public function body(): string - { - if (!$this->config->isEnabled()) { - return ''; - } - - return $this->renderer->renderNoscript($this->config->getContainerId()); + return $this->dataLayerProvider->renderHead(); } public function jsInit(): string diff --git a/templates/frontOffice/default/assets/js/addToCart.js b/templates/frontOffice/default/assets/js/addToCart.js new file mode 100644 index 0000000..3e5918b --- /dev/null +++ b/templates/frontOffice/default/assets/js/addToCart.js @@ -0,0 +1,29 @@ +document.addEventListener('addPseToCart', addPseToCart); +document.addEventListener('removePseFromCart', removePseFromCart); + +async function getCartItem(e) { + const response = await fetch(window.GTM_ADD_TO_CART, { + method: "POST", + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ pseId: e.detail.pse, quantity: e.detail.quantity }) + }) + return await response.json(); +} + +async function addPseToCart(e) { + const resultJson = await getCartItem(e); + window.dataLayer = window.dataLayer || []; + window.dataLayer.push({ ecommerce: null }); + window.dataLayer.push({ event: 'add_to_cart', ecommerce: JSON.parse(resultJson) }); +} + +async function removePseFromCart(e) { + const resultJson = await getCartItem(e); + window.dataLayer = window.dataLayer || []; + window.dataLayer.push({ ecommerce: null }); + window.dataLayer.push({ event: 'remove_from_cart', ecommerce: JSON.parse(resultJson) }); +} + + + + diff --git a/templates/frontOffice/default/assets/js/getItem.js b/templates/frontOffice/default/assets/js/getItem.js new file mode 100644 index 0000000..991c276 --- /dev/null +++ b/templates/frontOffice/default/assets/js/getItem.js @@ -0,0 +1,34 @@ +document.addEventListener('DOMContentLoaded', async () => { + const products = document.querySelectorAll("a.ProductCard, .ProductCard a"); + + if (!window.dataLayer) return; + + products.forEach(product => { + product.addEventListener('click',(e) => processLinkClick(e), false); + }) +}) + +async function processLinkClick (e) { + e.preventDefault(); + + const targetUrl = e.currentTarget?.href ?? e.currentTarget.getElementsByTagName('a')[0].href; + + if (!targetUrl) return null; + + const response = await fetch(window.GTM_GET_ITEM_URL, { + method: "POST", + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ productUrl: targetUrl }) + }) + + const resultJson = await response.json(); + + window.dataLayer.push({ ecommerce: null }); + + window.dataLayer.push({ + 'event': 'select_item', + 'ecommerce': { items: JSON.parse(resultJson) } + }); + + window.location.href = targetUrl; +} diff --git a/templates/theme-hook/addToCart.html.twig b/templates/theme-hook/addToCart.html.twig new file mode 100644 index 0000000..704932d --- /dev/null +++ b/templates/theme-hook/addToCart.html.twig @@ -0,0 +1,4 @@ + + diff --git a/templates/theme-hook/getItems.html.twig b/templates/theme-hook/getItems.html.twig new file mode 100644 index 0000000..188a528 --- /dev/null +++ b/templates/theme-hook/getItems.html.twig @@ -0,0 +1,4 @@ + + diff --git a/templates/theme-hook/push.html.twig b/templates/theme-hook/push.html.twig new file mode 100644 index 0000000..f7868b9 --- /dev/null +++ b/templates/theme-hook/push.html.twig @@ -0,0 +1,5 @@ + diff --git a/templates/theme-hook/script.html.twig b/templates/theme-hook/script.html.twig index 545db03..58c4bb3 100644 --- a/templates/theme-hook/script.html.twig +++ b/templates/theme-hook/script.html.twig @@ -1,3 +1,4 @@ +{{ gtm_head() }}