diff --git a/client-app/app-runner.ts b/client-app/app-runner.ts index 5612773dd3..2166999c90 100644 --- a/client-app/app-runner.ts +++ b/client-app/app-runner.ts @@ -3,7 +3,7 @@ import { DefaultApolloClient } from "@vue/apollo-composable"; import { createApp, h, provide } from "vue"; import { getEpParam, isPreviewMode as isPageBuilderPreviewMode } from "@/builder-preview/utils"; import { apolloClient, getStore } from "@/core/api/graphql"; -import { useCurrency, useThemeContext, useGoogleAnalytics, useWhiteLabeling, useNavigations } from "@/core/composables"; +import { useCurrency, useThemeContext, useWhiteLabeling, useNavigations } from "@/core/composables"; import { useHotjar } from "@/core/composables/useHotjar"; import { useLanguages } from "@/core/composables/useLanguages"; import { FALLBACK_LOCALE, IS_DEVELOPMENT } from "@/core/constants"; @@ -12,6 +12,7 @@ import { applicationInsightsPlugin, authPlugin, configPlugin, contextPlugin, per import { extractHostname, getBaseUrl, Logger } from "@/core/utilities"; import { createI18n } from "@/i18n"; import { init as initCustomerReviews } from "@/modules/customer-reviews"; +import { init as initializeGoogleAnalytics } from "@/modules/google-analytics"; import { initialize as initializePurchaseRequests } from "@/modules/purchase-requests"; import { init as initPushNotifications } from "@/modules/push-messages"; import { init as initModuleQuotes } from "@/modules/quotes"; @@ -66,7 +67,6 @@ export default async () => { mergeLocales, } = useLanguages(); const { currentCurrency } = useCurrency(); - const { init: initializeGoogleAnalytics } = useGoogleAnalytics(); const { init: initializeHotjar } = useHotjar(); const { fetchMenus } = useNavigations(); const { themePresetName, fetchWhiteLabelingSettings } = useWhiteLabeling(); @@ -90,9 +90,6 @@ export default async () => { await Promise.all([fetchThemeContext(store), fetchUser(), fallback.setMessage()]); - void initializeGoogleAnalytics(); - void initializeHotjar(); - // priority rule: pinedLocale > contactLocale > urlLocale > storeLocale const twoLetterAppLocale = detectLocale([ pinedLocale.value, @@ -136,6 +133,8 @@ export default async () => { void initModuleQuotes(router, i18n); void initCustomerReviews(i18n); void initializePurchaseRequests(router, i18n); + void initializeGoogleAnalytics(); + void initializeHotjar(); if (themePresetName.value) { await fetchThemeContext(store, themePresetName.value); diff --git a/client-app/core/composables/index.ts b/client-app/core/composables/index.ts index cf5d1d9ada..25d512c8ca 100644 --- a/client-app/core/composables/index.ts +++ b/client-app/core/composables/index.ts @@ -1,10 +1,10 @@ +export * from "./useAnalytics"; export * from "./useAuth"; export * from "./useBreadcrumbs"; export * from "./useCategoriesRoutes"; export * from "./useCountries"; export * from "./useCurrency"; export * from "./useErrorsTranslator"; -export * from "./useGoogleAnalytics"; export * from "./useHistoricalEvents"; export * from "./useImpersonate"; export * from "./useMutationBatcher"; diff --git a/client-app/core/composables/useAnalytics.test.ts b/client-app/core/composables/useAnalytics.test.ts new file mode 100644 index 0000000000..fe158c7737 --- /dev/null +++ b/client-app/core/composables/useAnalytics.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Logger } from "@/core/utilities"; +import type { CustomerOrderType, LineItemType, Product } from "../api/graphql/types"; +import type { useAnalytics as useAnalyticsType } from "@/core/composables/useAnalytics"; +import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "@/core/types/analytics"; + +vi.mock("@/core/utilities/logger", () => ({ + Logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/core/constants", () => ({ + IS_DEVELOPMENT: false, +})); + +const mockedProduct = { + id: "123", +} as Product; + +const mockedCustomerOrder = { + id: "123", +} as CustomerOrderType; + +const arbitraryParam = { someParam: "value" }; + +describe("useAnalytics", () => { + let analyticsInstance: ReturnType; + let addTracker: ReturnType["addTracker"]; + let analytics: ReturnType["analytics"]; + let mockTracker1: TackerType; + let mockTracker2: TackerType; + + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("@/core/constants"); + vi.clearAllMocks(); + + const { useAnalytics } = await import("@/core/composables/useAnalytics"); + + analyticsInstance = useAnalytics(); + addTracker = analyticsInstance.addTracker; + analytics = analyticsInstance.analytics; + + mockTracker1 = { + viewItemList: vi.fn(), + selectItem: vi.fn(), + viewItem: vi.fn(), + addItemToWishList: vi.fn(), + addItemToCart: vi.fn(), + addItemsToCart: vi.fn(), + removeItemsFromCart: vi.fn(), + viewCart: vi.fn(), + clearCart: vi.fn(), + beginCheckout: vi.fn(), + addShippingInfo: vi.fn(), + addPaymentInfo: vi.fn(), + purchase: vi.fn(), + placeOrder: vi.fn(), + search: vi.fn(), + }; + + mockTracker2 = { + viewItemList: vi.fn(), + selectItem: vi.fn(), + viewItem: vi.fn(), + addItemToWishList: vi.fn(), + addItemToCart: vi.fn(), + addItemsToCart: vi.fn(), + removeItemsFromCart: vi.fn(), + viewCart: vi.fn(), + clearCart: vi.fn(), + beginCheckout: vi.fn(), + addShippingInfo: vi.fn(), + addPaymentInfo: vi.fn(), + purchase: vi.fn(), + placeOrder: vi.fn(), + search: vi.fn(), + }; + }); + + it("should dispatch events to a single tracker", () => { + addTracker(mockTracker1); + + const event: AnalyticsEventNameType = "viewItemList"; + const args: IAnalyticsEventMap["viewItemList"] = [[{ code: "item1" }], arbitraryParam]; + + analytics(event, ...args); + + expect(mockTracker1.viewItemList).toHaveBeenCalledWith(...args); + expect(Logger.debug).not.toHaveBeenCalled(); + expect(Logger.warn).not.toHaveBeenCalled(); + }); + + it("should dispatch events to multiple trackers", () => { + addTracker(mockTracker1); + addTracker(mockTracker2); + + const event: AnalyticsEventNameType = "selectItem"; + const args: IAnalyticsEventMap["selectItem"] = [{ productId: "123" } as LineItemType, arbitraryParam]; + + analytics(event, ...args); + + expect(mockTracker1.selectItem).toHaveBeenCalledWith(...args); + expect(mockTracker2.selectItem).toHaveBeenCalledWith(...args); + expect(Logger.debug).not.toHaveBeenCalled(); + expect(Logger.warn).not.toHaveBeenCalled(); + }); + + it("should handle multiple dispatches of the same event", () => { + addTracker(mockTracker1); + + const event: AnalyticsEventNameType = "viewItem"; + const args1: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value1" }]; + const args2: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value2" }]; + + analytics(event, ...args1); + analytics(event, ...args2); + + expect(mockTracker1.viewItem).toHaveBeenCalledTimes(2); + expect(mockTracker1.viewItem).toHaveBeenCalledWith(...args1); + expect(mockTracker1.viewItem).toHaveBeenCalledWith(...args2); + expect(Logger.debug).not.toHaveBeenCalled(); + expect(Logger.warn).not.toHaveBeenCalled(); + }); + + it("should log a warning if a tracker does not handle the event", () => { + addTracker(mockTracker1); + + delete mockTracker1.purchase; + + const event: AnalyticsEventNameType = "purchase"; + const args: IAnalyticsEventMap["purchase"] = [mockedCustomerOrder, "txn123", arbitraryParam]; + + analytics(event, ...args); + + expect(mockTracker1.purchase).toBeUndefined(); + expect(Logger.warn).toHaveBeenCalledWith('useAnalytics, unsupported event: "purchase" in tracker.'); + }); + + it("should handle adding the same tracker multiple times", () => { + addTracker(mockTracker1); + addTracker(mockTracker1); + + const event: AnalyticsEventNameType = "search"; + const args: IAnalyticsEventMap["search"] = ["query", [{ code: "item1" }], 1]; + + analytics(event, ...args); + + expect(mockTracker1.search).toHaveBeenCalledTimes(1); + expect(mockTracker1.search).toHaveBeenCalledWith(...args); + }); + + it("should handle trackers with partial event support gracefully", () => { + const partialTracker: TackerType = { + viewItem: vi.fn(), + search: vi.fn(), + }; + + addTracker(partialTracker); + + const event1: AnalyticsEventNameType = "viewItem"; + const args1: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value1" }]; + + analytics(event1, ...args1); + + expect(partialTracker.viewItem).toHaveBeenCalledWith(...args1); + expect(Logger.warn).not.toHaveBeenCalled(); + + const event2: AnalyticsEventNameType = "purchase"; + const args2: IAnalyticsEventMap["purchase"] = [mockedCustomerOrder, "txn123", { someParam: "value2" }]; + + analytics(event2, ...args2); + + expect(partialTracker.purchase).toBeUndefined(); + expect(Logger.warn).toHaveBeenCalledWith('useAnalytics, unsupported event: "purchase" in tracker.'); + }); + + it("should continue dispatching events even if one tracker throws an error", () => { + const faultyTracker: TackerType = { + viewItem: vi.fn(() => { + throw new Error("Tracker error"); + }), + }; + + const normalTracker: TackerType = { + viewItem: vi.fn(), + }; + + addTracker(faultyTracker); + addTracker(normalTracker); + + const event: AnalyticsEventNameType = "viewItem"; + const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam]; + + const loggerErrorSpy = vi.spyOn(Logger, "error"); + + analytics(event, ...args); + + expect(faultyTracker.viewItem).toHaveBeenCalledWith(...args); + expect(normalTracker.viewItem).toHaveBeenCalledWith(...args); + expect(loggerErrorSpy).toHaveBeenCalled(); + }); + + it("should not dispatch events and not log warnings when no trackers are added", () => { + const event: AnalyticsEventNameType = "viewItem"; + const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam]; + + analytics(event, ...args); + + expect(mockTracker1.viewItem).not.toHaveBeenCalled(); + expect(Logger.warn).not.toHaveBeenCalled(); + expect(Logger.debug).not.toHaveBeenCalled(); + }); + + it("should handle a high volume of events and multiple trackers without issues", () => { + const numTrackers = 10; + const trackers: TackerType[] = Array.from({ length: numTrackers }, () => ({ + viewItem: vi.fn(), + search: vi.fn(), + })); + + trackers.forEach(addTracker); + + const numEvents = 100; + for (let i = 0; i < numEvents; i++) { + const event: AnalyticsEventNameType = "viewItem"; + const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: `value${i}` }]; + analytics(event, ...args); + } + + trackers.forEach((tracker) => { + expect(tracker.viewItem).toHaveBeenCalledTimes(numEvents); + for (let i = 0; i < numEvents; i++) { + expect(tracker.viewItem).toHaveBeenNthCalledWith(i + 1, mockedProduct, { someParam: `value${i}` }); + } + }); + + expect(Logger.warn).not.toHaveBeenCalled(); + expect(Logger.debug).not.toHaveBeenCalled(); + }); + + it("should not dispatch events and log debug in development mode", async () => { + vi.resetModules(); + vi.doUnmock("@/core/constants"); + vi.doMock("@/core/constants", () => ({ + IS_DEVELOPMENT: true, + })); + + const { useAnalytics: useAnalyticsDev } = await import("@/core/composables/useAnalytics"); + const { addTracker: addTrackerDev, analytics: analyticsDev } = useAnalyticsDev(); + + addTrackerDev(mockTracker1); + + const event: AnalyticsEventNameType = "addItemToCart"; + const args: IAnalyticsEventMap["addItemToCart"] = [mockedProduct, 2, arbitraryParam]; + + analyticsDev(event, ...args); + + expect(mockTracker1.addItemToCart).not.toHaveBeenCalled(); + expect(Logger.debug).toHaveBeenCalledWith("useAnalytics, can't track event in development mode"); + expect(Logger.warn).not.toHaveBeenCalled(); + }); + + it("should not dispatch events and not log warnings when no trackers are added", () => { + const event: AnalyticsEventNameType = "viewItem"; + const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam]; + + analytics(event, ...args); + + expect(mockTracker1.viewItem).not.toHaveBeenCalled(); + expect(Logger.warn).not.toHaveBeenCalled(); + expect(Logger.debug).not.toHaveBeenCalled(); + }); +}); diff --git a/client-app/core/composables/useAnalytics.ts b/client-app/core/composables/useAnalytics.ts new file mode 100644 index 0000000000..3cce972177 --- /dev/null +++ b/client-app/core/composables/useAnalytics.ts @@ -0,0 +1,38 @@ +import { createGlobalState } from "@vueuse/core"; +import { IS_DEVELOPMENT } from "@/core/constants"; +import { Logger } from "@/core/utilities/logger"; +import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "../types/analytics"; + +function _useAnalytics() { + const trackers: Set = new Set(); + + function addTracker(tracker: TackerType): void { + trackers.add(tracker); + } + + function analytics(event: E, ...args: IAnalyticsEventMap[E]): void { + if (IS_DEVELOPMENT) { + Logger.debug("useAnalytics, can't track event in development mode"); + return; + } + trackers.forEach((tracker) => { + const handler = tracker[event]; + if (handler) { + try { + handler(...args); + } catch (error) { + Logger.error(`useAnalytics, error calling event: "${event}" in tracker.`, error); + } + } else { + Logger.warn(`useAnalytics, unsupported event: "${event}" in tracker.`); + } + }); + } + + return { + addTracker, + analytics, + }; +} + +export const useAnalytics = createGlobalState(_useAnalytics); diff --git a/client-app/core/composables/useGoogleAnalytics.ts b/client-app/core/composables/useGoogleAnalytics.ts deleted file mode 100644 index 83626baaee..0000000000 --- a/client-app/core/composables/useGoogleAnalytics.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useScriptTag } from "@vueuse/core"; -import { useCurrency } from "@/core/composables/useCurrency"; -import { useModuleSettings } from "@/core/composables/useModuleSettings"; -import { IS_DEVELOPMENT } from "@/core/constants"; -import { globals } from "@/core/globals"; -import { Logger } from "@/core/utilities"; - -const MODULE_ID = "VirtoCommerce.GoogleEcommerceAnalytics"; -const IS_ENABLED_KEY = "GoogleAnalytics4.EnableTracking"; - -const { getModuleSettings, hasModuleSettings, isEnabled } = useModuleSettings(MODULE_ID); - -const { currentCurrency } = useCurrency(); -const { currencyCode } = globals; - -type GoogleAnalyticsMethodsType = Omit< - ReturnType, - "initModule" ->; -type MethodNamesType = keyof GoogleAnalyticsMethodsType; -type MethodArgsType = unknown[]; -type MethodQueueEntryType = { - method: MethodNamesType; - args: MethodArgsType; -}; - -// needs to queue methods until the module is initialized -const methodsQueue: Array = []; - -let googleAnalyticsMethods: GoogleAnalyticsMethodsType = new Proxy({} as GoogleAnalyticsMethodsType, { - get(target, prop) { - if (prop !== "init") { - return (...args: MethodArgsType) => { - methodsQueue.push({ method: prop as MethodNamesType, args }); - }; - } - return Reflect.get(target, prop) as { init: () => Promise }; - }, -}); - -export function useGoogleAnalytics() { - async function init(): Promise { - if (hasModuleSettings && isEnabled(IS_ENABLED_KEY)) { - try { - const { useGoogleAnalyticsModule } = await import("@virto-commerce/front-modules-google-ecommerce-analytics"); - const { initModule, ...methods } = useGoogleAnalyticsModule(); - - initModule({ - getModuleSettings, - isDevelopment: IS_DEVELOPMENT, - logger: Logger, - useScriptTag, - currentCurrency, - currencyCode, - }); - googleAnalyticsMethods = methods; - - // it there are any methods in the queue, execute them, then clear the queue - if (methodsQueue.length) { - methodsQueue.forEach(({ method, args }) => { - (googleAnalyticsMethods[method] as (...args: MethodArgsType) => void)(...args); - }); - methodsQueue.length = 0; - } - } catch (e) { - Logger.error(useGoogleAnalytics.name, e); - } - } - } - - return Object.assign(googleAnalyticsMethods, { init }); -} diff --git a/client-app/core/types/analytics.ts b/client-app/core/types/analytics.ts new file mode 100644 index 0000000000..97b5c9b1b7 --- /dev/null +++ b/client-app/core/types/analytics.ts @@ -0,0 +1,29 @@ +import type { CartType, CustomerOrderType, LineItemType, Product, VariationType } from "@/core/api/graphql/types"; + +export interface IAnalyticsEventMap { + viewItemList: [items: { code: string }[], params?: EventParamsType & ViewItemListParamsAdditionalType]; + selectItem: [item: Product | LineItemType, params?: EventParamsType]; + viewItem: [item: Product, params?: EventParamsType]; + addItemToWishList: [item: Product, params?: EventParamsType]; + addItemToCart: [item: Product | VariationType, quantity?: number, params?: EventParamsType]; + addItemsToCart: [items: (Product | VariationType)[], params?: EventParamsType]; + removeItemsFromCart: [items: LineItemType[], params?: EventParamsType]; + viewCart: [cart: CartType, params?: EventParamsType]; + clearCart: [cart: CartType, params?: EventParamsType]; + beginCheckout: [cart: CartType, params?: EventParamsType]; + addShippingInfo: [cart?: CartType, params?: EventParamsType, shipmentMethodOption?: string]; + addPaymentInfo: [cart?: CartType, params?: EventParamsType, paymentGatewayCode?: string]; + purchase: [order: CustomerOrderType, transactionId?: string, params?: EventParamsType]; + placeOrder: [order: CustomerOrderType, params?: EventParamsType]; + search: [searchTerm: string, visibleItems?: { code: string }[], itemsCount?: number]; +} + +export type AnalyticsEventNameType = keyof IAnalyticsEventMap; + +export type ViewItemListParamsAdditionalType = { item_list_id?: string; item_list_name?: string }; + +export type EventParamsType = Record; + +export type TackerType = Partial<{ + [K in AnalyticsEventNameType]: (...args: IAnalyticsEventMap[K]) => void | Promise; +}>; diff --git a/client-app/modules/google-analytics/constants.ts b/client-app/modules/google-analytics/constants.ts new file mode 100644 index 0000000000..3ba75de3a1 --- /dev/null +++ b/client-app/modules/google-analytics/constants.ts @@ -0,0 +1,10 @@ +export const DEBUG_PREFIX = "[GA]"; + +export const MODULE_ID = "VirtoCommerce.GoogleEcommerceAnalytics"; + +export const GOOGLE_ANALYTICS_SETTINGS_MAPPING = { + "GoogleAnalytics4.MeasurementId": "trackId", + "GoogleAnalytics4.EnableTracking": "isEnabled", +} as const; + +export const canUseDOM = !!(typeof window !== "undefined" && window.document?.createElement); diff --git a/client-app/modules/google-analytics/events.ts b/client-app/modules/google-analytics/events.ts new file mode 100644 index 0000000000..d941beb70b --- /dev/null +++ b/client-app/modules/google-analytics/events.ts @@ -0,0 +1,194 @@ +import { sumBy } from "lodash"; +import { globals } from "@/core/globals"; +import { Logger } from "@/core/utilities"; +import { DEBUG_PREFIX } from "./constants"; +import { lineItemToGtagItem, productToGtagItem, sendEvent } from "./utils"; +import type { TackerType } from "@/core/types/analytics"; + +const { currencyCode } = globals; + +export const analytics: TackerType = { + viewItemList(items, params) { + sendEvent("view_item_list", { + ...params, + items_skus: (items ?? []) + .map((el) => el.code) + .join(", ") + .trim(), + items_count: items?.length ?? 0, + }); + }, + + selectItem(item, params) { + const gtagItem = "productId" in item ? lineItemToGtagItem(item) : productToGtagItem(item); + sendEvent("select_item", { + ...params, + items: [gtagItem], + }); + }, + + viewItem(item, params) { + sendEvent("view_item", { + ...params, + currency: currencyCode, + value: item.price?.actual?.amount, + items: [productToGtagItem(item)], + }); + }, + + addItemToWishList(item, params) { + sendEvent("add_to_wishlist", { + ...params, + currency: currencyCode, + value: item.price?.actual?.amount, + items: [productToGtagItem(item)], + }); + }, + + addItemToCart(item, quantity, params) { + const inputItem = productToGtagItem(item); + inputItem.quantity = quantity; + sendEvent("add_to_cart", { + ...params, + currency: currencyCode, + value: (item.price?.actual?.amount || 0) * (quantity ?? 1), + items: [inputItem], + }); + }, + + addItemsToCart(items, params) { + const subtotal: number = sumBy(items, (item) => item?.price?.actual?.amount); + const inputItems = items.filter(Boolean).map((item) => productToGtagItem(item)); + sendEvent("add_to_cart", { + ...params, + currency: currencyCode, + value: subtotal, + items: inputItems, + items_count: inputItems.length, + }); + }, + + removeItemsFromCart(items, params) { + const subtotal: number = sumBy(items, (item) => item.extendedPrice?.amount); + const inputItems = items.map(lineItemToGtagItem); + sendEvent("remove_from_cart", { + ...params, + currency: currencyCode, + value: subtotal, + items: inputItems, + items_count: inputItems.length, + }); + }, + + viewCart(cart, params) { + sendEvent("view_cart", { + ...params, + currency: currencyCode, + value: cart.total.amount, + items: cart.items.map(lineItemToGtagItem), + items_count: cart.items.length, + }); + }, + + clearCart(cart, params) { + sendEvent("clear_cart", { + ...params, + currency: currencyCode, + value: cart.total.amount, + items: cart.items.map(lineItemToGtagItem), + items_count: cart.items.length, + }); + }, + + beginCheckout(cart, params) { + try { + sendEvent("begin_checkout", { + ...params, + currency: cart.currency.code, + value: cart.total.amount, + items: cart.items.map(lineItemToGtagItem), + items_count: cart.items.length, + coupon: cart.coupons?.[0]?.code, + }); + } catch (e) { + Logger.error(DEBUG_PREFIX, "beginCheckout", e); + } + }, + + addShippingInfo(cart, params, shipmentMethodOption) { + try { + sendEvent("add_shipping_info", { + ...params, + shipping_tier: shipmentMethodOption, + currency: cart?.shippingPrice.currency.code, + value: cart?.shippingPrice.amount, + coupon: cart?.coupons?.[0]?.code, + items: cart?.items.map(lineItemToGtagItem), + items_count: cart?.items.length, + }); + } catch (e) { + Logger.error(DEBUG_PREFIX, "addShippingInfo", e); + } + }, + + addPaymentInfo(cart, params, paymentGatewayCode) { + try { + sendEvent("add_payment_info", { + ...params, + payment_type: paymentGatewayCode, + currency: cart?.currency?.code, + value: cart?.total?.amount, + coupon: cart?.coupons?.[0]?.code, + items: cart?.items.map(lineItemToGtagItem), + items_count: cart?.items.length, + }); + } catch (e) { + Logger.error(DEBUG_PREFIX, "addPaymentInfo", e); + } + }, + + purchase(order, transactionId, params) { + try { + sendEvent("purchase", { + ...params, + currency: order.currency?.code, + transaction_id: transactionId, + value: order.total.amount, + coupon: order.coupons?.[0], + shipping: order.shippingTotal?.amount, + tax: order.taxTotal?.amount, + items: order.items.map(lineItemToGtagItem), + items_count: order?.items?.length, + }); + } catch (e) { + Logger.error(DEBUG_PREFIX, "purchase", e); + } + }, + + placeOrder(order, params) { + try { + sendEvent("place_order", { + ...params, + currency: order.currency?.code, + value: order.total?.amount, + coupon: order.coupons?.[0], + shipping: order.shippingTotal.amount, + tax: order.taxTotal.amount, + items_count: order.items?.length, + }); + } catch (e) { + Logger.error(DEBUG_PREFIX, "placeOrder", e); + } + }, + + search(searchTerm, visibleItems = [], itemsCount = 0) { + sendEvent("search", { + search_term: searchTerm, + items_count: itemsCount, + visible_items: visibleItems + .map((el) => el.code) + .join(", ") + .trim(), + }); + }, +}; diff --git a/client-app/modules/google-analytics/index.ts b/client-app/modules/google-analytics/index.ts new file mode 100644 index 0000000000..c5fcf35a52 --- /dev/null +++ b/client-app/modules/google-analytics/index.ts @@ -0,0 +1,33 @@ +import { useScriptTag } from "@vueuse/core"; +import { useCurrency } from "@/core/composables"; +import { useAnalytics } from "@/core/composables/useAnalytics"; +import { useModuleSettings } from "@/core/composables/useModuleSettings"; +import { IS_DEVELOPMENT } from "@/core/constants"; +import { MODULE_ID, GOOGLE_ANALYTICS_SETTINGS_MAPPING } from "./constants"; + +const { currentCurrency } = useCurrency(); + +const canUseDOM = !!(typeof window !== "undefined" && window.document?.createElement); + +export async function init(): Promise { + const { getModuleSettings, hasModuleSettings } = useModuleSettings(MODULE_ID); + const { trackId, isEnabled } = getModuleSettings(GOOGLE_ANALYTICS_SETTINGS_MAPPING); + + if (!canUseDOM || !trackId || !hasModuleSettings || !isEnabled || IS_DEVELOPMENT) { + return; + } + useScriptTag(`https://www.googletagmanager.com/gtag/js?id=${trackId}`); + const { addTracker } = useAnalytics(); + const tracker = await import("./events"); + addTracker(tracker.analytics); + window.dataLayer = window.dataLayer || []; + window.gtag = function gtag() { + // is not working with rest + // eslint-disable-next-line prefer-rest-params + window.dataLayer.push(arguments); + }; + + window.gtag("js", new Date()); + window.gtag("config", String(trackId), { debug_mode: true }); + window.gtag("set", { currency: currentCurrency.value.code }); +} diff --git a/client-app/modules/google-analytics/types.ts b/client-app/modules/google-analytics/types.ts new file mode 100644 index 0000000000..ca5dfe8990 --- /dev/null +++ b/client-app/modules/google-analytics/types.ts @@ -0,0 +1,9 @@ +export type CustomEventNamesType = "place_order" | "clear_cart"; +export type EventParamsType = Gtag.ControlParams & Gtag.EventParams & Gtag.CustomParams; + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Window { + dataLayer: Array; + } +} diff --git a/client-app/modules/google-analytics/utils.ts b/client-app/modules/google-analytics/utils.ts new file mode 100644 index 0000000000..bc80d45e45 --- /dev/null +++ b/client-app/modules/google-analytics/utils.ts @@ -0,0 +1,78 @@ +import { Logger } from "@/core/utilities"; +import { canUseDOM, DEBUG_PREFIX } from "./constants"; +import type { CustomEventNamesType, EventParamsType } from "./types"; +import type { + Breadcrumb, + LineItemType, + OrderLineItemType, + Product, + VariationType, + DiscountType, + OrderDiscountType, +} from "@/core/api/graphql/types"; + +export function sendEvent(eventName: Gtag.EventNames | CustomEventNamesType, eventParams?: EventParamsType): void { + if (canUseDOM && window.gtag) { + window.gtag("event", eventName, eventParams); + } else { + Logger.debug(DEBUG_PREFIX, eventName, eventParams); + } +} + +export function productToGtagItem(item: Product | VariationType, index?: number): Gtag.Item { + const categories: Record = "breadcrumbs" in item ? getCategories(item.breadcrumbs) : {}; + + return { + index, + item_id: item.code, + item_name: item.name, + affiliation: item.vendor?.name, + price: item.price?.list?.amount, + discount: item.price?.discountAmount?.amount, + quantity: item.availabilityData?.availableQuantity, + ...categories, + }; +} + +export function lineItemToGtagItem( + item: LineItemType | OrderLineItemType, + index?: number, +): Gtag.Item & { promotions: string } { + const categories: Record = getCategories(item.product?.breadcrumbs); + + return { + index, + item_id: item.sku, + item_name: item.name, + affiliation: item.vendor?.name ?? "?", + currency: item.placedPrice.currency.code, + discount: item.discountAmount?.amount || item.discountTotal?.amount, + price: "price" in item ? item.price.amount : item.listPrice.amount, + quantity: item.quantity, + promotion_id: item.discounts?.[0]?.promotionId, + promotion_name: + item.discounts && "promotionName" in item.discounts[0] ? item.discounts?.[0]?.promotionName : undefined, + promotions: item.discounts + ?.map((promotion: DiscountType | OrderDiscountType) => + "promotionName" in promotion ? promotion.promotionName : undefined, + ) + .filter(Boolean) + .join(", ") + .trim(), + ...categories, + }; +} + +export function getCategories(breadcrumbs: Breadcrumb[] = []): Record { + const categories: Record = {}; + + breadcrumbs + .filter((breadcrumb) => breadcrumb.typeName !== "CatalogProduct") + .slice(0, 5) // first five, according to the documentation + .forEach((breadcrumb, i) => { + const number = i + 1; + categories[`item_category${number > 1 ? number : ""}`] = breadcrumb.title; + }); + + return categories; +} diff --git a/client-app/pages/account/list-details.vue b/client-app/pages/account/list-details.vue index 64ae4b486d..e1daa11564 100644 --- a/client-app/pages/account/list-details.vue +++ b/client-app/pages/account/list-details.vue @@ -111,7 +111,7 @@ import { cloneDeep, isEqual, keyBy, pick } from "lodash"; import { computed, ref, watchEffect, defineAsyncComponent } from "vue"; import { useI18n } from "vue-i18n"; import { onBeforeRouteLeave, onBeforeRouteUpdate } from "vue-router"; -import { useGoogleAnalytics, useHistoricalEvents, usePageHead } from "@/core/composables"; +import { useAnalytics, useHistoricalEvents, usePageHead } from "@/core/composables"; import { PAGE_LIMIT } from "@/core/constants"; import { globals } from "@/core/globals"; import { prepareLineItem } from "@/core/utilities"; @@ -146,7 +146,7 @@ const props = defineProps(); const Error404 = defineAsyncComponent(() => import("@/pages/404.vue")); const { t } = useI18n(); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const broadcast = useBroadcast(); const { openModal } = useModal(); const { listLoading, list, fetchWishList, updateItemsInWishlist } = useWishlists(); @@ -207,7 +207,7 @@ async function addAllListItemsToCart(): Promise { await addItemsToCart(items); const products = wishlistItems.value.map((item) => item.product!); - ga.addItemsToCart(products); + analytics("addItemsToCart", products); void pushHistoricalEvent({ eventType: "addToCart", sessionId: cart.value?.id, @@ -289,7 +289,7 @@ async function addOrUpdateCartItem(item: PreparedLineItemType, quantity: number) } else { await addToCart(lineItem.product.id, quantity); - ga.addItemToCart(lineItem.product, quantity); + analytics("addItemToCart", lineItem.product, quantity); void pushHistoricalEvent({ eventType: "addToCart", sessionId: cart.value?.id, @@ -357,7 +357,7 @@ watchEffect(() => { .filter(Boolean); if (items?.length) { - ga.viewItemList(items, { + analytics("viewItemList", items, { item_list_name: `Wishlist "${list.value?.name}"`, }); } diff --git a/client-app/pages/cart.vue b/client-app/pages/cart.vue index 716f13e53f..e25ee6d694 100644 --- a/client-app/pages/cart.vue +++ b/client-app/pages/cart.vue @@ -155,7 +155,7 @@ import { computed, inject, ref } from "vue"; import { useI18n } from "vue-i18n"; import { recentlyBrowsed } from "@/core/api/graphql"; -import { useBreadcrumbs, useGoogleAnalytics, usePageHead } from "@/core/composables"; +import { useBreadcrumbs, useAnalytics, usePageHead } from "@/core/composables"; import { useModuleSettings } from "@/core/composables/useModuleSettings"; import { MODULE_ID_XRECOMMEND, XRECOMMEND_ENABLED_KEY } from "@/core/constants/modules"; import { configInjectionKey } from "@/core/injection-keys"; @@ -178,7 +178,7 @@ import RecentlyBrowsedProducts from "@/shared/catalog/components/recently-browse const config = inject(configInjectionKey, {}); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const { t } = useI18n(); const { isAuthenticated } = useUser(); const { @@ -230,7 +230,10 @@ async function handleRemoveItems(itemIds: string[]): Promise { /** * Send Google Analytics event for an item was removed from cart. */ - ga.removeItemsFromCart(cart.value!.items!.filter((item) => itemIds.includes(item.id))); + analytics( + "removeItemsFromCart", + cart.value!.items!.filter((item) => itemIds.includes(item.id)), + ); } function handleSelectItems(value: { itemIds: string[]; selected: boolean }) { @@ -248,7 +251,7 @@ void (async () => { * Send a Google Analytics shopping cart view event. */ if (cart.value) { - ga.viewCart(cart.value); + analytics("viewCart", cart.value); } if (!config.checkout_multistep_enabled) { diff --git a/client-app/pages/checkout/billing.vue b/client-app/pages/checkout/billing.vue index 5b7038f888..33c550320d 100644 --- a/client-app/pages/checkout/billing.vue +++ b/client-app/pages/checkout/billing.vue @@ -8,7 +8,9 @@ {{ $t("common.buttons.review_order") }} @@ -31,11 +33,11 @@ diff --git a/client-app/pages/checkout/shipping.vue b/client-app/pages/checkout/shipping.vue index e8afe077b3..4f250e7ac3 100644 --- a/client-app/pages/checkout/shipping.vue +++ b/client-app/pages/checkout/shipping.vue @@ -10,7 +10,9 @@ {{ $t("common.buttons.go_to_billing") }} @@ -33,11 +35,11 @@ diff --git a/client-app/pages/compare-products.vue b/client-app/pages/compare-products.vue index e4441bdb79..a68ae2f5ca 100644 --- a/client-app/pages/compare-products.vue +++ b/client-app/pages/compare-products.vue @@ -96,7 +96,7 @@ import _ from "lodash"; import { ref, computed, watch, watchEffect, onMounted } from "vue"; import { useI18n } from "vue-i18n"; -import { useBreadcrumbs, useGoogleAnalytics, usePageHead } from "@/core/composables"; +import { useBreadcrumbs, useAnalytics, usePageHead } from "@/core/composables"; import { getPropertyValue } from "@/core/utilities"; import { ProductCardCompare, useProducts } from "@/shared/catalog"; import { useCompareProducts } from "@/shared/compare"; @@ -117,7 +117,7 @@ usePageHead({ }, }); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const { fetchProducts, products } = useProducts(); const { clearCompareList, productsLimit, removeFromCompareList, productsIds } = useCompareProducts(); const breadcrumbs = useBreadcrumbs([{ title: t("pages.compare.links.compare_products") }]); @@ -208,7 +208,7 @@ function syncScroll(event: Event) { */ watchEffect(() => { if (products.value.length) { - ga.viewItemList(products.value, { + analytics("viewItemList", products.value, { item_list_id: "compare_products", item_list_name: t("pages.compare.header_block.title"), }); diff --git a/client-app/pages/product.vue b/client-app/pages/product.vue index cec51df932..1103e26588 100644 --- a/client-app/pages/product.vue +++ b/client-app/pages/product.vue @@ -115,7 +115,7 @@ import { useBreakpoints, useElementVisibility } from "@vueuse/core"; import { computed, defineAsyncComponent, ref, shallowRef, toRef, watchEffect } from "vue"; import { useI18n } from "vue-i18n"; import _productTemplate from "@/config/product.json"; -import { useBreadcrumbs, useGoogleAnalytics, usePageHead } from "@/core/composables"; +import { useBreadcrumbs, useAnalytics, usePageHead } from "@/core/composables"; import { useHistoricalEvents } from "@/core/composables/useHistoricalEvents"; import { useModuleSettings } from "@/core/composables/useModuleSettings"; import { BREAKPOINTS } from "@/core/constants"; @@ -199,7 +199,7 @@ const { recommendedProducts, fetchRecommendedProducts } = useRecommendedProducts const { isEnabled } = useModuleSettings(CUSTOMER_REVIEWS_MODULE_ID); const productReviewsEnabled = isEnabled(CUSTOMER_REVIEWS_ENABLED_KEY); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const { catalogBreadcrumb } = useCategory(); const { pushHistoricalEvent } = useHistoricalEvents(); @@ -362,7 +362,7 @@ watchEffect(async () => { watchEffect(() => { if (product.value) { // todo https://github.com/VirtoCommerce/vc-theme-b2b-vue/issues/1098 - ga.viewItem(product.value as Product); + analytics("viewItem", product.value as Product); void pushHistoricalEvent({ eventType: "click", productId: product.value.id, @@ -376,7 +376,7 @@ watchEffect(() => { */ watchEffect(() => { if (relatedProducts.value.length) { - ga.viewItemList(relatedProducts.value, { + analytics("viewItemList", relatedProducts.value, { item_list_id: "related_products", item_list_name: t("pages.product.related_product_section_title"), }); diff --git a/client-app/shared/cart/components/add-to-cart.vue b/client-app/shared/cart/components/add-to-cart.vue index 4b22bff008..211370a9a6 100644 --- a/client-app/shared/cart/components/add-to-cart.vue +++ b/client-app/shared/cart/components/add-to-cart.vue @@ -29,7 +29,7 @@ import { isDefined } from "@vueuse/core"; import { clone } from "lodash"; import { computed, ref, toRef } from "vue"; import { useI18n } from "vue-i18n"; -import { useErrorsTranslator, useGoogleAnalytics, useHistoricalEvents } from "@/core/composables"; +import { useErrorsTranslator, useAnalytics, useHistoricalEvents } from "@/core/composables"; import { LINE_ITEM_ID_URL_SEARCH_PARAM, LINE_ITEM_QUANTITY_LIMIT } from "@/core/constants"; import { ValidationErrorObjectType } from "@/core/enums"; import { globals } from "@/core/globals"; @@ -59,7 +59,7 @@ interface IProps { const product = toRef(props, "product"); const { cart, addToCart, changeItemQuantity } = useShortCart(); const { t } = useI18n(); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const { translate } = useErrorsTranslator("validation_error"); const configurableLineItemId = getUrlSearchParam(LINE_ITEM_ID_URL_SEARCH_PARAM); const { selectedConfigurationInput, changeCartConfiguredItem } = useConfigurableProduct(product.value.id); @@ -132,7 +132,7 @@ async function updateOrAddToCart(lineItem: ShortLineItemFragment | undefined, mo } function trackAddToCart(quantity: number) { - ga.addItemToCart(product.value, quantity); + analytics("addItemToCart", product.value, quantity); void pushHistoricalEvent({ eventType: "addToCart", sessionId: cart.value?.id, diff --git a/client-app/shared/cart/composables/useCart.ts b/client-app/shared/cart/composables/useCart.ts index 387fecf1c4..f6d08620f2 100644 --- a/client-app/shared/cart/composables/useCart.ts +++ b/client-app/shared/cart/composables/useCart.ts @@ -29,7 +29,7 @@ import { useValidateCouponQuery, generateCacheIdIfNew, } from "@/core/api/graphql"; -import { useGoogleAnalytics, useSyncMutationBatchers } from "@/core/composables"; +import { useAnalytics, useSyncMutationBatchers } from "@/core/composables"; import { getMergeStrategyUniqueBy, useMutationBatcher } from "@/core/composables/useMutationBatcher"; import { ProductType, ValidationErrorObjectType } from "@/core/enums"; import { groupByVendor, Logger } from "@/core/utilities"; @@ -156,7 +156,7 @@ export function useShortCart() { export function _useFullCart() { const { openModal } = useModal(); - const ga = useGoogleAnalytics(); + const { analytics } = useAnalytics(); const { client } = useApolloClient(); const { result: query, load, refetch, loading } = useGetFullCartQuery(); @@ -442,7 +442,7 @@ export function _useFullCart() { props: { async onResult() { await clearCart(); - ga.clearCart(cart.value!); + analytics("clearCart", cart.value!); }, }, }); diff --git a/client-app/shared/catalog/components/category.vue b/client-app/shared/catalog/components/category.vue index 910519e830..9101c28a75 100644 --- a/client-app/shared/catalog/components/category.vue +++ b/client-app/shared/catalog/components/category.vue @@ -226,7 +226,7 @@ import { whenever, } from "@vueuse/core"; import { computed, ref, shallowRef, toRef, toRefs, watch } from "vue"; -import { useBreadcrumbs, useGoogleAnalytics, useThemeContext } from "@/core/composables"; +import { useBreadcrumbs, useAnalytics, useThemeContext } from "@/core/composables"; import { useModuleSettings } from "@/core/composables/useModuleSettings"; import { BREAKPOINTS, DEFAULT_PAGE_SIZE, PRODUCT_SORTING_LIST } from "@/core/constants"; import { MODULE_XAPI_KEYS } from "@/core/constants/modules"; @@ -327,7 +327,7 @@ const { withFacets: true, }); const { loading: loadingCategory, category: currentCategory, catalogBreadcrumb, fetchCategory } = useCategory(); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const savedViewMode = useLocalStorage("viewMode", "grid"); @@ -407,7 +407,7 @@ async function changeProductsPage(pageNumber: number): Promise { /** * Send Google Analytics event for products on next page. */ - ga.viewItemList(products.value, { + analytics("viewItemList", products.value, { item_list_id: `${currentCategory.value?.slug}_page_${currentPage.value}`, item_list_name: `${currentCategory.value?.name} (page ${currentPage.value})`, }); @@ -419,14 +419,14 @@ async function fetchProducts(): Promise { /** * Send Google Analytics event for products. */ - ga.viewItemList(products.value, { + analytics("viewItemList", products.value, { item_list_id: currentCategory.value?.slug, item_list_name: currentCategory.value?.name, }); } function selectProduct(product: Product): void { - ga.selectItem(product); + analytics("selectItem", product); } whenever(() => !isMobile.value, hideFiltersSidebar); diff --git a/client-app/shared/checkout/composables/useCheckout.ts b/client-app/shared/checkout/composables/useCheckout.ts index db24108fd1..439c19cb56 100644 --- a/client-app/shared/checkout/composables/useCheckout.ts +++ b/client-app/shared/checkout/composables/useCheckout.ts @@ -4,7 +4,7 @@ import { computed, readonly, ref, shallowRef, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useRouter } from "vue-router"; import { createOrderFromCart as _createOrderFromCart } from "@/core/api/graphql"; -import { useGoogleAnalytics, useHistoricalEvents, useThemeContext } from "@/core/composables"; +import { useAnalytics, useHistoricalEvents, useThemeContext } from "@/core/composables"; import { AddressType, ProductType } from "@/core/enums"; import { globals } from "@/core/globals"; import { isEqualAddresses, Logger } from "@/core/utilities"; @@ -54,7 +54,7 @@ const useGlobalCheckout = createGlobalState(() => { }); export function _useCheckout() { - const ga = useGoogleAnalytics(); + const { analytics } = useAnalytics(); const { t } = useI18n(); const notifications = useNotifications(); const { openModal, closeModal } = useModal(); @@ -254,7 +254,7 @@ export function _useCheckout() { void fetchAddresses(); - ga.beginCheckout({ ...cart.value!, items: selectedLineItems.value }); + analytics("beginCheckout", { ...cart.value!, items: selectedLineItems.value }); loading.value = false; } @@ -455,7 +455,7 @@ export function _useCheckout() { clearState(); - ga.placeOrder(placedOrder.value); + analytics("placeOrder", placedOrder.value); void pushHistoricalEvent({ eventType: "placeOrder", sessionId: placedOrder.value.id, diff --git a/client-app/shared/layout/components/search-bar/search-bar.vue b/client-app/shared/layout/components/search-bar/search-bar.vue index cf00fd0a59..69d0576ce3 100644 --- a/client-app/shared/layout/components/search-bar/search-bar.vue +++ b/client-app/shared/layout/components/search-bar/search-bar.vue @@ -100,7 +100,7 @@ :product="product" @link-click=" hideSearchDropdown(); - ga.selectItem(product, { search_term: trimmedSearchPhrase }); + analytics('selectItem', product, { search_term: trimmedSearchPhrase }); " /> @@ -133,7 +133,7 @@ import { onClickOutside, useDebounceFn, useElementBounding, whenever } from "@vueuse/core"; import { computed, inject, ref, watchEffect } from "vue"; import { useRouter } from "vue-router"; -import { useCategoriesRoutes, useGoogleAnalytics, useRouteQueryParam, useThemeContext } from "@/core/composables"; +import { useCategoriesRoutes, useAnalytics, useRouteQueryParam, useThemeContext } from "@/core/composables"; import { useModuleSettings } from "@/core/composables/useModuleSettings"; import { DEFAULT_PAGE_SIZE } from "@/core/constants"; import { MODULE_XAPI_KEYS } from "@/core/constants/modules"; @@ -174,7 +174,7 @@ const { searchResults, } = useSearchBar(); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const router = useRouter(); const { themeContext } = useThemeContext(); @@ -264,7 +264,7 @@ async function searchAndShowDropdownResults(): Promise { * Send Google Analytics event for products. */ if (products.value.length) { - ga.viewItemList(products.value, { + analytics("viewItemList", products.value, { item_list_name: `Search phrase "${trimmedSearchPhrase.value}"`, }); } @@ -283,7 +283,7 @@ function goToSearchResultsPage() { if (trimmedSearchPhrase.value) { hideSearchDropdown(); void router.push(getSearchRoute(trimmedSearchPhrase.value)); - ga.search(trimmedSearchPhrase.value, products.value, total.value); + analytics("search", trimmedSearchPhrase.value, products.value, total.value); } } diff --git a/client-app/shared/payment/components/payment-processing-authorize-net.vue b/client-app/shared/payment/components/payment-processing-authorize-net.vue index 8049bfd309..db17eab822 100644 --- a/client-app/shared/payment/components/payment-processing-authorize-net.vue +++ b/client-app/shared/payment/components/payment-processing-authorize-net.vue @@ -55,7 +55,7 @@ import { clone } from "lodash"; import { computed, onMounted, ref, shallowRef, watch, watchEffect } from "vue"; import { useI18n } from "vue-i18n"; import { initializePayment } from "@/core/api/graphql"; -import { useGoogleAnalytics } from "@/core/composables/useGoogleAnalytics"; +import { useAnalytics } from "@/core/composables/useAnalytics"; import { Logger } from "@/core/utilities"; import { useAuthorizeNet } from "@/shared/payment/composables/useAuthorizeNet"; import { PaymentActionType } from "@/shared/payment/types"; @@ -97,7 +97,7 @@ const scriptURL = computed(() => parameters.value.find(({ key }) => key const apiLoginID = computed(() => parameters.value.find(({ key }) => key === "apiLogin")?.value ?? ""); const clientKey = computed(() => parameters.value.find(({ key }) => key === "clientKey")?.value ?? ""); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const { t } = useI18n(); const { loadAcceptJS, dispatchData, sendOpaqueData } = useAuthorizeNet({ scriptURL, manualScriptLoading: true }); @@ -175,7 +175,7 @@ async function pay(opaqueData: Accept.OpaqueData) { /** * Send Google Analytics purchase event. */ - ga.purchase(props.order); + analytics("purchase", props.order); } else { emit("fail", errorMessage); } diff --git a/client-app/shared/payment/components/payment-processing-skyflow.vue b/client-app/shared/payment/components/payment-processing-skyflow.vue index 473aba647d..4d137a8111 100644 --- a/client-app/shared/payment/components/payment-processing-skyflow.vue +++ b/client-app/shared/payment/components/payment-processing-skyflow.vue @@ -98,7 +98,7 @@ import Skyflow from "skyflow-js"; import { computed, onMounted, ref } from "vue"; import { useI18n } from "vue-i18n"; import { initializePayment, authorizePayment } from "@/core/api/graphql"; -import { useGoogleAnalytics, useThemeContext } from "@/core/composables"; +import { useAnalytics, useThemeContext } from "@/core/composables"; import { IS_DEVELOPMENT } from "@/core/constants"; import { replaceXFromBeginning } from "@/core/utilities"; import { useUser } from "@/shared/account"; @@ -126,7 +126,7 @@ type FieldsType = { [key: string]: string }; const { t } = useI18n(); const { user, isAuthenticated } = useUser(); const { skyflowCards, fetchSkyflowCards } = useSkyflowCards(); -const ga = useGoogleAnalytics(); +const { analytics } = useAnalytics(); const { themeContext } = useThemeContext(); const loading = ref(false); @@ -543,7 +543,7 @@ async function pay(parameters: InputKeyValueType[]): Promise { }); if (isSuccess) { - ga.purchase(props.order); + analytics("purchase", props.order); emit("success"); } else { emit("fail"); diff --git a/client-app/shared/static-content/components/related-products.vue b/client-app/shared/static-content/components/related-products.vue index 77a69fee51..de97093bcc 100644 --- a/client-app/shared/static-content/components/related-products.vue +++ b/client-app/shared/static-content/components/related-products.vue @@ -13,14 +13,14 @@ :key="index" :product="item" class="w-[calc((100%-1.75rem)/2)] sm:w-[calc((100%-2*1.75rem)/3)] md:w-[calc((100%-1.75rem)/2)]" - @link-click="ga.selectItem(item)" + @link-click="analytics('selectItem', item)" /> @@ -30,7 +30,7 @@