diff --git a/app.vue b/app.vue index 0db4dd2b..259b50ae 100644 --- a/app.vue +++ b/app.vue @@ -19,6 +19,7 @@ diff --git a/composables/useStatefullCookie.ts b/composables/useStatefullCookie.ts index 504de511..1311dd39 100644 --- a/composables/useStatefullCookie.ts +++ b/composables/useStatefullCookie.ts @@ -1,4 +1,4 @@ -import { CookieOptions } from 'nuxt/app' +import type { CookieOptions } from 'nuxt/app' const COOKIES_OPTS: CookieOptions = { path: '/', @@ -11,9 +11,13 @@ const COOKIES_OPTS: CookieOptions = { * https://github.com/nuxt/nuxt/issues/13020 * Solution based on https://github.com/nuxt/nuxt/issues/13020#issuecomment-1397282738 */ -export const useStatefulCookie = (name: string, opts: CookieOptions = COOKIES_OPTS) => { +export const useStatefulCookie = ( + name: string, + opts: CookieOptions = COOKIES_OPTS, +) => { const key = `cookies:${name}` - const cookie = useCookie(name, opts) + // @ts-ignore + const cookie = useCookie(name, opts) const state = useState(key, () => cookie.value) if (process.client) diff --git a/consts/cookiesKeys.ts b/consts/cookiesKeys.ts index 5e0719a7..b55e6b24 100644 --- a/consts/cookiesKeys.ts +++ b/consts/cookiesKeys.ts @@ -1,3 +1,5 @@ +import type { CookieOptions } from 'nuxt/app' + /** * Names of the cookies used by the application. */ @@ -8,4 +10,15 @@ export const IDENTITY_TOKEN_KEY = 'h_identity_token' export const REFRESH_TOKEN_KEY = 'h_refresh_token' -export const COOKIE_ACCEPTED_KEY = 'h_cookies_accepted' +export const COOKIE_REQUIRED_ACCEPTED_KEY = 'h_cookies_required_accepted' + +export const COOKIE_FUNCTIONAL_ACCEPTED_KEY = 'h_cookies_functional_accepted' + +export const COOKIE_ANALYTICS_ACCEPTED_KEY = 'h_cookies_analytics_accepted' + +export const COOKIE_ADS_ACCEPTED_KEY = 'h_cookies_ads_accepted' + +export const COOKIES_CONFIG: CookieOptions = { + maxAge: 365 * 24 * 60 * 60, + path: '/', +} as const diff --git a/nuxt.config.ts b/nuxt.config.ts index e33174ad..243cfadc 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -12,6 +12,7 @@ const { APP_HOST, RECAPTCHA_PUBLIC, GOOGLE_TAG_MANAGER_ID, + GOOGLE_ANALYTICS_ID, CENEO_GUID, LEASLINK_ID, CALLPAGE_ID, @@ -92,6 +93,7 @@ export default defineNuxtConfig({ isProduction, recaptchaPublic: RECAPTCHA_PUBLIC, googleTagManagerId: GOOGLE_TAG_MANAGER_ID, + googleAnalyticsId: GOOGLE_ANALYTICS_ID, ceneoGuid: CENEO_GUID, leaslinkId: LEASLINK_ID, callpageId: CALLPAGE_ID, diff --git a/package.json b/package.json index 52dc3c64..f53f6623 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@directus/sdk": "^10.3.5", + "@gtm-support/vue-gtm": "^2.2.0", "@heseya/store-core": "5.2.0", "@kyvg/vue3-notification": "^3.0.2", "@pinia-plugin-persistedstate/nuxt": "1.1.2", @@ -32,8 +33,7 @@ "nodemailer": "^6.9.7", "nuxt-swiper": "^1.2.2", "swiper": "^10.3.1", - "vee-validate": "4.9.6", - "vue-gtag-next": "^1.14.0" + "vee-validate": "4.9.6" }, "devDependencies": { "@commitlint/cli": "^17.7.2", diff --git a/plugins/google-analytics.client.ts b/plugins/google-analytics.client.ts new file mode 100644 index 00000000..3457d95c --- /dev/null +++ b/plugins/google-analytics.client.ts @@ -0,0 +1,19 @@ +export default defineNuxtPlugin(() => { + const config = usePublicRuntimeConfig() + if (!config.googleAnalyticsId) return + + useHead({ + script: [ + { + hid: 'google-analytics-src', + defer: true, + src: `https://www.googletagmanager.com/gtag/js?id=${config.googleAnalyticsId}`, + }, + { + hid: 'google-analytics-init', + defer: true, + children: `window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', '${config.googleAnalyticsId}');`, + }, + ], + }) +}) diff --git a/plugins/vue-gtag.client.ts b/plugins/vue-gtag.client.ts index 31f80bb3..d05719f1 100644 --- a/plugins/vue-gtag.client.ts +++ b/plugins/vue-gtag.client.ts @@ -1,59 +1,132 @@ import { HeseyaEvent } from '@heseya/store-core' -import VueGtag, { trackRouter, isTracking, useGtag } from 'vue-gtag-next' -import { Pinia } from '@pinia/nuxt/dist/runtime/composables' +import { createGtm, useGtm } from '@gtm-support/vue-gtm' -import { mapCartItemToItem, mapProductToItem } from '~/utils/google' -import { useConfigStore } from '@/store/config' +import { + COOKIE_ADS_ACCEPTED_KEY, + COOKIE_ANALYTICS_ACCEPTED_KEY, + COOKIE_FUNCTIONAL_ACCEPTED_KEY, + COOKIES_CONFIG, +} from '@/consts/cookiesKeys' + +import { mapCartItemToItem, mapProductToItem } from '@/utils/google' export default defineNuxtPlugin((nuxtApp) => { const { googleTagManagerId, isProduction } = usePublicRuntimeConfig() if (!googleTagManagerId) return - nuxtApp.vueApp.use(VueGtag, { - property: { + nuxtApp.vueApp.use( + createGtm({ id: googleTagManagerId, - }, - useDebugger: !isProduction, - }) + defer: true, + debug: !isProduction, + vueRouter: useRouter() as any, + loadScript: true, + enabled: false, + }), + ) + + /** + * * TRACKING + */ + const gtm = useGtm() + const pushEventsQueue = useState('gtag-push-queue', () => []) + const trackEventsQueue = useState('gtag-track-queue', () => []) + + const push = (event: object) => { + if (gtm?.enabled()) gtm?.push(event) + else pushEventsQueue.value.push(event) + } + + const trackEvent = (event: object) => { + if (gtm?.enabled()) gtm?.trackEvent(event) + else trackEventsQueue.value.push(event) + } + + const enableGtm = () => { + if (gtm?.enabled()) return - trackRouter(useRouter()) + gtm?.enable() + + // Clear the queue + pushEventsQueue.value.forEach((event) => gtm?.push(event)) + trackEventsQueue.value.forEach((event) => gtm?.trackEvent(event)) + pushEventsQueue.value = [] + trackEventsQueue.value = [] + } /** - * * EVENTS + ** Sending consents */ - const { event: gTagEvent } = useGtag() - const config = useConfigStore(nuxtApp.$pinia as Pinia) - const bus = useHeseyaEventBus() + push([ + 'consent', + 'default', + { + functionality_storage: 'denied', + analytics_storage: 'denied', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + wait_for_update: 500, + }, + ]) + + const functionalCookie = useStatefulCookie(COOKIE_FUNCTIONAL_ACCEPTED_KEY, COOKIES_CONFIG) + const analyticsCookie = useStatefulCookie(COOKIE_ANALYTICS_ACCEPTED_KEY, COOKIES_CONFIG) + const adsCookie = useStatefulCookie(COOKIE_ADS_ACCEPTED_KEY, COOKIES_CONFIG) + + watch( + [functionalCookie, analyticsCookie, adsCookie], + () => { + const isUnset = [functionalCookie, analyticsCookie, adsCookie].every( + (c) => c.value === undefined, + ) + if (isUnset) return + + push([ + 'consent', + 'update', + { + functionality_storage: functionalCookie.value === 1 ? 'granted' : 'denied', + analytics_storage: analyticsCookie.value === 1 ? 'granted' : 'denied', + ad_storage: adsCookie.value === 1 ? 'granted' : 'denied', + ad_user_data: adsCookie.value === 1 ? 'granted' : 'denied', + ad_personalization: adsCookie.value === 1 ? 'granted' : 'denied', + }, + ]) + }, + { immediate: true }, + ) + /** + * * EVENTS + */ + const bus = useHeseyaEventBus() bus.on(HeseyaEvent.ViewProduct, (product) => { - if (!isTracking.value) return - - gTagEvent('', { ecommerce: null }) - gTagEvent('view_item', { + trackEvent({ ecommerce: null }) + trackEvent({ + event: 'view_item', ecommerce: { items: [mapProductToItem(product)] }, }) }) bus.on(HeseyaEvent.ViewProductList, ({ set, items }) => { - if (!isTracking.value) return - - gTagEvent('', { ecommerce: null }) - gTagEvent('view_item_list', { + trackEvent({ ecommerce: null }) + trackEvent({ + event: 'view_item_list', ecommerce: { item_list_name: set?.name, - items: items.map(mapProductToItem), + items: items.map((i) => mapProductToItem(i)), }, }) }) bus.on(HeseyaEvent.AddToCart, (item) => { - if (!isTracking.value) return - - gTagEvent('', { ecommerce: null }) - gTagEvent('add_to_cart', { + trackEvent({ ecommerce: null }) + trackEvent({ + event: 'add_to_cart', ecommerce: { - currency: config.currency, + currency: 'PLN', value: item.price, items: [mapCartItemToItem(item)], }, @@ -61,12 +134,11 @@ export default defineNuxtPlugin((nuxtApp) => { }) bus.on(HeseyaEvent.RemoveFromCart, (item) => { - if (!isTracking.value) return - - gTagEvent('', { ecommerce: null }) - gTagEvent('remove_from_cart', { + trackEvent({ ecommerce: null }) + trackEvent({ + event: 'remove_from_cart', ecommerce: { - currency: config.currency, + currency: 'PLN', value: item.price, items: [mapCartItemToItem(item)], }, @@ -74,12 +146,12 @@ export default defineNuxtPlugin((nuxtApp) => { }) bus.on(HeseyaEvent.AddShippingInfo, ({ shipping, items }) => { - if (!isTracking.value) return - - gTagEvent('', { ecommerce: null }) - gTagEvent('add_shipping_info', { + trackEvent({ ecommerce: null }) + trackEvent({ + event: 'add_shipping_info', ecommerce: { - currency: config.currency, + shipping_tier: shipping.name, + currency: 'PLN', value: shipping.price, items: items.map(mapCartItemToItem), }, @@ -87,36 +159,31 @@ export default defineNuxtPlugin((nuxtApp) => { }) bus.on(HeseyaEvent.InitiateCheckout, (items) => { - if (!isTracking.value) return - - gTagEvent('', { ecommerce: null }) - gTagEvent('begin_checkout', { + trackEvent({ ecommerce: null }) + trackEvent({ + event: 'begin_checkout', ecommerce: { items: items.map(mapCartItemToItem) }, }) }) bus.on(HeseyaEvent.Login, () => { - if (!isTracking.value) return - - gTagEvent('login', { + trackEvent({ + event: 'login', method: 'email', }) }) bus.on(HeseyaEvent.Register, () => { - if (!isTracking.value) return - - gTagEvent('sign_up', { + trackEvent({ + event: 'sign_up', method: 'email', }) }) bus.on(HeseyaEvent.Purchase, ({ order, items }) => { - if (!isTracking.value) return - - gTagEvent('', { ecommerce: null }) // TODO: add coupons? - gTagEvent('purchase', { + trackEvent({ + event: 'purchase', ecommerce: { transaction_id: order.code, affiliation: 'storefront', @@ -130,11 +197,16 @@ export default defineNuxtPlugin((nuxtApp) => { }) bus.on(HeseyaEvent.ViewCart, (items) => { - if (!isTracking.value) return - - gTagEvent('', { ecommerce: null }) - gTagEvent('view_cart', { + trackEvent({ ecommerce: null }) + trackEvent({ + event: 'view_cart', ecommerce: { items: items.map(mapCartItemToItem) }, }) }) + + return { + provide: { + enableGtm, + }, + } }) diff --git a/yarn.lock b/yarn.lock index 3e038956..a03c9204 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1001,6 +1001,20 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== +"@gtm-support/core@^2.0.0": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@gtm-support/core/-/core-2.3.1.tgz#e802f1d60256583d468cf4ccef391f85e33c766d" + integrity sha512-eD0hndQjhgKm5f/7IA9fZYujmHiVMY+fnYv4mdZSmz5XJQlS4TiTmpdZx2l7I2A9rI9J6Ysz8LpXYYNo/Xq4LQ== + +"@gtm-support/vue-gtm@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@gtm-support/vue-gtm/-/vue-gtm-2.2.0.tgz#b6b17da2f27ff186aae731c8c32fdcb8a6738f25" + integrity sha512-7nhBTRkTG0mD+7r7JvNalJz++YwszZk0oP1HIY6fCgz6wNKxT6LuiXCqdPrZmNPe/WbPIKuqxGZN5s+i6NZqow== + dependencies: + "@gtm-support/core" "^2.0.0" + optionalDependencies: + vue-router ">= 4.1.0 < 5.0.0" + "@heseya/store-core@5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@heseya/store-core/-/store-core-5.2.0.tgz#65cdcbf8060b39cbdac22bf6a5b720184bdf32ef" @@ -2745,6 +2759,11 @@ resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.5.0.tgz#98b99425edee70b4c992692628fa1ea2c1e57d07" integrity sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q== +"@vue/devtools-api@^6.5.1": + version "6.6.1" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz#7c14346383751d9f6ad4bea0963245b30220ef83" + integrity sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA== + "@vue/language-core@1.8.20": version "1.8.20" resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-1.8.20.tgz#f7f83bad3f6a52f5d006b874630dd424233f1e08" @@ -9030,7 +9049,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9089,7 +9117,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9938,11 +9973,6 @@ vue-eslint-parser@^9.3.1: lodash "^4.17.21" semver "^7.3.6" -vue-gtag-next@^1.14.0: - version "1.14.0" - resolved "https://registry.yarnpkg.com/vue-gtag-next/-/vue-gtag-next-1.14.0.tgz#793aef0b90dff4213b9f3a79827cd99b06e678dd" - integrity sha512-iJl+cOG2GU5NuxqzSSIpt03WVOvZqyKB9TOy7d55KiuvRklcnb2nlqxW5B/a3/sbIt7fla+XEkRyMCcoz0zAHw== - vue-i18n-routing@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/vue-i18n-routing/-/vue-i18n-routing-1.1.1.tgz#5fbf100807e1bc6a7f7bcb7c11c4ccee5fffeee2" @@ -9963,6 +9993,13 @@ vue-i18n@9.5.0: "@intlify/shared" "9.5.0" "@vue/devtools-api" "^6.5.0" +"vue-router@>= 4.1.0 < 5.0.0": + version "4.3.2" + resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.3.2.tgz#08096c7765dacc6832f58e35f7a081a8b34116a7" + integrity sha512-hKQJ1vDAZ5LVkKEnHhmm1f9pMiWIBNGF5AwU67PdH7TyXCj/a4hTccuUuYCAMgJK6rO/NVYtQIEN3yL8CECa7Q== + dependencies: + "@vue/devtools-api" "^6.5.1" + vue-router@^4.2.5: version "4.2.5" resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.2.5.tgz#b9e3e08f1bd9ea363fdd173032620bc50cf0e98a" @@ -10086,7 +10123,16 @@ wide-align@^1.1.2, wide-align@^1.1.5: dependencies: string-width "^1.0.2 || 2 || 3 || 4" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==