@causalfoundry/js-sdk
Version:
Causal Foundry WEB SDK (JS/TS)
693 lines (604 loc) • 27.3 kB
text/typescript
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
}