UNPKG

@causalfoundry/js-sdk

Version:

Causal Foundry WEB SDK (JS/TS)

693 lines (604 loc) 27.3 kB
import { CancelCheckoutProperties, CartProperties, CheckoutProperties, DeliveryProperties, DrugProperties, ECommerceTypes, FacilityProperties, GroceryProperties, ItemAction, ItemObject, ItemProperties, ItemReportProperties, ItemRequestProperties, ItemType, ItemVerificationProperties, ShopMode } from "./typings" import {BloodProperties} from "./blood.typings" import ECommmercePropertiesTI from './typings-ti' import BloodPropertiesTI from './blood.typings-ti' import OxygenPropertiesTI from './oxygen.typings-ti' import CommonTypesTI from '../../core/commonTypes-ti' import {injectEvent} from "../../core/injector" import CfCore from "../../core/CfCore" import {ContentBlock, NavigationTypes} from "../Navigation/typings" import {ICatalogRepository} from "../../core/repositories/catalog/CatalogRepository" import {createCheckers} from "ts-interface-checker" import {hasRepeatedIds, isISOLocal, toISOLocal} from "../../utils" import {OxygenProperties} from "./oxygen.typings" import {MedicalEquipmentProperties} from "./medicalEquipment.typings" import CfExceptionHandler from "../../core/CfExceptionHandler"; const moduleName = ContentBlock.ECommerce let catalogRepository: ICatalogRepository; /** * This function must be called when the user interrupts the ordering, * at any moment * * @param properties * @param sendNow */ const logCancelCheckoutEvent = (properties: CancelCheckoutProperties, sendNow = false) => { if (!properties.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.CancelCheckout, 'empty cancel checkout id') } else if (properties.items.length == 0) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.CancelCheckout, 'cancel checkout empty items list') } else if (hasRepeatedIds(properties.items)) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.CancelCheckout, 'cancel checkout items contains repeated ids') } injectEvent( properties, [ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI], ECommerceTypes.CancelCheckout, moduleName, '', sendNow ) } /** * This function must be called when the user checkout the current * cart contents * * @param {CheckoutProperties} properties * @param userId use this parameter when the logger user is not the same than the user who has created this log * @param {boolean} sendNow When true, try to send the event immediately * without enqueueing it * * @throws Will throw the error 'currency-missmatch' * when the currency of some item is different * than the currency of the whole cart * * @throws Will throw the error 'cfLog-instance-not-created' * when {CfLog} have not been initialized */ const logCheckoutEvent = async (properties: CheckoutProperties, userId = '', sendNow = false): Promise<void> => { let shopMode = ShopMode.Delivery properties.items.every(itemObject => { verifyItemObject(ECommerceTypes.Checkout, itemObject) }) const abnormalCurrency = properties.items.find(item => item.currency !== properties.currency) if (!properties.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Checkout, 'empty checkout id') } else if (!properties.cart_id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Checkout, 'empty checkout cart id') } else if (properties.cart_price < 0) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Checkout, 'negative checkout price') } else if (properties.items.length == 0) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Checkout, 'checkout empty items list') } else if (hasRepeatedIds(properties.items)) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Checkout, 'checkout items list contains repeated ids') } else if (!properties.currency) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Checkout, 'checkout currency required') } else if (abnormalCurrency) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Checkout, 'checkout currency mismatch') } else if (properties.is_successful == undefined) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Checkout, 'checkout is_successful required') } else if (properties.shop_mode) { shopMode = properties.shop_mode } const internalCheckoutProperties = { ...properties, shop_mode: shopMode, } injectEvent( internalCheckoutProperties, [ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI], ECommerceTypes.Checkout, moduleName, userId, sendNow) } /** * Whenever the user adds or removes an item from the card, * this function must be called to log that event * * @param {CartProperties} properties Object with the information * to define both, the action to perform in the Cart, and the * item * @param {boolean} sendNow When true, try to send the event immediately * without enqueueing it * * @throws Will throw the error 'currency-missmatch' * when the currency of the given item is different from the currency of the whole cart * * @throws Will throw the error 'cfLog-instance-not-created' * when {CfLog} have not been initialized */ const logCartEvent = async (properties: CartProperties, sendNow = false): Promise<void> => { if (properties) { verifyItemObject(ECommerceTypes.Cart, properties.item) } else { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Cart, 'invalid cart item object') } if (!properties.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Cart, 'empty cart id') } else if (!properties.action) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Cart, 'cart action required') } else if (properties.cart_price < 0) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Cart, 'negative cart price') } else if (!properties.currency) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Cart, 'cart currency required') } else if (!properties.item) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Cart, 'cart item object required') } else if (properties.currency !== properties.item.currency) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Cart, 'different currency codes provided') } const internalCartProperties = { ...properties, } injectEvent( internalCartProperties, [ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI], ECommerceTypes.Cart, moduleName, '', sendNow) } /** * Use this method when the items have been already delivered * * * @param {DeliveryProperties} properties * @param {string} userId use this parameter when the logger user is not the same than the user who has created this log * @param {boolean} sendNow When true, try to send the event immediately * without enqueueing it * */ const logDeliveryEvent = (properties: DeliveryProperties, userId = '', sendNow = false): void => { if (!properties.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Delivery, 'empty delivery id') } else if (!properties.action) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Delivery, 'delivery action required') } else if (!properties.order_id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Delivery, 'empty delivery order id') } else if (!properties.est_delivery_ts) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Delivery, 'delivery time required') } else if (!properties.is_urgent) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Delivery, 'delivery is_urgent value required') } else if (properties.delivery_coordinates && (properties.delivery_coordinates?.lat == 0 || properties.delivery_coordinates?.lon == 0)) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Delivery, 'Invalid delivery_coordinates provided') } else if (properties.dispatch_coordinates && (properties.dispatch_coordinates?.lat == 0 || properties.dispatch_coordinates?.lon == 0)) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Delivery, 'Invalid dispatch_coordinates provided') } if(properties.est_delivery_ts instanceof Date){ properties.est_delivery_ts = toISOLocal(properties.est_delivery_ts) }else if(!isISOLocal(properties.est_delivery_ts)) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.Delivery, 'invalid delivery date format') } injectEvent( properties, [ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI], ECommerceTypes.Delivery, moduleName, userId, sendNow) } /** * Event to trigger when there is a report about the item submitted. * * @param properties properties for the item report event. * @param sendNow When true, try to send the event immediately without enqueueing it */ const logItemReportEvent = (properties: ItemReportProperties, sendNow = false): void => { if (!properties.item.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemReport, 'empty item report id') } else if (!properties.store_info) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemReport, 'report store object required') } else if (!properties.store_info.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemReport, 'empty report store id') } else if (!properties.report_info) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemReport, 'empty report info object') } else if (!properties.report_info.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemReport, 'empty report id') } else if (!properties.report_info.short_desc) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemReport, 'empty report short desc') } injectEvent( properties, [ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI], ECommerceTypes.ItemReport, moduleName, '', sendNow) } /** * Event to trigger when there is a request about the item submitted. * * @param properties properties for the item request event. * @param sendNow When true, try to send the event immediately without enqueueing it */ const logItemRequestEvent = (properties: ItemRequestProperties, sendNow = false): void => { if (!properties.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemRequest, 'empty request id') } else if (!properties.item_name) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemRequest, 'empty request item name') } else if (!properties.manufacturer) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemRequest, 'empty request item manufacturer') } injectEvent( properties, [ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI], ECommerceTypes.ItemRequest, moduleName, '', sendNow) } /** * Event to trigger when there is a verification request about the item. * * @param properties properties for the item request event. * @param sendNow When true, try to send the event immediately without enqueueing it */ const logItemVerificationEvent = (properties: ItemVerificationProperties, sendNow = false): void => { if (!properties.scan_channel) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemVerification, 'invalid verification scan channel') } else if (!properties.scan_type) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemVerification, 'invalid verification scan type') } else if (properties.is_successful == undefined) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemVerification, 'invalid verification is_successful') } else if (!properties.item_info.id) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemVerification, 'empty verification item id') } else if (!properties.item_info.type) { new CfExceptionHandler().throwAndLogError(ECommerceTypes.ItemVerification, 'invalid verification item type') } injectEvent( properties, [ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI], ECommerceTypes.ItemVerification, moduleName, '', sendNow) } /** * Use this function to log whenever the user interacts with an item * * @param { ItemProperties } properties * @param { DrugProperties } drugProperties Information related to drug features * to be injected in the server catalog. Only needed when ItemProperties.action is * `ItemAction.view` * @param { boolean } sendNow When true, try to send the event immediately * without enqueueing it * * @throws Will throw the error 'cfLog-instance-not-created' * when {CfLog} have not been initialized * * @throws Will throw the error 'currency-missmatch' * when the currency of the given item is different * than the currency of the whole cart */ const logItemEvent = async ( properties: ItemProperties, productProperties?: BloodProperties | DrugProperties | OxygenProperties | MedicalEquipmentProperties | GroceryProperties, sendNow = false ): Promise<void> => { const {ItemProperties: PropertiesChecker} = createCheckers( ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI, CommonTypesTI) PropertiesChecker.check(properties) verifyItemObject(ECommerceTypes.Item, properties.item) const internalItemProperties = { ...properties, } injectEvent( internalItemProperties, [ECommmercePropertiesTI, BloodPropertiesTI, OxygenPropertiesTI], ECommerceTypes.Item, moduleName, '', sendNow) if ((properties.action === ItemAction.View || properties.action === ItemAction.Impression) && productProperties) { if (properties.item.type === ItemType.Drug) { const drugProperties = productProperties as DrugProperties if (!drugProperties.name) { new CfExceptionHandler().throwAndLogError("drug catalog", 'drug name is required.') } catalogRepository.injectDrug( properties.item.id, drugProperties) } else if (properties.item.type === ItemType.Grocery) { const groceryProperties = productProperties as GroceryProperties if (!groceryProperties.name) { new CfExceptionHandler().throwAndLogError("grocery catalog", 'grocery name is required.') } catalogRepository.injectGrocery( properties.item.id, groceryProperties) } else if (properties.item.type === ItemType.Blood) { const bloodProperties = productProperties as BloodProperties if (!bloodProperties.blood_group) { new CfExceptionHandler().throwAndLogError("blood catalog", 'blood group is required.') } catalogRepository.injectBlood( properties.item.id, bloodProperties) } else if (properties.item.type === ItemType.Oxygen) { const oxygenProperties = productProperties as OxygenProperties catalogRepository.injectOxygen( properties.item.id, oxygenProperties) } else if (properties.item.type === ItemType.MedicalEquipment) { const medicalEquipmentProperties = productProperties as MedicalEquipmentProperties if (!medicalEquipmentProperties.name) { new CfExceptionHandler().throwAndLogError("medical equipment catalog", 'medical equipment name is required.') } catalogRepository.injectMedicalEquipment( properties.item.id, medicalEquipmentProperties) } } } const updateGroceryCatalog = (itemId: string, properties: GroceryProperties): void => { if (!itemId) { new CfExceptionHandler().throwAndLogError("grocery catalog", 'empty grocery item id') } else if (!properties.name) { new CfExceptionHandler().throwAndLogError("grocery catalog", 'grocery name is required.') } catalogRepository.injectGrocery(itemId, properties) } const updateFacilityCatalog = (facilityId: string, properties: FacilityProperties): void => { if (!facilityId) { new CfExceptionHandler().throwAndLogError("facility catalog", 'empty facility item id') } else if (!properties.name) { new CfExceptionHandler().throwAndLogError("facility catalog", 'facility name is required.') } catalogRepository.injectFacility(facilityId, properties) } const updateDrugCatalog = (itemId: string, properties: DrugProperties): void => { if (!itemId) { new CfExceptionHandler().throwAndLogError("drug catalog", 'empty drug item id') } else if (!properties.name) { new CfExceptionHandler().throwAndLogError("drug catalog", 'drug name is required.') } catalogRepository.injectDrug(itemId, properties) } const updateBloodCatalog = (itemId: string, properties: BloodProperties): void => { if (!itemId) { new CfExceptionHandler().throwAndLogError("blood catalog", 'empty blood item id') } else if (!properties.blood_group) { new CfExceptionHandler().throwAndLogError("blood catalog", 'blood_group is required.') } catalogRepository.injectBlood(itemId, properties) } const updateOxygenCatalog = (itemId: string, properties: OxygenProperties): void => { if (!itemId) { new CfExceptionHandler().throwAndLogError("oxygen catalog", 'empty oxygen item id') } catalogRepository.injectOxygen(itemId, properties) } const updateMedicalEquipmentCatalog = (itemId: string, properties: MedicalEquipmentProperties): void => { if (!itemId) { new CfExceptionHandler().throwAndLogError("medical equipment catalog", 'empty medical equipment item id') } else if (!properties.name) { new CfExceptionHandler().throwAndLogError("medical equipment catalog", 'medical equipment name is required.') } catalogRepository.injectMedicalEquipment(itemId, properties) } /** * Start tracking impressions automatically for the items whose parent * is the element defined by the class `containerClassname`. A impression * is a visualization of an item for specific amount of time (i.e.: 1s) * * In addition, the items to track must include some metadata by using * the internal dataset of HTML elements, as shown in the next example. * * ```html * <div * data-log-id="0" * data-log-quantity="9" * data-log-price="426" * data-log-currency="EUR" * data-log-stock-status="in_stock" * data-log-promo-id="your-promo-id" * class="item-card" * > * ``` * * @param {string} containerClassname Represents the CSS classname of the * container to monitorize * * @param {string} itemClassname Any item within the indicated container must * also include a common classname to let the SDK monitorize it * * @throws Will throw the error 'container-does-not-exist' * when `containerClassname` does not correspond to * any DOM element */ const startTrackingImpressions = (containerClassname: string, itemClassname: string, searchId: string) => { const impressionHandler = ({dataset, appData}) => { const { logId: id, logCurrency: currency, logType: type, logPrice: price, logQuantity: quantity, logStockStatus: stock_status, logPromoId: promo_id, logFacilityId: facility_id, logDrugCatalogObject: drug_catalog_object } = dataset const itemDetail: ItemObject = { id, type: type || ItemType.Drug, currency: currency, price: parseInt(price), quantity: parseInt(quantity), stock_status: stock_status, promo_id: promo_id || "", facility_id: facility_id || "", } let catalogObj; let catalogData; if (drug_catalog_object) { try { catalogObj = JSON.parse(drug_catalog_object) if (type === ItemType.Drug) { catalogData = { market_id: catalogObj.market_id, name: catalogObj.name, description: catalogObj.description, supplier_id: catalogObj.supplier_id, supplier_name: catalogObj.supplier_name, producer: catalogObj.producer || "", packaging: catalogObj.packaging || "", active_ingredients: catalogObj.active_ingredients, drug_form: catalogObj.drug_form || "", drug_strength: catalogObj.drug_strength || "", atc_anatomical_group: catalogObj.atc_anatomical_group || "", otc_or_ethical: catalogObj.otc_or_ethical || "" } } else if (type === ItemType.Blood) { catalogData = { market_id: catalogObj.market_id, blood_component: catalogObj.blood_component, blood_group: catalogObj.blood_group, packaging: catalogObj.packaging, packaging_size: catalogObj.packaging_size, supplier_id: catalogObj.supplier_id || "", supplier_name: catalogObj.supplier_name || "" } } else if (type === ItemType.MedicalEquipment) { catalogData = { name: catalogObj.name, market_id: catalogObj.market_id, description: catalogObj.description || "", supplier_id: catalogObj.supplier_id, supplier_name: catalogObj.supplier_name, producer: catalogObj.producer || "", packaging: catalogObj.packaging || "", category: catalogObj.category || "" } } else if (type === ItemType.Oxygen) { catalogData = { market_id: catalogObj.market_id, packaging: catalogObj.packaging, packaging_size: catalogObj.packaging_size, supplier_id: catalogObj.supplier_id || "", supplier_name: catalogObj.supplier_name || "" } } } catch (e) { console.log('Malformed catalog object') } } verifyItemObject(ECommerceTypes.Item, itemDetail) logItemEvent({ action: ItemAction.Impression, item: itemDetail, ...appData }, catalogData) } CfCore.getInstance().startTrackingImpressions(impressionHandler, containerClassname, itemClassname, searchId) } /** * When the container indicated in `startTrackingImpressions` is no longer * available (i.e.: the user has jumped to another section), call this function * to stop monitoring those items inside the removed container * * @param {string} containerClassname The className that identifies the container * to stop tracking */ const stopTrackingImpressions = (containerClassname) => { CfCore.getInstance().stopTrackingImpressions(containerClassname) } /** * When the search is updated, that is, the user introduced a new query * or a new attribute in the available selector, but the container does not * changed, call the this function. It may happen that some items are shared * with the previous search but, due searchId has changed, they will be already * logged * * @param {string} searchId * * @throws `unknown-container` when the `containerClassname` was not being already tracked */ const restartTrackingImpressions = (containerClassname: string, searchId: string) => { CfCore.getInstance().restartTrackingImpressions(containerClassname, searchId) } const verifyItemObject = (eventName: ECommerceTypes, itemObject: ItemObject) => { if (!itemObject.id) { new CfExceptionHandler().throwAndLogError(eventName, 'empty item id') } else if (!itemObject.type) { new CfExceptionHandler().throwAndLogError(eventName, 'invalid item type') } else if (itemObject.quantity < 0) { new CfExceptionHandler().throwAndLogError(eventName, 'invalid item quantity') } else if (itemObject.price < 0) { new CfExceptionHandler().throwAndLogError(eventName, 'invalid item price') } else if (!itemObject.currency) { new CfExceptionHandler().throwAndLogError(eventName, 'item currency required') } if (itemObject.type === ItemType.Subscription) { if (!itemObject.subscription) { new CfExceptionHandler().throwAndLogError(eventName, 'invalid subscription detail object') } else if (!itemObject.subscription.type) { new CfExceptionHandler().throwAndLogError(eventName, 'invalid subscription detail object type') } else if (!itemObject.subscription.status) { new CfExceptionHandler().throwAndLogError(eventName, 'invalid subscription detail object status') } else if (!itemObject.subscription.subscription_items) { new CfExceptionHandler().throwAndLogError(eventName, 'invalid subscription detail object list') } else if (itemObject.subscription.subscription_items.length == 0) { new CfExceptionHandler().throwAndLogError(eventName, 'subscription items list is required') } itemObject.subscription.subscription_items.every(item => { if (!item.id) { new CfExceptionHandler().throwAndLogError(NavigationTypes.Search, 'empty subscription items list item id') } else if (!item.type) { new CfExceptionHandler().throwAndLogError(NavigationTypes.Search, 'empty subscription items list item type') } }) } } /** * Private function to inject repositories * * @ignore */ const init = ( injectedCatalogRepository: ICatalogRepository ) => { catalogRepository = injectedCatalogRepository } export default { updateDrugCatalog, updateGroceryCatalog, updateFacilityCatalog, updateBloodCatalog, updateOxygenCatalog, updateMedicalEquipmentCatalog, logItemReportEvent, logItemRequestEvent, logItemVerificationEvent, logCancelCheckoutEvent, logCartEvent, logCheckoutEvent, logDeliveryEvent, logItemEvent, restartTrackingImpressions, startTrackingImpressions, stopTrackingImpressions, init }