Skip to content

Commit

Permalink
Merge branch 'next-32925/google-consent-v2' into '6.5.x'
Browse files Browse the repository at this point in the history
NEXT-32925 - Implement Google Consent Mode v2

See merge request shopware/6/product/platform!12930
  • Loading branch information
tobiasberge committed Feb 19, 2024
2 parents 708baef + f5f9aeb commit 8767a98
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 2 deletions.
14 changes: 14 additions & 0 deletions Framework/Cookie/CookieProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -100,6 +113,7 @@ public function getCookieGroups(): array
return [
$requiredCookies,
self::STATISTICAL_COOKIES,
self::MARKETING_COOKIES,
self::COMFORT_FEATURES_COOKIES,
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <head> 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 <script> is injected
expect(document.getElementsByTagName('script').length).toBe(0);
});

test('registers all default Google Analytics events allowance cookie is set', () => {
// Set the Google Analytics cookie
Object.defineProperty(document, 'cookie', {
writable: true,
value: 'google-analytics-enabled=1',
});

const googleAnalyticsPlugin = new GoogleAnalyticsPlugin(document);

// Verify all default events are registered
expect(googleAnalyticsPlugin.events).toEqual([
new AddToCartEvent(),
new AddToCartByNumberEvent(),
new BeginCheckoutEvent(),
new BeginCheckoutOnCartEvent(),
new CheckoutProgressEvent(),
new LoginEvent(),
new PurchaseEvent(),
new RemoveFromCartEvent(),
new SearchAjaxEvent(),
new SignUpEvent(),
new ViewItemEvent(),
new ViewItemListEvent(),
new ViewSearchResultsEvent(),
]);
});

test('sets the correct google consent when cookie update event is fired', () => {
// Set the Google Analytics cookie
Object.defineProperty(document, 'cookie', {
writable: true,
value: 'google-analytics-enabled=1',
});

new GoogleAnalyticsPlugin(document)

// Simulate cookie update event
document.$emitter.publish(COOKIE_CONFIGURATION_UPDATE, {
'google-analytics-enabled': true,
'google-ads-enabled': true,
});

// Verify gtag consent update is called with expected parameters
expect(window.gtag).toHaveBeenNthCalledWith(3, 'consent', 'update', {
'ad_user_data': 'granted',
'ad_personalization': 'granted',
'ad_storage': 'granted',
'analytics_storage': 'granted',
});
});
});
5 changes: 4 additions & 1 deletion Resources/snippet/de_DE/storefront.de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,10 @@
"groupStatisticalGoogleAnalytics": "Google Analytics",
"groupComfortFeatures": "Komfortfunktionen",
"groupComfortFeaturesWishlist": "Merkzettel",
"groupComfortFeaturesYoutubeVideo": "YouTube-Video"
"groupComfortFeaturesYoutubeVideo": "YouTube-Video",
"groupMarketing": "Marketing",
"groupMarketingDescription": "Erlaubt es Google, personenbezogene Daten für Online-Werbung und Marketing zu sammeln.",
"groupMarketingAdConsent": "Google Werbung und Marketing"
},
"ellipsis": {
"expandLabel": "mehr anzeigen",
Expand Down
5 changes: 4 additions & 1 deletion Resources/snippet/en_GB/storefront.en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,10 @@
"groupStatisticalGoogleAnalytics": "Google Analytics",
"groupComfortFeatures": "Comfort features",
"groupComfortFeaturesWishlist": "Wishlist",
"groupComfortFeaturesYoutubeVideo": "YouTube video"
"groupComfortFeaturesYoutubeVideo": "YouTube video",
"groupMarketing": "Marketing",
"groupMarketingDescription": "Allows Google to collect personal data for online advertising and marketing.",
"groupMarketingAdConsent": "Google Advertising"
},
"ellipsis": {
"expandLabel": "show more",
Expand Down
18 changes: 18 additions & 0 deletions Resources/views/storefront/component/analytics.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,22 @@
</script>
{% endif %}
{% endblock %}

<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
(() => {
const analyticsStorageEnabled = document.cookie.split(';').some((item) => item.trim().includes('google-analytics-enabled=1'));
const adsEnabled = document.cookie.split(';').some((item) => item.trim().includes('google-ads-enabled=1'));
// Always set a default consent for consent mode v2
gtag('consent', 'default', {
'ad_user_data': adsEnabled ? 'granted' : 'denied',
'ad_storage': adsEnabled ? 'granted' : 'denied',
'ad_personalization': adsEnabled ? 'granted' : 'denied',
'analytics_storage': analyticsStorageEnabled ? 'granted' : 'denied'
});
})();
</script>
{% endblock %}

0 comments on commit 8767a98

Please sign in to comment.