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
JavaScript
/*! 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 };