support-frontend/assets/helpers/tracking/quantumMetric.ts (425 lines of code) (raw):

import { loadScript } from '@guardian/libs'; import { viewId } from 'ophan'; import type { Participations } from 'helpers/abTests/models'; import type { ContributionType } from 'helpers/contributions'; import type { PaymentMethod } from 'helpers/forms/paymentMethods'; import type { IsoCurrency } from 'helpers/internationalisation/currency'; import type { ActiveProductKey } from 'helpers/productCatalog'; import type { BillingPeriod } from 'helpers/productPrice/billingPeriods'; import type { ProductPrice } from 'helpers/productPrice/productPrices'; import type { SubscriptionProduct } from 'helpers/productPrice/subscriptions'; import { logException } from 'helpers/utilities/logger'; import type { ReferrerAcquisitionData } from './acquisitions'; import { canRunQuantumMetric, getContributionAnnualValue, getConvertedAnnualValue, getConvertedValue, getSubscriptionAnnualValue, waitForQuantumMetricAPi, } from './quantumMetricHelpers'; // ---- Types ---- // type SendEventTestParticipationId = 30; type SendEventPageViewId = 181; type SendEventCheckoutValueId = 182; type SendEventCheckoutConversionId = 183; enum SendEventAcquisitionDataFromQueryParam { Source = 94, ComponentId = 95, ComponentType = 96, CampaignCode = 97, ReferrerUrl = 99, IsRemote = 100, } enum SendEventSubscriptionCheckoutStart { DigiSub = 75, PaperSub = 76, GuardianWeeklySub = 77, GuardianWeeklySubGift = 79, } enum SendEventSubscriptionCheckoutConversion { DigiSub = 31, PaperSub = 67, GuardianWeeklySub = 68, GuardianWeeklySubGift = 70, } enum SendEventContributionAmountUpdate { SingleContribution = 71, RecurringContribution = 72, } enum SendEventContributionPaymentMethodUpdate { PaymentMethod = 103, PaymentMethodAtConversion = 110, } enum SendEventContributionCheckoutConversion { SingleContribution = 73, RecurringContribution = 74, } type SendEventId = | SendEventTestParticipationId | SendEventSubscriptionCheckoutStart | SendEventSubscriptionCheckoutConversion | SendEventContributionAmountUpdate | SendEventContributionCheckoutConversion | SendEventContributionPaymentMethodUpdate | SendEventAcquisitionDataFromQueryParam | SendEventPageViewId | SendEventCheckoutValueId | SendEventCheckoutConversionId; // ---- sendEvent logic ---- // const { DigiSub, PaperSub, GuardianWeeklySub, GuardianWeeklySubGift } = SendEventSubscriptionCheckoutStart; const { SingleContribution, RecurringContribution } = SendEventContributionAmountUpdate; const cartValueEventIds: SendEventId[] = [ DigiSub, PaperSub, GuardianWeeklySub, GuardianWeeklySubGift, SingleContribution, RecurringContribution, ]; async function ifQmPermitted(callback: () => void) { const canRun = await canRunQuantumMetric(); if (canRun) { callback(); } } function sendEvent( id: SendEventId, isConversion: boolean, value: string, payload?: Record<string, unknown>, ): void { /** * A cart value event is indicated by 64 in QM. * A non cart value event is indicated by 0 in QM. * And a conversion event is indicated by 1 in QM. */ const qmCartValueEventId = isConversion ? 1 : cartValueEventIds.includes(id) ? 64 : 0; if (window.QuantumMetricAPI?.isOn()) { window.QuantumMetricAPI.sendEvent( id, qmCartValueEventId, value, payload ?? {}, ); } } function sendEventWhenReadyTrigger(sendEventWhenReady: () => void): void { /** * Quantum Metric's script sets up QuantumMetricAPI. * We need to check it is defined and ready before we can * send events to it. If it is ready we call sendEventWhenReady * immediately. If it is not ready we poll a function that checks * if QuantumMetricAPI is available. Once it's available we * call sendEventWhenReady. */ if (window.QuantumMetricAPI?.isOn()) { sendEventWhenReady(); } else { waitForQuantumMetricAPi(() => { sendEventWhenReady(); }); } } function sendEventAcquisitionDataFromQueryParamEvent( acquisitionData: ReferrerAcquisitionData, ): void { void ifQmPermitted(() => { const sendEventWhenReady = () => { type ReferrerAcquisitionDataKeysToLogType = Record<string, number>; const acquisitionDataKeysToLog: ReferrerAcquisitionDataKeysToLogType = { source: SendEventAcquisitionDataFromQueryParam.Source, componentId: SendEventAcquisitionDataFromQueryParam.ComponentId, componentType: SendEventAcquisitionDataFromQueryParam.ComponentType, campaignCode: SendEventAcquisitionDataFromQueryParam.CampaignCode, referrerUrl: SendEventAcquisitionDataFromQueryParam.ReferrerUrl, isRemote: SendEventAcquisitionDataFromQueryParam.IsRemote, }; Object.keys(acquisitionDataKeysToLog).forEach((key) => { const acquisitionDataValueToLog = acquisitionData[key as keyof ReferrerAcquisitionData]?.toString(); if (acquisitionDataValueToLog && acquisitionDataKeysToLog[key]) { sendEvent( acquisitionDataKeysToLog[key] ?? 0, false, acquisitionDataValueToLog.toString(), ); } }); }; sendEventWhenReadyTrigger(sendEventWhenReady); }); } function sendEventSubscriptionCheckoutEvent( id: | SendEventSubscriptionCheckoutStart | SendEventSubscriptionCheckoutConversion, productPrice: ProductPrice, billingPeriod: BillingPeriod, isConversion: boolean, ): void { void ifQmPermitted(() => { const sendEventWhenReady = () => { const sourceCurrency = productPrice.currency; const targetCurrency: IsoCurrency = 'GBP'; const value = getSubscriptionAnnualValue(productPrice, billingPeriod); if (!value) { return; } else if (window.QuantumMetricAPI?.isOn()) { const convertedValue: number = window.QuantumMetricAPI.currencyConvertFromToValue( value, sourceCurrency, targetCurrency, ); sendEvent(id, isConversion, Math.round(convertedValue).toString()); } }; sendEventWhenReadyTrigger(sendEventWhenReady); }); } function productToCheckoutEvents( product: SubscriptionProduct, orderIsAGift: boolean, ): | { start: SendEventSubscriptionCheckoutStart; conversion: SendEventSubscriptionCheckoutConversion; } | undefined { switch (product) { case 'DigitalPack': return checkoutEvents( SendEventSubscriptionCheckoutStart.DigiSub, SendEventSubscriptionCheckoutConversion.DigiSub, ); case 'GuardianWeekly': return orderIsAGift ? checkoutEvents( SendEventSubscriptionCheckoutStart.GuardianWeeklySubGift, SendEventSubscriptionCheckoutConversion.GuardianWeeklySubGift, ) : checkoutEvents( SendEventSubscriptionCheckoutStart.GuardianWeeklySub, SendEventSubscriptionCheckoutConversion.GuardianWeeklySub, ); case 'Paper': case 'PaperAndDigital': return checkoutEvents( SendEventSubscriptionCheckoutStart.PaperSub, SendEventSubscriptionCheckoutConversion.PaperSub, ); default: return; } } function checkoutEvents( start: SendEventSubscriptionCheckoutStart, conversion: SendEventSubscriptionCheckoutConversion, ) { return { start, conversion }; } function sendEventSubscriptionCheckoutStart( product: SubscriptionProduct, orderIsAGift: boolean, productPrice: ProductPrice, billingPeriod: BillingPeriod, ): void { const sendEventIds = productToCheckoutEvents(product, orderIsAGift); if (sendEventIds) { sendEventSubscriptionCheckoutEvent( sendEventIds.start, productPrice, billingPeriod, false, ); } } function sendEventSubscriptionCheckoutConversion( product: SubscriptionProduct, orderIsAGift: boolean, productPrice: ProductPrice, billingPeriod: BillingPeriod, ): void { const sendEventIds = productToCheckoutEvents(product, orderIsAGift); if (sendEventIds) { sendEventSubscriptionCheckoutEvent( sendEventIds.conversion, productPrice, billingPeriod, true, ); } } function sendEventOneTimeCheckoutValue( amount: number, sourceCurrency: IsoCurrency, isConversion?: boolean, ): void { void ifQmPermitted(() => { const sendEventWhenReady = () => { const sendEventId = isConversion ? 183 : 182; const convertedValue = getConvertedValue(amount, sourceCurrency); const payload = { product: 'ONE-OFF', }; if (convertedValue) { sendEvent( sendEventId, !!isConversion, Math.round(convertedValue).toString(), payload, ); } }; sendEventWhenReadyTrigger(sendEventWhenReady); }); } function sendEventCheckoutValue( amount: number, product: ActiveProductKey, billingPeriod: BillingPeriod, sourceCurrency: IsoCurrency, isConversion?: boolean, ): void { void ifQmPermitted(() => { const sendEventWhenReady = () => { const sendEventId = isConversion ? 183 : 182; const convertedValue = getConvertedAnnualValue( billingPeriod, amount, sourceCurrency, ); const payload = { product, billingPeriod, }; if (convertedValue) { sendEvent( sendEventId, !!isConversion, Math.round(convertedValue).toString(), payload, ); } }; sendEventWhenReadyTrigger(sendEventWhenReady); }); } // TODO: To be deleted with the 2-step checkout function sendEventContributionCheckoutConversion( amount: number, contributionType: ContributionType, sourceCurrency: IsoCurrency, ): void { void ifQmPermitted(() => { const sendEventWhenReady = () => { const sendEventId = contributionType === 'ONE_OFF' ? SendEventContributionCheckoutConversion.SingleContribution : SendEventContributionCheckoutConversion.RecurringContribution; const convertedValue = getContributionAnnualValue( contributionType, amount, sourceCurrency, ); if (convertedValue) { sendEvent(sendEventId, true, Math.round(convertedValue).toString()); } }; sendEventWhenReadyTrigger(sendEventWhenReady); }); } // TODO: To be deleted with the 2-step checkout function sendEventContributionCartValue( amount: string, contributionType: ContributionType, sourceCurrency: IsoCurrency, ): void { if (amount === 'other' || Number.isNaN(parseInt(amount))) { return; } void ifQmPermitted(() => { const sendEventWhenReady = () => { const sendEventId = contributionType === 'ONE_OFF' ? SendEventContributionAmountUpdate.SingleContribution : SendEventContributionAmountUpdate.RecurringContribution; const convertedValue = getContributionAnnualValue( contributionType, parseInt(amount), sourceCurrency, ); if (convertedValue) { sendEvent(sendEventId, false, Math.round(convertedValue).toString()); } }; sendEventWhenReadyTrigger(sendEventWhenReady); }); } function sendEventPaymentMethodSelected( paymentMethod: PaymentMethod | 'StripeExpressCheckoutElement' | null, ): void { if (paymentMethod) { void ifQmPermitted(() => { const sendEventWhenReady = () => { const sendEventId = SendEventContributionPaymentMethodUpdate.PaymentMethod; sendEvent(sendEventId, false, paymentMethod.toString()); }; sendEventWhenReadyTrigger(sendEventWhenReady); }); } } function sendEventABTestParticipations(participations: Participations): void { const sendEventABTestId: SendEventTestParticipationId = 30; const valueQueue: string[] = []; Object.keys(participations).forEach((testId) => { const value = `${testId}-${participations[testId]}`; /** * Quantum Metric's script sets up QuantumMetricAPI * We need to check it is defined and ready before we can * send events to it. If it is not ready we add the events to * a valueQueue to be processed later. */ if (window.QuantumMetricAPI?.isOn()) { sendEvent(sendEventABTestId, false, value); } else { valueQueue.push(value); } }); /** * If valueQueue is populated QuantumMetricAPI was not ready to be * sent events, in this scenario we poll a function that checks if * QuantumMetricAPI is available. Once it's available we process the * queue of values to be sent with sendEvent. */ if (valueQueue.length) { waitForQuantumMetricAPi(() => { valueQueue.forEach((value) => { sendEvent(sendEventABTestId, false, value); }); }); } } function sendEventPageViewId(): void { const sendEventPageViewId: SendEventPageViewId = 181; void ifQmPermitted(() => { const sendEventWhenReady = () => { sendEvent(sendEventPageViewId, false, viewId); }; sendEventWhenReadyTrigger(sendEventWhenReady); }); } // ---- initialisation logic ---- // function addQM() { return loadScript( 'https://cdn.quantummetric.com/instrumentation/1.35.4/quantum-gnm.js', { async: true, integrity: 'sha384-VMLIC70VzACtZAEkPaL+7xW+v0+UjkIUuGxlArtIG+Pzqlp5DkbfVG9tRm75Liwx', crossOrigin: 'anonymous', }, ).catch(() => { logException('Failed to load Quantum Metric'); }); } function init( participations: Participations, acquisitionData: ReferrerAcquisitionData, ): void { void ifQmPermitted(() => { void addQM().then(() => { /** * Quantum Metric's script has loaded so we can attempt to * send user AB test participations, acquisition data and * the current page view ID via the sendEvent function. */ sendEventABTestParticipations(participations); sendEventAcquisitionDataFromQueryParamEvent(acquisitionData); sendEventPageViewId(); }); }); } // ----- Exports ----- // export { init, sendEventSubscriptionCheckoutStart, sendEventSubscriptionCheckoutConversion, sendEventContributionCheckoutConversion, sendEventContributionCartValue, sendEventPaymentMethodSelected, sendEventCheckoutValue, sendEventOneTimeCheckoutValue, };