UNPKG

choreograph-create-pixel

Version:

This library lets you apply best practises to Choreograph Create pixel development and implementation.

428 lines (364 loc) β€’ 11.3 kB
/*! choreograph-create-pixel v1.4.3 2024/08/27 */ class ChoreographCreatePixel { constructor(userConfig) { this.previouslyScrapedHash = null; this.logs = []; this.config = { colors: { error: "red", warn: "orange", success: "green" }, icons: { scraper: "πŸ“š", viewed: "πŸ‘€", basketed: "πŸ›’", purchased: "🧾", attribution: "πŸ”–", conversion: "πŸ“ˆ", }, debug: /(pixel|lemonpi)_debug/i.test(location.href), before: (data, callback) => callback(data), after: () => {}, optional: [], ...userConfig, }; if (this.validateConfig()) this.cycle(); } static getUrl({ allowedQueryParameters = [], allowHash = false } = {}) { let url = `${location.protocol}//${location.host}${location.pathname}`; const parameters = new URLSearchParams(location.search); allowedQueryParameters.reduce((paramAdded, parameter) => { const separator = paramAdded ? "&" : "?"; const key = encodeURI(parameter); const value = parameters.get(parameter); if (value != null) { url += `${separator}${key}${ value === "" ? "" : `=${encodeURI(value)}` }`; return true; } return paramAdded; }, false); if (allowHash) url += location.hash; return url; } static getAllPathSegments() { return location.pathname .split("/") .filter((segment) => segment) .map((segment) => decodeURI(segment)); } static getPathSegment(index) { return this.getAllPathSegments()[index]; } static getAllQueryParameters() { return location.search .replace(/^\?/, "") .split("&") .filter((parameter) => parameter) .reduce( (parameters, parameter) => ({ ...parameters, [decodeURI(parameter.split("=")[0])]: decodeURI( parameter.split("=")[1] || "" ), }), {} ); } static getQueryParameter(key) { return this.getAllQueryParameters()[key]; } log(level, message, data = null) { if (!this.config.debug || this.logs.includes(message)) return; const args = [ `%cβΈ¬ create%c ${this.config.icons[this.config.type]} ${ this.config.type } %c${message}`, "background:black;color:white;border-radius:3px;padding:3px 6px", "font-weight:bold", `color:${this.config.colors[level]}`, ]; if (data) args.push(data); console.info(...args); this.logs.push(message); } validateConfig() { if (typeof this.config.advertiser !== "number") this.log("error", "Please use a number", { advertiser: this.config.advertiser, }); else if ( ![ "scraper", "viewed", "basketed", "purchased", "attribution", "conversion", ].includes(this.config.type) ) this.log( "error", "Please use scraper, viewed, basketed, purchased, attribution or conversion", { type: this.config.type } ); else if ( ["attribution", "conversion"].includes(this.config.type) && typeof this.config.label !== "string" ) this.log("error", "Please use a string", { label: this.config.label, }); else if (!(this.config.url instanceof RegExp)) this.log("error", "Please use a regular expression", { url: this.config.url, }); else if ( !["attribution", "conversion"].includes(this.config.type) && !this.config.scrape ) this.log("error", "Please provide something to scrape", { scrape: this.config.scrape, }); else return true; } cycle() { if (!this.config.url.test(location.href)) this.log("warn", `URL pattern does not match "${location.href}"`, { url: this.config.url, }); else if ( this.config.type === "scraper" && document.querySelector('html[class*="translated-"]') ) this.log( "warn", "This page has been translated by the browser, and won't be scraped" ); else if (this.config.trigger) { // TO-DO: replace this.config.type with a unique pixel instance ID const elementAttribute = `create-${this.config.type}-${this.config.trigger.event}`; try { let elements = typeof this.config.trigger.elements === "string" ? document.querySelectorAll(this.config.trigger.elements) : this.config.trigger.elements(); if (!elements.forEach) elements = [elements]; elements.forEach((element) => { if (!element.hasAttribute(elementAttribute)) { element.addEventListener(this.config.trigger.event, () => this.config.type === "conversion" ? this.convert() : this.scrape(element) ); element.setAttribute(elementAttribute, ""); } }); } catch (error) { this.log("error", error.message, { "trigger.elements": this.config.trigger.elements, }); } } else { switch (this.config.type) { case "attribution": this.attribute(); break; case "conversion": this.convert(); break; default: this.scrape(); break; } } // TO-DO: Move this to each method's end? Pageview conversions are currently recursive setTimeout(() => this.cycle(), 750); } attribute() { const ccpid = this.constructor.getQueryParameter("ccpid"); if (!ccpid) return this.log("warn", "ccpid query parameter not present"); if (!/^[0-9a-f]{20}$/.test(ccpid)) return this.log( "error", "ccpid query parameter is not formatted correctly" ); const storageItemLabel = `choreograph-${this.config.label}`; const storedCcpid = localStorage.getItem(storageItemLabel); if (storedCcpid === "") return this.log("warn", `"${this.config.label}" already converted`); if (storedCcpid !== ccpid) localStorage.setItem(storageItemLabel, ccpid); this.log("success", `Stored CCPID "${ccpid}" for "${this.config.label}"`); } convert() { const label = `choreograph-${this.config.label}`; const ccpid = localStorage.getItem(label); if (!ccpid) return this.log("warn", `"${this.config.label}" attribution not present`); this.send(ccpid); localStorage.setItem(label, ""); } scrape(element) { let hasErrors = false; let data; const handleField = (field, fieldName) => { let result = field; if (typeof result === "function") { try { result = result(element); } catch (error) { if (this.config.optional.includes(fieldName)) return undefined; this.log("error", error.message, { [fieldName ? `scrape.${fieldName}` : "scrape"]: result, }); hasErrors = true; } } if (typeof result === "string") result = result.replace(/\s+/g, " ").trim(); if ( (result == null || result === "") && !this.config.optional.includes(fieldName) ) { this.log("error", "Value is empty", { [fieldName ? `scrape.${fieldName}` : "scrape"]: result, }); hasErrors = true; } return result; }; if (this.config.type !== "scraper") { data = handleField(this.config.scrape); } else { data = Object.keys(this.config.scrape).reduce( (acc, fieldName) => ({ ...acc, [fieldName]: handleField(this.config.scrape[fieldName], fieldName), }), {} ); } const dataHash = JSON.stringify(data); if (!hasErrors && this.previouslyScrapedHash !== dataHash) { try { this.config.before(data, (newData) => { if (Array.isArray(newData)) newData.forEach((id) => this.send(id)); else this.send(newData); }); } catch (error) { this.log("error", error.message, { before: this.config.before, }); } this.previouslyScrapedHash = dataHash; this.logs.length = 0; } } send(data) { let payload; let url; let method = "GET"; switch (this.config.type) { case "scraper": { const { sku } = data; delete data.sku; payload = { "advertiser-id": this.config.advertiser, sku, fields: data, }; url = `https://d.lemonpi.io/scrapes${ this.config.debug ? "?validate=true" : "" }`; method = "POST"; break; } case "viewed": case "basketed": case "purchased": payload = { "event-type": `product-${this.config.type}`, sku: data, }; url = `https://d.lemonpi.io/a/${ this.config.advertiser }/product/event?e=${encodeURIComponent(JSON.stringify(payload))}`; break; case "conversion": payload = { version: 1, type: "conversion", name: this.config.label, "conversion-attribution-id": data, "advertiser-id": this.config.advertiser, }; url = `https://content.lemonpi.io/track/event?e=${encodeURIComponent( JSON.stringify(payload) )}`; break; } if ( ["viewed", "basketed", "purchased"].includes(this.config.type) && !this.config.debug ) { new Image().src = url; return; } fetch( url, method === "POST" ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), } : null ) .then((response) => response .json() .then((result) => { if (response.ok) { this.log("warn", "Successful, with warnings. Details:", { payload, result, }); try { this.config.after(data); } catch (error) { this.log("error", error.message, { after: this.config.after, }); } } else this.log( "error", `Failed: ${response.status} (${response.statusText}). Details:`, { payload, result } ); this.logs.length = 0; }) .catch(() => { if (response.ok) { this.log("success", "Successful!", { payload }); try { this.config.after(data); } catch (error) { this.log("error", error.message, { after: this.config.after, }); } } else this.log( "error", `Failed: ${response.status} (${response.statusText}). Details:`, { payload, response } ); this.logs.length = 0; }) ) .catch((error) => { this.log("error", `Failed: ${error.message}`, { payload }); this.logs.length = 0; }); } } export { ChoreographCreatePixel as default };