From c417f0d9dde29e6b2dde6dccd61f4c668f859608 Mon Sep 17 00:00:00 2001 From: Ivan Kalachikov Date: Wed, 11 Dec 2024 21:29:51 +0100 Subject: [PATCH 01/10] feat: analytics-module --- client-app/app-runner.ts | 4 +- client-app/core/composables/index.ts | 2 +- client-app/core/composables/useAnalytics.ts | 47 ++++ .../core/composables/useGoogleAnalytics.ts | 72 ------ client-app/core/types/analytics.ts | 29 +++ .../modules/google-analytics/constants.ts | 8 + client-app/modules/google-analytics/events.ts | 211 ++++++++++++++++++ client-app/modules/google-analytics/index.ts | 33 +++ client-app/modules/google-analytics/types.ts | 9 + client-app/modules/google-analytics/utils.ts | 67 ++++++ client-app/pages/account/list-details.vue | 10 +- client-app/pages/cart.vue | 8 +- client-app/pages/checkout/billing.vue | 6 +- client-app/pages/checkout/shipping.vue | 8 +- client-app/pages/compare-products.vue | 6 +- client-app/pages/product.vue | 8 +- .../shared/cart/components/add-to-cart.vue | 6 +- client-app/shared/cart/composables/useCart.ts | 6 +- .../shared/catalog/components/category.vue | 10 +- .../checkout/composables/useCheckout.ts | 8 +- .../components/search-bar/search-bar.vue | 10 +- .../payment-processing-authorize-net.vue | 6 +- .../components/payment-processing-skyflow.vue | 6 +- .../components/related-products.vue | 8 +- .../components/add-to-wishlists-modal.vue | 8 +- 25 files changed, 465 insertions(+), 131 deletions(-) create mode 100644 client-app/core/composables/useAnalytics.ts delete mode 100644 client-app/core/composables/useGoogleAnalytics.ts create mode 100644 client-app/core/types/analytics.ts create mode 100644 client-app/modules/google-analytics/constants.ts create mode 100644 client-app/modules/google-analytics/events.ts create mode 100644 client-app/modules/google-analytics/index.ts create mode 100644 client-app/modules/google-analytics/types.ts create mode 100644 client-app/modules/google-analytics/utils.ts diff --git a/client-app/app-runner.ts b/client-app/app-runner.ts index 4bbcefece4..6d91a0c856 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 { init as initPushNotifications } from "@/modules/push-messages"; import { init as initModuleQuotes } from "@/modules/quotes"; import { createRouter } from "@/router"; @@ -65,7 +66,6 @@ export default async () => { mergeLocales, } = useLanguages(); const { currentCurrency } = useCurrency(); - const { init: initializeGoogleAnalytics } = useGoogleAnalytics(); const { init: initializeHotjar } = useHotjar(); const { fetchMenus } = useNavigations(); const { themePresetName, fetchWhiteLabelingSettings } = useWhiteLabeling(); 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.ts b/client-app/core/composables/useAnalytics.ts new file mode 100644 index 0000000000..e001775f2c --- /dev/null +++ b/client-app/core/composables/useAnalytics.ts @@ -0,0 +1,47 @@ +import { createGlobalState } from "@vueuse/core"; +import { IS_DEVELOPMENT } from "@/core/constants"; +import { Logger } from "@/core/utilities"; +import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "../types/analytics"; + +function _useAnalytics() { + const trackers: TackerType[] = []; + + function addTracker(tracker: TackerType): void { + trackers.push(tracker); + } + + const trackEvent = new Proxy( + {} as { + [E in AnalyticsEventNameType]: (...args: IAnalyticsEventMap[E]) => void; + }, + { + get(target, prop: AnalyticsEventNameType) { + return (...args: IAnalyticsEventMap[E]) => { + _trackEvent(prop, ...args); + }; + }, + }, + ); + + function _trackEvent(event: E, ...args: IAnalyticsEventMap[E]): void { + if (IS_DEVELOPMENT) { + Logger.debug(`${useAnalytics.name}, can't track event in development mode`); + return; + } + trackers.forEach((tracker) => { + const handler = tracker[event]; + if (handler) { + handler(...args); + } else { + Logger.warn(`${useAnalytics.name}, unsupported event: "${event}" in tracker.`); + } + }); + } + + return { + addTracker, + trackEvent, + }; +} + +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..d1a798a532 --- /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; +}>; diff --git a/client-app/modules/google-analytics/constants.ts b/client-app/modules/google-analytics/constants.ts new file mode 100644 index 0000000000..8ca6623f0b --- /dev/null +++ b/client-app/modules/google-analytics/constants.ts @@ -0,0 +1,8 @@ +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; diff --git a/client-app/modules/google-analytics/events.ts b/client-app/modules/google-analytics/events.ts new file mode 100644 index 0000000000..50314a78e3 --- /dev/null +++ b/client-app/modules/google-analytics/events.ts @@ -0,0 +1,211 @@ +import { sumBy } from "lodash"; +import { globals } from "@/core/globals"; +import { Logger } from "@/core/utilities"; +import { DEBUG_PREFIX } from "./constants"; +import { lineItemToGtagItem, productToGtagItem } from "./utils"; +import type { CustomEventNamesType, EventParamsType } from "./types"; +import type { CartType, CustomerOrderType, LineItemType, Product, VariationType } from "@/core/api/graphql/types"; +import type { ViewItemListParamsAdditionalType } from "@/core/types/analytics"; + +const canUseDOM = !!(typeof window !== "undefined" && window.document?.createElement); +const { currencyCode } = globals; + +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 viewItemList( + items: { code: string }[] = [], + params?: EventParamsType & ViewItemListParamsAdditionalType, +): void { + sendEvent("view_item_list", { + ...params, + items_skus: items + .map((el) => el.code) + .join(", ") + .trim(), + items_count: items.length, + }); +} + +export function selectItem(item: Product | LineItemType, params?: EventParamsType): void { + const gtagItem = "productId" in item ? lineItemToGtagItem(item) : productToGtagItem(item); + + sendEvent("select_item", { + ...params, + items: [gtagItem], + }); +} + +export function viewItem(item: Product, params?: EventParamsType): void { + sendEvent("view_item", { + ...params, + currency: currencyCode, + value: item.price?.actual?.amount, + items: [productToGtagItem(item)], + }); +} + +export function addItemToWishList(item: Product, params?: EventParamsType): void { + sendEvent("add_to_wishlist", { + ...params, + currency: currencyCode, + value: item.price?.actual?.amount, + items: [productToGtagItem(item)], + }); +} + +export function addItemToCart(item: Product | VariationType, quantity = 1, params?: EventParamsType): void { + const inputItem = productToGtagItem(item); + + inputItem.quantity = quantity; + + sendEvent("add_to_cart", { + ...params, + currency: currencyCode, + value: item.price?.actual?.amount * quantity, + items: [inputItem], + }); +} + +export function addItemsToCart(items: (Product | VariationType)[], params?: EventParamsType): void { + const subtotal: number = sumBy(items, (item) => item?.price?.actual?.amount); + const inputItems = items.filter((item) => item).map((item) => productToGtagItem(item)); + + sendEvent("add_to_cart", { + ...params, + currency: currencyCode, + value: subtotal, + items: inputItems, + items_count: inputItems.length, + }); +} + +export function removeItemsFromCart(items: LineItemType[], params?: EventParamsType): void { + const subtotal: number = sumBy(items, (item) => item.extendedPrice?.amount); + const inputItems = items.map((item) => lineItemToGtagItem(item)); + + sendEvent("remove_from_cart", { + ...params, + currency: currencyCode, + value: subtotal, + items: inputItems, + items_count: inputItems.length, + }); +} + +export function viewCart(cart: CartType, params?: EventParamsType): void { + sendEvent("view_cart", { + ...params, + currency: currencyCode, + value: cart.total.amount, + items: cart.items.map(lineItemToGtagItem), + items_count: cart.items.length, + }); +} + +export function clearCart(cart: CartType, params?: EventParamsType): void { + sendEvent("clear_cart", { + ...params, + currency: currencyCode, + value: cart.total.amount, + items: cart.items.map(lineItemToGtagItem), + items_count: cart.items.length, + }); +} + +export function beginCheckout(cart: CartType, params?: EventParamsType): void { + 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.name, e); + } +} + +export function addShippingInfo(cart?: CartType, params?: EventParamsType, shipmentMethodOption?: string): void { + 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.name, e); + } +} + +export function addPaymentInfo(cart?: CartType, params?: EventParamsType, paymentGatewayCode?: string): void { + 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.name, e); + } +} + +export function purchase(order: CustomerOrderType, transactionId?: string, params?: EventParamsType): void { + 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.name, e); + } +} + +export function placeOrder(order: CustomerOrderType, params?: EventParamsType): void { + 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.name, e); + } +} + +export function search(searchTerm: string, visibleItems: { code: string }[] = [], itemsCount: number = 0): void { + 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..798b259540 --- /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; + } + const { addTracker } = useAnalytics(); + const tracker = await import("./events"); + addTracker(tracker); + useScriptTag(`https://www.googletagmanager.com/gtag/js?id=${trackId}`); + 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..26d38081ff --- /dev/null +++ b/client-app/modules/google-analytics/utils.ts @@ -0,0 +1,67 @@ +import type { + Breadcrumb, + LineItemType, + OrderLineItemType, + Product, + VariationType, + DiscountType, + OrderDiscountType, +} from "@/core/api/graphql/types"; + +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 9568d03b28..86c7c155fb 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 { trackEvent } = 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); + trackEvent.addItemsToCart(products); void pushHistoricalEvent({ eventType: "addToCart", sessionId: cart.value?.id, @@ -287,7 +287,7 @@ async function addOrUpdateCartItem(item: PreparedLineItemType, quantity: number) } else { await addToCart(lineItem.product.id, quantity); - ga.addItemToCart(lineItem.product, quantity); + trackEvent.addItemToCart(lineItem.product, quantity); void pushHistoricalEvent({ eventType: "addToCart", sessionId: cart.value?.id, @@ -355,7 +355,7 @@ watchEffect(() => { .filter(Boolean); if (items?.length) { - ga.viewItemList(items, { + trackEvent.viewItemList(items, { item_list_name: `Wishlist "${list.value?.name}"`, }); } diff --git a/client-app/pages/cart.vue b/client-app/pages/cart.vue index e38766e2ba..ae7d6a3d8a 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 { trackEvent } = useAnalytics(); const { t } = useI18n(); const { isAuthenticated } = useUser(); const { @@ -230,7 +230,7 @@ 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))); + trackEvent.removeItemsFromCart(cart.value!.items!.filter((item) => itemIds.includes(item.id))); } function handleSelectItems(value: { itemIds: string[]; selected: boolean }) { @@ -248,7 +248,7 @@ void (async () => { * Send a Google Analytics shopping cart view event. */ if (cart.value) { - ga.viewCart(cart.value); + trackEvent.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..9a50a6bb31 100644 --- a/client-app/pages/checkout/billing.vue +++ b/client-app/pages/checkout/billing.vue @@ -8,7 +8,7 @@ {{ $t("common.buttons.review_order") }} @@ -31,11 +31,11 @@ diff --git a/client-app/pages/checkout/shipping.vue b/client-app/pages/checkout/shipping.vue index e8afe077b3..3a6a503fa4 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..eb4df46190 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 { trackEvent } = 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, { + trackEvent.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..08ae4972c6 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 { trackEvent } = 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); + trackEvent.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, { + trackEvent.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 8d95aa96f7..0fae03d56b 100644 --- a/client-app/shared/cart/components/add-to-cart.vue +++ b/client-app/shared/cart/components/add-to-cart.vue @@ -28,7 +28,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"; @@ -58,7 +58,7 @@ interface IProps { const product = toRef(props, "product"); const { cart, addToCart, changeItemQuantity, changeCartConfiguredItem } = useShortCart(); const { t } = useI18n(); -const ga = useGoogleAnalytics(); +const { trackEvent } = useAnalytics(); const { translate } = useErrorsTranslator("validation_error"); const configurableLineItemId = getUrlSearchParam(LINE_ITEM_ID_URL_SEARCH_PARAM); const { selectedConfigurationInput } = useConfigurableProduct(product.value.id); @@ -131,7 +131,7 @@ async function updateOrAddToCart(lineItem: ShortLineItemFragment | undefined, mo } function trackAddToCart(quantity: number) { - ga.addItemToCart(product.value, quantity); + trackEvent.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 caca981679..9124ba72b2 100644 --- a/client-app/shared/cart/composables/useCart.ts +++ b/client-app/shared/cart/composables/useCart.ts @@ -30,7 +30,7 @@ import { generateCacheIdIfNew, useChangeCartConfiguredItemMutation, } 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"; @@ -174,7 +174,7 @@ export function useShortCart() { export function _useFullCart() { const { openModal } = useModal(); - const ga = useGoogleAnalytics(); + const { trackEvent } = useAnalytics(); const { client } = useApolloClient(); const { result: query, load, refetch, loading } = useGetFullCartQuery(); @@ -460,7 +460,7 @@ export function _useFullCart() { props: { async onResult() { await clearCart(); - ga.clearCart(cart.value!); + trackEvent.clearCart(cart.value!); }, }, }); diff --git a/client-app/shared/catalog/components/category.vue b/client-app/shared/catalog/components/category.vue index 910519e830..3328cc40b4 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 { trackEvent } = 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, { + trackEvent.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, { + trackEvent.viewItemList(products.value, { item_list_id: currentCategory.value?.slug, item_list_name: currentCategory.value?.name, }); } function selectProduct(product: Product): void { - ga.selectItem(product); + trackEvent.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..980905c4cd 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 { trackEvent } = 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 }); + trackEvent.beginCheckout({ ...cart.value!, items: selectedLineItems.value }); loading.value = false; } @@ -455,7 +455,7 @@ export function _useCheckout() { clearState(); - ga.placeOrder(placedOrder.value); + trackEvent.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..eec0a23300 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 }); + trackEvent.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 { trackEvent } = 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, { + trackEvent.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); + trackEvent.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..f732231cc0 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 { trackEvent } = 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); + trackEvent.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..d42d52d92c 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 { trackEvent } = useAnalytics(); const { themeContext } = useThemeContext(); const loading = ref(false); @@ -543,7 +543,7 @@ async function pay(parameters: InputKeyValueType[]): Promise { }); if (isSuccess) { - ga.purchase(props.order); + trackEvent.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..4e670b5280 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="trackEvent.selectItem(item)" /> @@ -30,7 +30,7 @@ diff --git a/client-app/pages/checkout/shipping.vue b/client-app/pages/checkout/shipping.vue index 3a6a503fa4..4f250e7ac3 100644 --- a/client-app/pages/checkout/shipping.vue +++ b/client-app/pages/checkout/shipping.vue @@ -11,7 +11,7 @@ :to="{ name: 'Billing' }" :disabled="!isValidShipment" @click=" - trackEvent.addShippingInfo({ ...cart!, items: selectedLineItems }, {}, shipment?.shipmentMethodOption) + analytics('addShippingInfo', { ...cart!, items: selectedLineItems }, {}, shipment?.shipmentMethodOption) " > {{ $t("common.buttons.go_to_billing") }} @@ -41,5 +41,5 @@ import { OrderCommentSection, OrderSummary, ProceedTo, ShippingDetailsSection, u const { cart, shipment, selectedLineItems, hasValidationErrors } = useFullCart(); const { comment, isValidShipment } = useCheckout(); -const { trackEvent } = useAnalytics(); +const { analytics } = useAnalytics(); diff --git a/client-app/pages/compare-products.vue b/client-app/pages/compare-products.vue index eb4df46190..a68ae2f5ca 100644 --- a/client-app/pages/compare-products.vue +++ b/client-app/pages/compare-products.vue @@ -117,7 +117,7 @@ usePageHead({ }, }); -const { trackEvent } = useAnalytics(); +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) { - trackEvent.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 08ae4972c6..1103e26588 100644 --- a/client-app/pages/product.vue +++ b/client-app/pages/product.vue @@ -199,7 +199,7 @@ const { recommendedProducts, fetchRecommendedProducts } = useRecommendedProducts const { isEnabled } = useModuleSettings(CUSTOMER_REVIEWS_MODULE_ID); const productReviewsEnabled = isEnabled(CUSTOMER_REVIEWS_ENABLED_KEY); -const { trackEvent } = useAnalytics(); +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 - trackEvent.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) { - trackEvent.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 0fae03d56b..3f8aaa96ef 100644 --- a/client-app/shared/cart/components/add-to-cart.vue +++ b/client-app/shared/cart/components/add-to-cart.vue @@ -58,7 +58,7 @@ interface IProps { const product = toRef(props, "product"); const { cart, addToCart, changeItemQuantity, changeCartConfiguredItem } = useShortCart(); const { t } = useI18n(); -const { trackEvent } = useAnalytics(); +const { analytics } = useAnalytics(); const { translate } = useErrorsTranslator("validation_error"); const configurableLineItemId = getUrlSearchParam(LINE_ITEM_ID_URL_SEARCH_PARAM); const { selectedConfigurationInput } = useConfigurableProduct(product.value.id); @@ -131,7 +131,7 @@ async function updateOrAddToCart(lineItem: ShortLineItemFragment | undefined, mo } function trackAddToCart(quantity: number) { - trackEvent.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 9124ba72b2..182076836b 100644 --- a/client-app/shared/cart/composables/useCart.ts +++ b/client-app/shared/cart/composables/useCart.ts @@ -174,7 +174,7 @@ export function useShortCart() { export function _useFullCart() { const { openModal } = useModal(); - const { trackEvent } = useAnalytics(); + const { analytics } = useAnalytics(); const { client } = useApolloClient(); const { result: query, load, refetch, loading } = useGetFullCartQuery(); @@ -460,7 +460,7 @@ export function _useFullCart() { props: { async onResult() { await clearCart(); - trackEvent.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 3328cc40b4..9101c28a75 100644 --- a/client-app/shared/catalog/components/category.vue +++ b/client-app/shared/catalog/components/category.vue @@ -327,7 +327,7 @@ const { withFacets: true, }); const { loading: loadingCategory, category: currentCategory, catalogBreadcrumb, fetchCategory } = useCategory(); -const { trackEvent } = useAnalytics(); +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. */ - trackEvent.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. */ - trackEvent.viewItemList(products.value, { + analytics("viewItemList", products.value, { item_list_id: currentCategory.value?.slug, item_list_name: currentCategory.value?.name, }); } function selectProduct(product: Product): void { - trackEvent.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 980905c4cd..439c19cb56 100644 --- a/client-app/shared/checkout/composables/useCheckout.ts +++ b/client-app/shared/checkout/composables/useCheckout.ts @@ -54,7 +54,7 @@ const useGlobalCheckout = createGlobalState(() => { }); export function _useCheckout() { - const { trackEvent } = useAnalytics(); + const { analytics } = useAnalytics(); const { t } = useI18n(); const notifications = useNotifications(); const { openModal, closeModal } = useModal(); @@ -254,7 +254,7 @@ export function _useCheckout() { void fetchAddresses(); - trackEvent.beginCheckout({ ...cart.value!, items: selectedLineItems.value }); + analytics("beginCheckout", { ...cart.value!, items: selectedLineItems.value }); loading.value = false; } @@ -455,7 +455,7 @@ export function _useCheckout() { clearState(); - trackEvent.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 eec0a23300..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(); - trackEvent.selectItem(product, { search_term: trimmedSearchPhrase }); + analytics('selectItem', product, { search_term: trimmedSearchPhrase }); " /> @@ -174,7 +174,7 @@ const { searchResults, } = useSearchBar(); -const { trackEvent } = useAnalytics(); +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) { - trackEvent.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)); - trackEvent.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 f732231cc0..db17eab822 100644 --- a/client-app/shared/payment/components/payment-processing-authorize-net.vue +++ b/client-app/shared/payment/components/payment-processing-authorize-net.vue @@ -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 { trackEvent } = useAnalytics(); +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. */ - trackEvent.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 d42d52d92c..4d137a8111 100644 --- a/client-app/shared/payment/components/payment-processing-skyflow.vue +++ b/client-app/shared/payment/components/payment-processing-skyflow.vue @@ -126,7 +126,7 @@ type FieldsType = { [key: string]: string }; const { t } = useI18n(); const { user, isAuthenticated } = useUser(); const { skyflowCards, fetchSkyflowCards } = useSkyflowCards(); -const { trackEvent } = useAnalytics(); +const { analytics } = useAnalytics(); const { themeContext } = useThemeContext(); const loading = ref(false); @@ -543,7 +543,7 @@ async function pay(parameters: InputKeyValueType[]): Promise { }); if (isSuccess) { - trackEvent.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 4e670b5280..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="trackEvent.selectItem(item)" + @link-click="analytics('selectItem', item)" /> @@ -42,7 +42,7 @@ interface IProps { const props = defineProps(); const breakpoints = useBreakpoints(BREAKPOINTS); -const { trackEvent } = useAnalytics(); +const { analytics } = useAnalytics(); const sm = breakpoints.smaller("sm"); const md = breakpoints.smaller("md"); diff --git a/client-app/shared/wishlists/components/add-to-wishlists-modal.vue b/client-app/shared/wishlists/components/add-to-wishlists-modal.vue index 0dac1fce75..163c3910d3 100644 --- a/client-app/shared/wishlists/components/add-to-wishlists-modal.vue +++ b/client-app/shared/wishlists/components/add-to-wishlists-modal.vue @@ -165,7 +165,7 @@ const { removeItemsFromWishlists, } = useWishlists({ autoRefetch: false }); const notifications = useNotifications(); -const { trackEvent } = useAnalytics(); +const { analytics } = useAnalytics(); const { loading: loadingProductWishlists, load: fetchProductWishlists, @@ -228,7 +228,7 @@ async function addToWishlistsFromListOther() { /** * Send Google Analytics event for an item added to wish list. */ - trackEvent.addItemToWishList(product.value!); + analytics("addItemToWishList", product.value!); } async function createLists() { @@ -249,7 +249,7 @@ async function createLists() { /** * Send Google Analytics event for an item added to wish list. */ - trackEvent.addItemToWishList(product.value!); + analytics("addItemToWishList", product.value!); } async function removeProductFromWishlists() { From 1707e1317b155ac9b994c4eda4887c55622fcd0e Mon Sep 17 00:00:00 2001 From: Ivan Kalachikov Date: Thu, 12 Dec 2024 13:22:02 +0100 Subject: [PATCH 03/10] feat: analytics-module --- client-app/modules/google-analytics/constants.ts | 2 ++ client-app/modules/google-analytics/events.ts | 12 +----------- client-app/modules/google-analytics/index.ts | 2 +- client-app/modules/google-analytics/utils.ts | 11 +++++++++++ package.json | 1 - yarn.lock | 13 +------------ 6 files changed, 16 insertions(+), 25 deletions(-) diff --git a/client-app/modules/google-analytics/constants.ts b/client-app/modules/google-analytics/constants.ts index 8ca6623f0b..3ba75de3a1 100644 --- a/client-app/modules/google-analytics/constants.ts +++ b/client-app/modules/google-analytics/constants.ts @@ -6,3 +6,5 @@ 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 index 0b02e9e9a1..fafe6a43ad 100644 --- a/client-app/modules/google-analytics/events.ts +++ b/client-app/modules/google-analytics/events.ts @@ -2,21 +2,11 @@ import { sumBy } from "lodash"; import { globals } from "@/core/globals"; import { Logger } from "@/core/utilities"; import { DEBUG_PREFIX } from "./constants"; -import { lineItemToGtagItem, productToGtagItem } from "./utils"; -import type { CustomEventNamesType, EventParamsType } from "./types"; +import { lineItemToGtagItem, productToGtagItem, sendEvent } from "./utils"; import type { IAnalyticsEventMap } from "@/core/types/analytics"; -const canUseDOM = !!(typeof window !== "undefined" && window.document?.createElement); const { currencyCode } = globals; -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 const analytics = { viewItemList(...[items = [], params]: IAnalyticsEventMap["viewItemList"]) { sendEvent("view_item_list", { diff --git a/client-app/modules/google-analytics/index.ts b/client-app/modules/google-analytics/index.ts index 227395038d..c5fcf35a52 100644 --- a/client-app/modules/google-analytics/index.ts +++ b/client-app/modules/google-analytics/index.ts @@ -16,10 +16,10 @@ export async function init(): Promise { 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); - useScriptTag(`https://www.googletagmanager.com/gtag/js?id=${trackId}`); window.dataLayer = window.dataLayer || []; window.gtag = function gtag() { // is not working with rest diff --git a/client-app/modules/google-analytics/utils.ts b/client-app/modules/google-analytics/utils.ts index 26d38081ff..a8c452f968 100644 --- a/client-app/modules/google-analytics/utils.ts +++ b/client-app/modules/google-analytics/utils.ts @@ -1,3 +1,6 @@ +import { Logger } from "@/core/utilities"; +import { canUseDOM, DEBUG_PREFIX } from "./constants"; +import type { CustomEventNamesType, EventParamsType } from "./types"; import type { Breadcrumb, LineItemType, @@ -65,3 +68,11 @@ export function getCategories(breadcrumbs: Breadcrumb[] = []): Record Date: Thu, 12 Dec 2024 13:43:49 +0100 Subject: [PATCH 04/10] feat: analytics-module --- client-app/modules/google-analytics/events.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/client-app/modules/google-analytics/events.ts b/client-app/modules/google-analytics/events.ts index fafe6a43ad..744a3af0b5 100644 --- a/client-app/modules/google-analytics/events.ts +++ b/client-app/modules/google-analytics/events.ts @@ -3,12 +3,12 @@ import { globals } from "@/core/globals"; import { Logger } from "@/core/utilities"; import { DEBUG_PREFIX } from "./constants"; import { lineItemToGtagItem, productToGtagItem, sendEvent } from "./utils"; -import type { IAnalyticsEventMap } from "@/core/types/analytics"; +import type { TackerType } from "@/core/types/analytics"; const { currencyCode } = globals; -export const analytics = { - viewItemList(...[items = [], params]: IAnalyticsEventMap["viewItemList"]) { +export const analytics: TackerType = { + viewItemList(items = [], params) { sendEvent("view_item_list", { ...params, items_skus: items @@ -19,7 +19,7 @@ export const analytics = { }); }, - selectItem(...[item, params]: IAnalyticsEventMap["selectItem"]) { + selectItem(item, params) { const gtagItem = "productId" in item ? lineItemToGtagItem(item) : productToGtagItem(item); sendEvent("select_item", { ...params, @@ -27,7 +27,7 @@ export const analytics = { }); }, - viewItem(...[item, params]: IAnalyticsEventMap["viewItem"]) { + viewItem(item, params) { sendEvent("view_item", { ...params, currency: currencyCode, @@ -36,7 +36,7 @@ export const analytics = { }); }, - addItemToWishList(...[item, params]: IAnalyticsEventMap["addItemToWishList"]) { + addItemToWishList(item, params) { sendEvent("add_to_wishlist", { ...params, currency: currencyCode, @@ -45,7 +45,7 @@ export const analytics = { }); }, - addItemToCart(...[item, quantity = 1, params]: IAnalyticsEventMap["addItemToCart"]) { + addItemToCart(item, quantity = 1, params) { const inputItem = productToGtagItem(item); inputItem.quantity = quantity; sendEvent("add_to_cart", { @@ -56,7 +56,7 @@ export const analytics = { }); }, - addItemsToCart(...[items, params]: IAnalyticsEventMap["addItemsToCart"]) { + 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", { @@ -68,7 +68,7 @@ export const analytics = { }); }, - removeItemsFromCart(...[items, params]: IAnalyticsEventMap["removeItemsFromCart"]) { + removeItemsFromCart(items, params) { const subtotal: number = sumBy(items, (item) => item.extendedPrice?.amount); const inputItems = items.map(lineItemToGtagItem); sendEvent("remove_from_cart", { @@ -80,7 +80,7 @@ export const analytics = { }); }, - viewCart(...[cart, params]: IAnalyticsEventMap["viewCart"]) { + viewCart(cart, params) { sendEvent("view_cart", { ...params, currency: currencyCode, @@ -90,7 +90,7 @@ export const analytics = { }); }, - clearCart(...[cart, params]: IAnalyticsEventMap["clearCart"]) { + clearCart(cart, params) { sendEvent("clear_cart", { ...params, currency: currencyCode, @@ -100,7 +100,7 @@ export const analytics = { }); }, - beginCheckout(...[cart, params]: IAnalyticsEventMap["beginCheckout"]) { + beginCheckout(cart, params) { try { sendEvent("begin_checkout", { ...params, @@ -115,7 +115,7 @@ export const analytics = { } }, - addShippingInfo(...[cart, params, shipmentMethodOption]: IAnalyticsEventMap["addShippingInfo"]) { + addShippingInfo(cart, params, shipmentMethodOption) { try { sendEvent("add_shipping_info", { ...params, @@ -131,7 +131,7 @@ export const analytics = { } }, - addPaymentInfo(...[cart, params, paymentGatewayCode]: IAnalyticsEventMap["addPaymentInfo"]) { + addPaymentInfo(cart, params, paymentGatewayCode) { try { sendEvent("add_payment_info", { ...params, @@ -147,7 +147,7 @@ export const analytics = { } }, - purchase(...[order, transactionId, params]: IAnalyticsEventMap["purchase"]) { + purchase(order, transactionId, params) { try { sendEvent("purchase", { ...params, @@ -165,7 +165,7 @@ export const analytics = { } }, - placeOrder(...[order, params]: IAnalyticsEventMap["placeOrder"]) { + placeOrder(order, params) { try { sendEvent("place_order", { ...params, @@ -181,7 +181,7 @@ export const analytics = { } }, - search(...[searchTerm, visibleItems = [], itemsCount = 0]: IAnalyticsEventMap["search"]) { + search(searchTerm, visibleItems = [], itemsCount = 0) { sendEvent("search", { search_term: searchTerm, items_count: itemsCount, From af02436629b43bab96a30c01ad9261c00e6cd0a2 Mon Sep 17 00:00:00 2001 From: Ivan Kalachikov Date: Thu, 12 Dec 2024 14:03:14 +0100 Subject: [PATCH 05/10] feat: analytics-module --- client-app/modules/google-analytics/utils.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client-app/modules/google-analytics/utils.ts b/client-app/modules/google-analytics/utils.ts index a8c452f968..65c8230f79 100644 --- a/client-app/modules/google-analytics/utils.ts +++ b/client-app/modules/google-analytics/utils.ts @@ -11,6 +11,14 @@ import type { 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) : {}; @@ -68,11 +76,3 @@ export function getCategories(breadcrumbs: Breadcrumb[] = []): Record Date: Thu, 12 Dec 2024 16:21:21 +0100 Subject: [PATCH 06/10] feat: add unit tests --- .../core/composables/useAnalytics.test.ts | 292 ++++++++++++++++++ client-app/core/composables/useAnalytics.ts | 14 +- client-app/core/types/analytics.ts | 2 +- 3 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 client-app/core/composables/useAnalytics.test.ts diff --git a/client-app/core/composables/useAnalytics.test.ts b/client-app/core/composables/useAnalytics.test.ts new file mode 100644 index 0000000000..6dd72d9c60 --- /dev/null +++ b/client-app/core/composables/useAnalytics.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useAnalytics } from "@/core/composables/useAnalytics"; +import { Logger } from "@/core/utilities"; +import type { CustomerOrderType, LineItemType, Product } from "../api/graphql/types"; +import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "@/core/types/analytics"; + +vi.mock("@/core/utilities", () => ({ + Logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("@/core/constants", () => ({ + IS_DEVELOPMENT: false, +})); + +describe("useAnalytics Composable", () => { + let analyticsInstance: ReturnType; + let addTracker: ReturnType["addTracker"]; + let trackEvent: ReturnType["analytics"]; + let mockTracker1: TackerType; + let mockTracker2: TackerType; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + analyticsInstance = useAnalytics(); + addTracker = analyticsInstance.addTracker; + trackEvent = 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" }], { someParam: "value" }]; + + trackEvent(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, { someParam: "value" }]; + + trackEvent(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"] = [{ id: "123" } as Product, { someParam: "value1" }]; + const args2: IAnalyticsEventMap["viewItem"] = [{ id: "321" } as Product, { someParam: "value2" }]; + + trackEvent(event, ...args1); + trackEvent(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 not dispatch events and log debug in development mode", async () => { + vi.doMock("@/core/constants", () => ({ + IS_DEVELOPMENT: true, + })); + + const { useAnalytics: useAnalyticsDev } = await import("@/core/composables/useAnalytics"); + const analyticsDev = useAnalyticsDev(); + const addTrackerDev = analyticsDev.addTracker; + const trackEventDev = analyticsDev.analytics; + + addTrackerDev(mockTracker1); + + const event: AnalyticsEventNameType = "addItemToCart"; + const args: IAnalyticsEventMap["addItemToCart"] = [{ id: "123" } as Product, 2, { someParam: "value" }]; + + trackEventDev(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 log a warning if a tracker does not handle the event", () => { + addTracker(mockTracker1); + + delete mockTracker1.purchase; + + const event: AnalyticsEventNameType = "purchase"; + const args: IAnalyticsEventMap["purchase"] = [ + { + id: "123", + } as CustomerOrderType, + "txn123", + { someParam: "value" }, + ]; + + trackEvent(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]; + + trackEvent(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"] = [ + { + id: "123", + } as Product, + { someParam: "value1" }, + ]; + + trackEvent(event1, ...args1); + + expect(partialTracker.viewItem).toHaveBeenCalledWith(...args1); + expect(Logger.warn).not.toHaveBeenCalled(); + + const event2: AnalyticsEventNameType = "purchase"; + const args2: IAnalyticsEventMap["purchase"] = [ + { + id: "123", + } as CustomerOrderType, + "txn123", + { someParam: "value2" }, + ]; + + trackEvent(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"] = [ + { + id: "123", + } as Product, + { someParam: "value" }, + ]; + + const loggerErrorSpy = vi.spyOn(Logger, "error"); + + trackEvent(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"] = [ + { + id: "123", + } as Product, + { someParam: "value" }, + ]; + + trackEvent(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"] = [ + { + id: "123", + } as Product, + { someParam: `value${i}` }, + ]; + trackEvent(event, ...args); + } + + trackers.forEach((tracker) => { + expect(tracker.viewItem).toHaveBeenCalledTimes(numEvents); + for (let i = 0; i < numEvents; i++) { + expect(tracker.viewItem).toHaveBeenNthCalledWith( + i + 1, + { + id: "123", + } as Product, + { someParam: `value${i}` }, + ); + } + }); + + 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 index 8aec1434d7..ea5dfaab5f 100644 --- a/client-app/core/composables/useAnalytics.ts +++ b/client-app/core/composables/useAnalytics.ts @@ -4,23 +4,27 @@ import { Logger } from "@/core/utilities"; import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "../types/analytics"; function _useAnalytics() { - const trackers: TackerType[] = []; + const trackers: Set = new Set(); function addTracker(tracker: TackerType): void { - trackers.push(tracker); + trackers.add(tracker); } function analytics(event: E, ...args: IAnalyticsEventMap[E]): void { if (IS_DEVELOPMENT) { - Logger.debug(`${useAnalytics.name}, can't track event in development mode`); + Logger.debug("useAnalytics, can't track event in development mode"); return; } trackers.forEach((tracker) => { const handler = tracker[event]; if (handler) { - handler(...args); + try { + handler(...args); + } catch (error) { + Logger.error(`useAnalytics, error calling event: "${event}" in tracker.`, error); + } } else { - Logger.warn(`${useAnalytics.name}, unsupported event: "${event}" in tracker.`); + Logger.warn(`useAnalytics, unsupported event: "${event}" in tracker.`); } }); } diff --git a/client-app/core/types/analytics.ts b/client-app/core/types/analytics.ts index d1a798a532..97b5c9b1b7 100644 --- a/client-app/core/types/analytics.ts +++ b/client-app/core/types/analytics.ts @@ -25,5 +25,5 @@ export type ViewItemListParamsAdditionalType = { item_list_id?: string; item_lis export type EventParamsType = Record; export type TackerType = Partial<{ - [K in AnalyticsEventNameType]: (...args: IAnalyticsEventMap[K]) => void; + [K in AnalyticsEventNameType]: (...args: IAnalyticsEventMap[K]) => void | Promise; }>; From b37fe2d48808069ca47ab31c661ba11f99282cde Mon Sep 17 00:00:00 2001 From: Ivan Kalachikov Date: Thu, 12 Dec 2024 16:56:52 +0100 Subject: [PATCH 07/10] feat: add unit tests --- .../core/composables/useAnalytics.test.ts | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/client-app/core/composables/useAnalytics.test.ts b/client-app/core/composables/useAnalytics.test.ts index 6dd72d9c60..617c17d2ac 100644 --- a/client-app/core/composables/useAnalytics.test.ts +++ b/client-app/core/composables/useAnalytics.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { useAnalytics } from "@/core/composables/useAnalytics"; 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", () => ({ @@ -9,28 +9,28 @@ vi.mock("@/core/utilities", () => ({ debug: vi.fn(), warn: vi.fn(), error: vi.fn(), - info: vi.fn(), }, })); -vi.mock("@/core/constants", () => ({ - IS_DEVELOPMENT: false, -})); - describe("useAnalytics Composable", () => { - let analyticsInstance: ReturnType; - let addTracker: ReturnType["addTracker"]; - let trackEvent: ReturnType["analytics"]; + let analyticsInstance: ReturnType; + let addTracker: ReturnType["addTracker"]; + let analytics: ReturnType["analytics"]; let mockTracker1: TackerType; let mockTracker2: TackerType; - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); + vi.doUnmock("@/core/constants"); vi.clearAllMocks(); + vi.doMock("@/core/constants", () => ({ + IS_DEVELOPMENT: false, + })); + const { useAnalytics } = await import("@/core/composables/useAnalytics"); analyticsInstance = useAnalytics(); addTracker = analyticsInstance.addTracker; - trackEvent = analyticsInstance.analytics; + analytics = analyticsInstance.analytics; mockTracker1 = { viewItemList: vi.fn(), @@ -75,7 +75,7 @@ describe("useAnalytics Composable", () => { const event: AnalyticsEventNameType = "viewItemList"; const args: IAnalyticsEventMap["viewItemList"] = [[{ code: "item1" }], { someParam: "value" }]; - trackEvent(event, ...args); + analytics(event, ...args); expect(mockTracker1.viewItemList).toHaveBeenCalledWith(...args); expect(Logger.debug).not.toHaveBeenCalled(); @@ -89,7 +89,7 @@ describe("useAnalytics Composable", () => { const event: AnalyticsEventNameType = "selectItem"; const args: IAnalyticsEventMap["selectItem"] = [{ productId: "123" } as LineItemType, { someParam: "value" }]; - trackEvent(event, ...args); + analytics(event, ...args); expect(mockTracker1.selectItem).toHaveBeenCalledWith(...args); expect(mockTracker2.selectItem).toHaveBeenCalledWith(...args); @@ -104,8 +104,8 @@ describe("useAnalytics Composable", () => { const args1: IAnalyticsEventMap["viewItem"] = [{ id: "123" } as Product, { someParam: "value1" }]; const args2: IAnalyticsEventMap["viewItem"] = [{ id: "321" } as Product, { someParam: "value2" }]; - trackEvent(event, ...args1); - trackEvent(event, ...args2); + analytics(event, ...args1); + analytics(event, ...args2); expect(mockTracker1.viewItem).toHaveBeenCalledTimes(2); expect(mockTracker1.viewItem).toHaveBeenCalledWith(...args1); @@ -114,28 +114,6 @@ describe("useAnalytics Composable", () => { expect(Logger.warn).not.toHaveBeenCalled(); }); - it("should not dispatch events and log debug in development mode", async () => { - vi.doMock("@/core/constants", () => ({ - IS_DEVELOPMENT: true, - })); - - const { useAnalytics: useAnalyticsDev } = await import("@/core/composables/useAnalytics"); - const analyticsDev = useAnalyticsDev(); - const addTrackerDev = analyticsDev.addTracker; - const trackEventDev = analyticsDev.analytics; - - addTrackerDev(mockTracker1); - - const event: AnalyticsEventNameType = "addItemToCart"; - const args: IAnalyticsEventMap["addItemToCart"] = [{ id: "123" } as Product, 2, { someParam: "value" }]; - - trackEventDev(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 log a warning if a tracker does not handle the event", () => { addTracker(mockTracker1); @@ -150,7 +128,7 @@ describe("useAnalytics Composable", () => { { someParam: "value" }, ]; - trackEvent(event, ...args); + analytics(event, ...args); expect(mockTracker1.purchase).toBeUndefined(); expect(Logger.warn).toHaveBeenCalledWith('useAnalytics, unsupported event: "purchase" in tracker.'); @@ -163,7 +141,7 @@ describe("useAnalytics Composable", () => { const event: AnalyticsEventNameType = "search"; const args: IAnalyticsEventMap["search"] = ["query", [{ code: "item1" }], 1]; - trackEvent(event, ...args); + analytics(event, ...args); expect(mockTracker1.search).toHaveBeenCalledTimes(1); expect(mockTracker1.search).toHaveBeenCalledWith(...args); @@ -185,7 +163,7 @@ describe("useAnalytics Composable", () => { { someParam: "value1" }, ]; - trackEvent(event1, ...args1); + analytics(event1, ...args1); expect(partialTracker.viewItem).toHaveBeenCalledWith(...args1); expect(Logger.warn).not.toHaveBeenCalled(); @@ -199,7 +177,7 @@ describe("useAnalytics Composable", () => { { someParam: "value2" }, ]; - trackEvent(event2, ...args2); + analytics(event2, ...args2); expect(partialTracker.purchase).toBeUndefined(); expect(Logger.warn).toHaveBeenCalledWith('useAnalytics, unsupported event: "purchase" in tracker.'); @@ -229,7 +207,7 @@ describe("useAnalytics Composable", () => { const loggerErrorSpy = vi.spyOn(Logger, "error"); - trackEvent(event, ...args); + analytics(event, ...args); expect(faultyTracker.viewItem).toHaveBeenCalledWith(...args); expect(normalTracker.viewItem).toHaveBeenCalledWith(...args); @@ -245,7 +223,7 @@ describe("useAnalytics Composable", () => { { someParam: "value" }, ]; - trackEvent(event, ...args); + analytics(event, ...args); expect(mockTracker1.viewItem).not.toHaveBeenCalled(); expect(Logger.warn).not.toHaveBeenCalled(); @@ -270,7 +248,7 @@ describe("useAnalytics Composable", () => { } as Product, { someParam: `value${i}` }, ]; - trackEvent(event, ...args); + analytics(event, ...args); } trackers.forEach((tracker) => { @@ -289,4 +267,26 @@ describe("useAnalytics Composable", () => { 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"] = [{ id: "123" } as Product, 2, { someParam: "value" }]; + + 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(); + }); }); From 9d16210988be4bfdb98c2d050b4fbd9a774d5dc4 Mon Sep 17 00:00:00 2001 From: Ivan Kalachikov Date: Thu, 12 Dec 2024 17:18:12 +0100 Subject: [PATCH 08/10] feat: add unit tests --- .../core/composables/useAnalytics.test.ts | 28 +++++++++++++++---- client-app/core/composables/useAnalytics.ts | 2 +- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/client-app/core/composables/useAnalytics.test.ts b/client-app/core/composables/useAnalytics.test.ts index 617c17d2ac..a90cf3256a 100644 --- a/client-app/core/composables/useAnalytics.test.ts +++ b/client-app/core/composables/useAnalytics.test.ts @@ -4,7 +4,7 @@ import type { CustomerOrderType, LineItemType, Product } from "../api/graphql/ty import type { useAnalytics as useAnalyticsType } from "@/core/composables/useAnalytics"; import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "@/core/types/analytics"; -vi.mock("@/core/utilities", () => ({ +vi.mock("@/core/utilities/logger", () => ({ Logger: { debug: vi.fn(), warn: vi.fn(), @@ -12,7 +12,11 @@ vi.mock("@/core/utilities", () => ({ }, })); -describe("useAnalytics Composable", () => { +vi.mock("@/core/constants", () => ({ + IS_DEVELOPMENT: false, +})); + +describe("useAnalytics", () => { let analyticsInstance: ReturnType; let addTracker: ReturnType["addTracker"]; let analytics: ReturnType["analytics"]; @@ -23,9 +27,7 @@ describe("useAnalytics Composable", () => { vi.resetModules(); vi.doUnmock("@/core/constants"); vi.clearAllMocks(); - vi.doMock("@/core/constants", () => ({ - IS_DEVELOPMENT: false, - })); + const { useAnalytics } = await import("@/core/composables/useAnalytics"); analyticsInstance = useAnalytics(); @@ -289,4 +291,20 @@ describe("useAnalytics Composable", () => { 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"] = [ + { + id: "123", + } as Product, + { someParam: "value" }, + ]; + + 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 index ea5dfaab5f..3cce972177 100644 --- a/client-app/core/composables/useAnalytics.ts +++ b/client-app/core/composables/useAnalytics.ts @@ -1,6 +1,6 @@ import { createGlobalState } from "@vueuse/core"; import { IS_DEVELOPMENT } from "@/core/constants"; -import { Logger } from "@/core/utilities"; +import { Logger } from "@/core/utilities/logger"; import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "../types/analytics"; function _useAnalytics() { From fe632c0e191788a452337d7b9facf10f329aa4ae Mon Sep 17 00:00:00 2001 From: Ivan Kalachikov Date: Thu, 12 Dec 2024 17:25:24 +0100 Subject: [PATCH 09/10] feat: add unit tests --- .../core/composables/useAnalytics.test.ts | 79 ++++++------------- 1 file changed, 23 insertions(+), 56 deletions(-) diff --git a/client-app/core/composables/useAnalytics.test.ts b/client-app/core/composables/useAnalytics.test.ts index a90cf3256a..fe158c7737 100644 --- a/client-app/core/composables/useAnalytics.test.ts +++ b/client-app/core/composables/useAnalytics.test.ts @@ -16,6 +16,16 @@ 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"]; @@ -75,7 +85,7 @@ describe("useAnalytics", () => { addTracker(mockTracker1); const event: AnalyticsEventNameType = "viewItemList"; - const args: IAnalyticsEventMap["viewItemList"] = [[{ code: "item1" }], { someParam: "value" }]; + const args: IAnalyticsEventMap["viewItemList"] = [[{ code: "item1" }], arbitraryParam]; analytics(event, ...args); @@ -89,7 +99,7 @@ describe("useAnalytics", () => { addTracker(mockTracker2); const event: AnalyticsEventNameType = "selectItem"; - const args: IAnalyticsEventMap["selectItem"] = [{ productId: "123" } as LineItemType, { someParam: "value" }]; + const args: IAnalyticsEventMap["selectItem"] = [{ productId: "123" } as LineItemType, arbitraryParam]; analytics(event, ...args); @@ -103,8 +113,8 @@ describe("useAnalytics", () => { addTracker(mockTracker1); const event: AnalyticsEventNameType = "viewItem"; - const args1: IAnalyticsEventMap["viewItem"] = [{ id: "123" } as Product, { someParam: "value1" }]; - const args2: IAnalyticsEventMap["viewItem"] = [{ id: "321" } as Product, { someParam: "value2" }]; + const args1: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value1" }]; + const args2: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value2" }]; analytics(event, ...args1); analytics(event, ...args2); @@ -122,13 +132,7 @@ describe("useAnalytics", () => { delete mockTracker1.purchase; const event: AnalyticsEventNameType = "purchase"; - const args: IAnalyticsEventMap["purchase"] = [ - { - id: "123", - } as CustomerOrderType, - "txn123", - { someParam: "value" }, - ]; + const args: IAnalyticsEventMap["purchase"] = [mockedCustomerOrder, "txn123", arbitraryParam]; analytics(event, ...args); @@ -158,12 +162,7 @@ describe("useAnalytics", () => { addTracker(partialTracker); const event1: AnalyticsEventNameType = "viewItem"; - const args1: IAnalyticsEventMap["viewItem"] = [ - { - id: "123", - } as Product, - { someParam: "value1" }, - ]; + const args1: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value1" }]; analytics(event1, ...args1); @@ -171,13 +170,7 @@ describe("useAnalytics", () => { expect(Logger.warn).not.toHaveBeenCalled(); const event2: AnalyticsEventNameType = "purchase"; - const args2: IAnalyticsEventMap["purchase"] = [ - { - id: "123", - } as CustomerOrderType, - "txn123", - { someParam: "value2" }, - ]; + const args2: IAnalyticsEventMap["purchase"] = [mockedCustomerOrder, "txn123", { someParam: "value2" }]; analytics(event2, ...args2); @@ -200,12 +193,7 @@ describe("useAnalytics", () => { addTracker(normalTracker); const event: AnalyticsEventNameType = "viewItem"; - const args: IAnalyticsEventMap["viewItem"] = [ - { - id: "123", - } as Product, - { someParam: "value" }, - ]; + const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam]; const loggerErrorSpy = vi.spyOn(Logger, "error"); @@ -218,12 +206,7 @@ describe("useAnalytics", () => { it("should not dispatch events and not log warnings when no trackers are added", () => { const event: AnalyticsEventNameType = "viewItem"; - const args: IAnalyticsEventMap["viewItem"] = [ - { - id: "123", - } as Product, - { someParam: "value" }, - ]; + const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam]; analytics(event, ...args); @@ -244,25 +227,14 @@ describe("useAnalytics", () => { const numEvents = 100; for (let i = 0; i < numEvents; i++) { const event: AnalyticsEventNameType = "viewItem"; - const args: IAnalyticsEventMap["viewItem"] = [ - { - id: "123", - } as Product, - { someParam: `value${i}` }, - ]; + 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, - { - id: "123", - } as Product, - { someParam: `value${i}` }, - ); + expect(tracker.viewItem).toHaveBeenNthCalledWith(i + 1, mockedProduct, { someParam: `value${i}` }); } }); @@ -283,7 +255,7 @@ describe("useAnalytics", () => { addTrackerDev(mockTracker1); const event: AnalyticsEventNameType = "addItemToCart"; - const args: IAnalyticsEventMap["addItemToCart"] = [{ id: "123" } as Product, 2, { someParam: "value" }]; + const args: IAnalyticsEventMap["addItemToCart"] = [mockedProduct, 2, arbitraryParam]; analyticsDev(event, ...args); @@ -294,12 +266,7 @@ describe("useAnalytics", () => { it("should not dispatch events and not log warnings when no trackers are added", () => { const event: AnalyticsEventNameType = "viewItem"; - const args: IAnalyticsEventMap["viewItem"] = [ - { - id: "123", - } as Product, - { someParam: "value" }, - ]; + const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam]; analytics(event, ...args); From 924ae9465f227e9c2309459555422dc079eae656 Mon Sep 17 00:00:00 2001 From: Ivan Kalachikov Date: Thu, 12 Dec 2024 17:34:58 +0100 Subject: [PATCH 10/10] fix: sonar issues --- client-app/modules/google-analytics/events.ts | 10 +++++----- client-app/modules/google-analytics/utils.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client-app/modules/google-analytics/events.ts b/client-app/modules/google-analytics/events.ts index 744a3af0b5..d941beb70b 100644 --- a/client-app/modules/google-analytics/events.ts +++ b/client-app/modules/google-analytics/events.ts @@ -8,14 +8,14 @@ import type { TackerType } from "@/core/types/analytics"; const { currencyCode } = globals; export const analytics: TackerType = { - viewItemList(items = [], params) { + viewItemList(items, params) { sendEvent("view_item_list", { ...params, - items_skus: items + items_skus: (items ?? []) .map((el) => el.code) .join(", ") .trim(), - items_count: items.length, + items_count: items?.length ?? 0, }); }, @@ -45,13 +45,13 @@ export const analytics: TackerType = { }); }, - addItemToCart(item, quantity = 1, params) { + 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, + value: (item.price?.actual?.amount || 0) * (quantity ?? 1), items: [inputItem], }); }, diff --git a/client-app/modules/google-analytics/utils.ts b/client-app/modules/google-analytics/utils.ts index 65c8230f79..bc80d45e45 100644 --- a/client-app/modules/google-analytics/utils.ts +++ b/client-app/modules/google-analytics/utils.ts @@ -44,7 +44,7 @@ export function lineItemToGtagItem( index, item_id: item.sku, item_name: item.name, - affiliation: item.vendor?.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,