Skip to content

Commit

Permalink
feat: google analytics module (#1170)
Browse files Browse the repository at this point in the history
  • Loading branch information
ivan-kalachikov committed Jul 15, 2024
1 parent 62f8c15 commit 7194cf2
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 360 deletions.
2 changes: 1 addition & 1 deletion client-app/app-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default async () => {

await Promise.all([fetchThemeContext(store), fetchUser(), fallback.setMessage()]);

initializeGoogleAnalytics();
void initializeGoogleAnalytics();
void initializeHotjar();

/**
Expand Down
352 changes: 31 additions & 321 deletions client-app/core/composables/useGoogleAnalytics.ts
Original file line number Diff line number Diff line change
@@ -1,337 +1,47 @@
import { canUseDOM } from "@apollo/client/utilities";
import { useScriptTag } from "@vueuse/core";
import { sumBy } from "lodash";
import { useCurrency } from "@/core/composables/useCurrency";
import { useThemeContext } from "@/core/composables/useThemeContext";
import { useModuleSettings } from "@/core/composables/useModuleSettings";
import { IS_DEVELOPMENT } from "@/core/constants";
import { globals } from "@/core/globals";
import { Logger } from "@/core/utilities";
import { globals } from "../globals";
import type {
Breadcrumb,
CartType,
CustomerOrderType,
LineItemType,
OrderLineItemType,
Product,
VariationType,
} from "@/core/api/graphql/types";

/**
* Custom events. The items array can not be added
*/
type CustomEventNamesType = "place_order" | "clear_cart";
type EventParamsType = Gtag.ControlParams & Gtag.EventParams & Gtag.CustomParams;
type EventParamsExtendedType = EventParamsType & { item_list_id?: string; item_list_name?: string };
const MODULE_ID = "VirtoCommerce.GoogleEcommerceAnalytics";
const IS_ENABLED_KEY = "GoogleAnalytics4.EnableTracking";

const { currentCurrency } = useCurrency();
const { modulesSettings } = useThemeContext();

const DEBUG_PREFIX = "[GA]";

const MODULE_KEYS = {
ID: "VirtoCommerce.GoogleEcommerceAnalytics",
ENABLE_STATE: "GoogleAnalytics4.EnableTracking",
TRACK_ID: "GoogleAnalytics4.MeasurementId",
};

function getCategories(breadcrumbs: Breadcrumb[] = []): Record<string, string> {
const categories: Record<string, string> = {};

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;
}

function productToGtagItem(item: Product | VariationType, index?: number): Gtag.Item {
const categories: Record<string, string> = "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,
};
}

function lineItemToGtagItem(item: LineItemType | OrderLineItemType, index?: number): Gtag.Item {
const categories: Record<string, string> = 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,
...categories,
};
}

/** @deprecated use direct mapping */
function getCartEventParams(cart: CartType): EventParamsType {
return {
currency: globals.currencyCode,
value: cart.total.amount,
items: cart.items.map(lineItemToGtagItem),
items_count: cart.items.length,
};
}

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);
}
}

function viewItemList(items: { code: string }[] = [], params?: EventParamsExtendedType): void {
sendEvent("view_item_list", {
...params,
items_skus: items
.map((el) => el.code)
.join(", ")
.trim(),
items_count: items.length,
});
}

function selectItem(item: Product | LineItemType, params?: EventParamsExtendedType): void {
const gtagItem = "productId" in item ? lineItemToGtagItem(item) : productToGtagItem(item);

sendEvent("select_item", {
...params,
items: [gtagItem],
});
}

function viewItem(item: Product, params?: EventParamsExtendedType): void {
sendEvent("view_item", {
...params,
currency: globals.currencyCode,
value: item.price?.actual?.amount,
items: [productToGtagItem(item)],
});
}

function addItemToWishList(item: Product, params?: EventParamsExtendedType): void {
sendEvent("add_to_wishlist", {
...params,
currency: globals.currencyCode,
value: item.price?.actual?.amount,
items: [productToGtagItem(item)],
});
}

function addItemToCart(item: Product | VariationType, quantity = 1, params?: EventParamsExtendedType): void {
const inputItem = productToGtagItem(item);

inputItem.quantity = quantity;

sendEvent("add_to_cart", {
...params,
currency: globals.currencyCode,
value: item.price?.actual?.amount * quantity,
items: [inputItem],
});
}
const { getModuleSettings, hasModuleSettings, isEnabled } = useModuleSettings(MODULE_ID);

function addItemsToCart(items: (Product | VariationType)[], params?: EventParamsExtendedType): 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: globals.currencyCode,
value: subtotal,
items: inputItems,
items_count: inputItems.length,
});
}

function removeItemsFromCart(items: LineItemType[], params?: EventParamsExtendedType): void {
const subtotal: number = sumBy(items, (item) => item.extendedPrice?.amount);
const inputItems = items.map((item) => lineItemToGtagItem(item));

sendEvent("remove_from_cart", {
...params,
currency: globals.currencyCode,
value: subtotal,
items: inputItems,
items_count: inputItems.length,
});
}

function viewCart(cart: CartType, params?: EventParamsExtendedType): void {
const cartEventParams: EventParamsType = getCartEventParams(cart);

sendEvent("view_cart", {
...params,
...cartEventParams,
});
}

function clearCart(cart: CartType, params?: EventParamsExtendedType): void {
const cartEventParams: EventParamsType = getCartEventParams(cart);

sendEvent("clear_cart", {
...params,
...cartEventParams,
});
}

function beginCheckout(cart: CartType, params?: EventParamsExtendedType): 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);
}
}

function addShippingInfo(cart?: CartType, params?: EventParamsExtendedType, 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);
}
}

function addPaymentInfo(cart?: CartType, params?: EventParamsExtendedType, 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);
}
}

function purchase(order: CustomerOrderType, transactionId?: string, params?: EventParamsExtendedType): 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);
}
}

function placeOrder(order: CustomerOrderType, params?: EventParamsExtendedType): 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);
}
}

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(),
});
}

function init() {
if (!canUseDOM) {
return;
}
const { currentCurrency } = useCurrency();
const { currencyCode } = globals;

const moduleSettings = modulesSettings.value?.find((el) => el.moduleId === MODULE_KEYS.ID);
const isGoogleAnalyticsEnabled = !!moduleSettings?.settings?.find((el) => el.name === MODULE_KEYS.ENABLE_STATE)
?.value;
type GoogleAnalyticsMethodsType = ReturnType<
typeof import("@virto-commerce/front-modules-google-ecommerce-analytics").useGoogleAnalyticsModule
>;
let googleAnalyticsMethods: Omit<GoogleAnalyticsMethodsType, "initModule">;

if (isGoogleAnalyticsEnabled) {
const id = moduleSettings?.settings?.find((el) => el.name === MODULE_KEYS.TRACK_ID)?.value as string;
if (!IS_DEVELOPMENT) {
useScriptTag(`https://www.googletagmanager.com/gtag/js?id=${id}`);
} else {
Logger.debug(DEBUG_PREFIX, "initialized without sync with google");
export function useGoogleAnalytics() {
async function init(): Promise<void> {
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;
} catch (e) {
Logger.error(useGoogleAnalytics.name, e);
}
}

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", id, { debug_mode: true });
window.gtag("set", { currency: currentCurrency.value.code });
}
}

export function useGoogleAnalytics() {
return {
sendEvent,
viewItemList,
selectItem,
viewItem,
addItemToWishList,
addItemToCart,
addItemsToCart,
removeItemsFromCart,
viewCart,
clearCart,
beginCheckout,
addShippingInfo,
addPaymentInfo,
purchase,
placeOrder,
search,
init,
...googleAnalyticsMethods,
};
}
2 changes: 1 addition & 1 deletion client-app/core/composables/useModuleSettings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createGlobalState } from "@vueuse/core";
import { computed } from "vue";
import { useThemeContext } from "@/core/composables";
import { useThemeContext } from "@/core/composables/useThemeContext";

function _useModuleSettings(moduleId: string) {
const { themeContext } = useThemeContext();
Expand Down
Loading

0 comments on commit 7194cf2

Please sign in to comment.