From e689b4992e28290749d5f25a90ee73f7d5dd11e8 Mon Sep 17 00:00:00 2001 From: Tobias Berge Date: Thu, 8 Feb 2024 16:46:16 +0100 Subject: [PATCH] NEXT-32925 - Implement Google Consent Mode v2 --- .../2024-02-08-add-google-consent-v2.md | 8 ++ .../Framework/Cookie/CookieProvider.php | 14 ++ .../google-analytics.plugin.js | 31 +++++ .../google-analytics.plugin.test.js | 127 ++++++++++++++++++ .../snippet/de_DE/storefront.de-DE.json | 5 +- .../snippet/en_GB/storefront.en-GB.json | 5 +- .../storefront/component/analytics.html.twig | 18 +++ 7 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 changelog/_unreleased/2024-02-08-add-google-consent-v2.md create mode 100644 src/Storefront/Resources/app/storefront/test/plugin/google-analytics/google-analytics.plugin.test.js diff --git a/changelog/_unreleased/2024-02-08-add-google-consent-v2.md b/changelog/_unreleased/2024-02-08-add-google-consent-v2.md new file mode 100644 index 00000000000..89f2960d3df --- /dev/null +++ b/changelog/_unreleased/2024-02-08-add-google-consent-v2.md @@ -0,0 +1,8 @@ +--- +title: Add Google Consent V2 +issue: NEXT-32925 +--- +# Storefront +* Added new cookie group `\Shopware\Storefront\Framework\Cookie\CookieProvider::MARKETING_COOKIES` to the cookie provider +* Added new cookie `google-ads-enabled` +* Changed `views/storefront/component/analytics.html.twig` to always set a default consent with `gtag` for considering Google Consent Mode v2 \ No newline at end of file diff --git a/src/Storefront/Framework/Cookie/CookieProvider.php b/src/Storefront/Framework/Cookie/CookieProvider.php index 0476d9453f2..ad73f585d54 100644 --- a/src/Storefront/Framework/Cookie/CookieProvider.php +++ b/src/Storefront/Framework/Cookie/CookieProvider.php @@ -65,6 +65,19 @@ class CookieProvider implements CookieProviderInterface ], ]; + private const MARKETING_COOKIES = [ + 'snippet_name' => 'cookie.groupMarketing', + 'snippet_description' => 'cookie.groupMarketingDescription', + 'entries' => [ + [ + 'snippet_name' => 'cookie.groupMarketingAdConsent', + 'cookie' => 'google-ads-enabled', + 'expiration' => '30', + 'value' => '1', + ], + ], + ]; + /** * A group CAN be a cookie, it's entries MUST be a cookie. * If a "group" is a cookie itself, it should not contain "children", because it may lead to unexpected UI behavior. @@ -100,6 +113,7 @@ public function getCookieGroups(): array return [ $requiredCookies, self::STATISTICAL_COOKIES, + self::MARKETING_COOKIES, self::COMFORT_FEATURES_COOKIES, ]; } diff --git a/src/Storefront/Resources/app/storefront/src/plugin/google-analytics/google-analytics.plugin.js b/src/Storefront/Resources/app/storefront/src/plugin/google-analytics/google-analytics.plugin.js index dff5ba5cdf7..dd04f424ab8 100644 --- a/src/Storefront/Resources/app/storefront/src/plugin/google-analytics/google-analytics.plugin.js +++ b/src/Storefront/Resources/app/storefront/src/plugin/google-analytics/google-analytics.plugin.js @@ -24,6 +24,7 @@ export default class GoogleAnalyticsPlugin extends Plugin { init() { this.cookieEnabledName = 'google-analytics-enabled'; + this.cookieAdsEnabledName = 'google-ads-enabled'; this.storage = Storage; this.handleTrackingLocation(); @@ -113,6 +114,8 @@ export default class GoogleAnalyticsPlugin extends Plugin handleCookies(cookieUpdateEvent) { const updatedCookies = cookieUpdateEvent.detail; + this._updateConsent(updatedCookies); + if (!Object.prototype.hasOwnProperty.call(updatedCookies, this.cookieEnabledName)) { return; } @@ -146,6 +149,34 @@ export default class GoogleAnalyticsPlugin extends Plugin }); } + /** + * @param {Object} updatedCookies + * @private + */ + _updateConsent(updatedCookies) { + if (Object.keys(updatedCookies).length === 0) { + return; + } + + const consentUpdateConfig = {}; + + if (Object.prototype.hasOwnProperty.call(updatedCookies, this.cookieEnabledName)) { + consentUpdateConfig['analytics_storage'] = updatedCookies[this.cookieEnabledName] ? 'granted' : 'denied'; + } + + if (Object.prototype.hasOwnProperty.call(updatedCookies, this.cookieAdsEnabledName)) { + consentUpdateConfig['ad_storage'] = updatedCookies[this.cookieAdsEnabledName] ? 'granted' : 'denied'; + consentUpdateConfig['ad_user_data'] = updatedCookies[this.cookieAdsEnabledName] ? 'granted' : 'denied'; + consentUpdateConfig['ad_personalization'] = updatedCookies[this.cookieAdsEnabledName] ? 'granted' : 'denied'; + } + + if (Object.keys(consentUpdateConfig).length === 0) { + return; + } + + gtag('consent', 'update', consentUpdateConfig); + } + /** * @private */ diff --git a/src/Storefront/Resources/app/storefront/test/plugin/google-analytics/google-analytics.plugin.test.js b/src/Storefront/Resources/app/storefront/test/plugin/google-analytics/google-analytics.plugin.test.js new file mode 100644 index 00000000000..70d9fb102f1 --- /dev/null +++ b/src/Storefront/Resources/app/storefront/test/plugin/google-analytics/google-analytics.plugin.test.js @@ -0,0 +1,127 @@ +import GoogleAnalyticsPlugin from 'src/plugin/google-analytics/google-analytics.plugin'; +import AddToCartEvent from 'src/plugin/google-analytics/events/add-to-cart.event'; +import AddToCartByNumberEvent from 'src/plugin/google-analytics/events/add-to-cart-by-number.event'; +import BeginCheckoutEvent from 'src/plugin/google-analytics/events/begin-checkout.event'; +import BeginCheckoutOnCartEvent from 'src/plugin/google-analytics/events/begin-checkout-on-cart.event'; +import CheckoutProgressEvent from 'src/plugin/google-analytics/events/checkout-progress.event'; +import LoginEvent from 'src/plugin/google-analytics/events/login.event'; +import PurchaseEvent from 'src/plugin/google-analytics/events/purchase.event'; +import RemoveFromCartEvent from 'src/plugin/google-analytics/events/remove-from-cart.event'; +import SearchAjaxEvent from 'src/plugin/google-analytics/events/search-ajax.event'; +import SignUpEvent from 'src/plugin/google-analytics/events/sign-up.event'; +import ViewItemEvent from 'src/plugin/google-analytics/events/view-item.event'; +import ViewItemListEvent from 'src/plugin/google-analytics/events/view-item-list.event'; +import ViewSearchResultsEvent from 'src/plugin/google-analytics/events/view-search-results'; +import { COOKIE_CONFIGURATION_UPDATE } from 'src/plugin/cookie/cookie-configuration.plugin'; + +describe('plugin/google-analytics/google-analytics.plugin', () => { + beforeEach(() => { + window.useDefaultCookieConsent = true; + window.gtag = jest.fn(); + window.gtagTrackingId = 'GA-12345-6'; + window.gtagURL = `https://www.googletagmanager.com/gtag/js?id=${window.gtagTrackingId}`; + window.gtagConfig = { + 'anonymize_ip': '1', + 'cookie_domain': 'none', + 'cookie_prefix': '_swag_ga', + }; + + document.$emitter.unsubscribe(COOKIE_CONFIGURATION_UPDATE); + }); + + afterEach(() => { + // Reset all cookies after each test + document.cookie = ''; + document.head.innerHTML = ''; + + jest.clearAllMocks(); + window.gtag.mockRestore(); + }); + + test('initialize Google Analytics plugin', () => { + expect(new GoogleAnalyticsPlugin(document)).toBeInstanceOf(GoogleAnalyticsPlugin); + }); + + test('starts Google Analytics when allowance cookie is set', () => { + // Set the Google Analytics cookie + Object.defineProperty(document, 'cookie', { + writable: true, + value: 'google-analytics-enabled=1', + }); + + const startGoogleAnalyticsSpy = jest.spyOn(GoogleAnalyticsPlugin.prototype, 'startGoogleAnalytics'); + new GoogleAnalyticsPlugin(document); + + expect(startGoogleAnalyticsSpy).toHaveBeenCalledTimes(1); + + // Verify gtag is called with expected parameters from window object + expect(window.gtag).toHaveBeenCalledTimes(2); + expect(window.gtag).toHaveBeenCalledWith('js', expect.any(Date)); + expect(window.gtag).toHaveBeenCalledWith('config', window.gtagTrackingId, window.gtagConfig); + + // Verify the tag manager script is injected into the with correct src + expect(document.getElementsByTagName('script')[0].src).toBe(window.gtagURL); + }); + + test('does not inject Google Analytics script when allowance cookie is not set', () => { + // No cookie is set before the plugin is initialized + new GoogleAnalyticsPlugin(document); + + // Verify gtag is not called + expect(window.gtag).not.toHaveBeenCalled(); + + // Verify that no analytics {% endif %} {% endblock %} + + {% endblock %}