palzintrack
Version:
Palzin Track Client
773 lines (657 loc) • 22.8 kB
JavaScript
((window) => {
const {
screen: { width, height },
navigator: { language },
location,
innerWidth,
innerHeight,
localStorage,
document,
history
} = window;
const { hostname, pathname, search } = location;
const { currentScript, referrer } = document;
if (!currentScript) return;
const _data = 'data-';
const _false = 'false';
const _true = 'true';
const attr = currentScript.getAttribute.bind(currentScript);
const apiToken = attr(_data + 'api-key');
const userId = attr(_data + 'user-id');
const ua = window.navigator.userAgent;
const dnt = true;
const projectId = attr(_data + 'project-id');
const hostUrl = 'https://api.palzin.live/v1/';
const tags = attr(_data + 'tags');
const autoTrack = attr(_data + 'auto-track') !== _false;
const excludeSearch = attr(_data + 'exclude-search') === _true;
const domain = attr(_data + 'domains') || '';
const domains = domain.split(',').map((n) => n.trim());
const host =
hostUrl ||
'__COLLECT_API_HOST__' ||
currentScript.src.split('/').slice(0, -1).join('/');
const endpoint = `${host.replace(/\/$/, '')}__COLLECT_API_ENDPOINT__`;
const screen = `${width}x${height}`;
const vp_size =
Math.max(
document.documentElement.clientWidth || 0,
window.innerWidth || 0
) +
'x' +
Math.max(
document.documentElement.clientHeight || 0,
window.innerHeight || 0
);
const eventRegex = /data-pt-event-([\w-_]+)/;
const eventNameAttribute = _data + 'pt-event';
const delayDuration = 300;
const query = location.search;
const utm_source =
// eslint-disable-next-line no-undef
new URLSearchParams(window.location.search).get('utm_source') || undefined;
const EVENT_CHANNEL = 'events';
let href = '';
const sameHost =
document.referrer.indexOf(location.protocol + '//' + location.host) === 0;
const pageReferrer = sameHost ? null : document.referrer;
const path = (function () {
let loc = location;
const canonical = document.querySelector("link[rel='canonical'][href]");
if (canonical) {
const a = document.createElement('a');
a.href = canonical.href;
if (
a.hostname.replace(/^www\./, '') ===
location.hostname.replace(/^www\./, '')
) {
loc = a;
}
}
return loc.pathname + loc.search || '/';
})();
/* Helper functions */
const encode = (str) => {
if (!str) {
return undefined;
}
try {
const result = decodeURI(str);
if (result !== str) {
return result;
}
} catch {
return str;
}
return encodeURI(str);
};
const parseURL = (url) => {
return excludeSearch ? url.split('?')[0] : url;
};
const getPayload = async () => ({
projectId,
hostname,
screen,
language,
userId,
vp_size,
utm_source,
anonymousId: await getAnonymousId(),
title: encode(title),
url: encode(currentUrl),
referrer: encode(currentRef),
tags: tags ? tags : undefined
});
/* Event handlers */
const handlePush = (state, title, url) => {
if (!url) return;
currentRef = currentUrl;
currentUrl = parseURL(url.toString());
if (currentUrl !== currentRef) {
// eslint-disable-next-line no-undef
setTimeout(track, delayDuration);
}
};
const handleInsights = () => {};
const handlePathChanges = () => {
const hook = (_this, method, callback) => {
const orig = _this[method];
return (...args) => {
callback.apply(null, args);
return orig.apply(_this, args);
};
};
history.pushState = hook(history, 'pushState', handlePush);
history.replaceState = hook(history, 'replaceState', handlePush);
};
const handleTitleChanges = () => {
// eslint-disable-next-line no-undef
const observer = new MutationObserver(([entry]) => {
title = entry && entry.target ? entry.target.text : undefined;
});
const node = document.querySelector('head > title');
if (node) {
observer.observe(node, {
subtree: true,
characterData: true,
childList: true
});
}
};
const handleFormSubmissions = () => {
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', async e => {
// Check if it's a tracked form
if (!form.hasAttribute('data-pt-event')) return;
e.preventDefault(); // Prevent default form submission
// eslint-disable-next-line no-undef
console.log(form);
// eslint-disable-next-line no-undef
const formData = new FormData(form);
const formValues = {};
// Extract form data to formValues, skipping sensitive data
for (const [key, value] of formData.entries()) {
if (key.toLowerCase().includes('password')) continue; // Skip sensitive data
formValues[key] = value;
}
// Extract event details from DOM attributes
const eventName = form.getAttribute('data-pt-event');
const eventDescription = form.getAttribute('data-pt-event-description');
const eventIcon = form.getAttribute('data-pt-event-icon');
const eventNotify = form.getAttribute('data-pt-event-notify');
const user_id = form.getAttribute('data-pt-user-id'); // use form's user-id or default to script's userId
// Assemble tags from form
const tags = {};
for (const [key, value] of Object.entries(formValues)) {
tags[key] = value;
}
const formValuesData = {};
// Assuming 'target' is a form object containing form data
for (const key of Object.keys(tags)) {
const value = tags[key];
if (value && !key.toLowerCase().includes('password')) {
formValuesData[camelToKebab(key)] = value.toString();
}
}
// Create the eventPayload
const eventPayload = {
channel: eventName || EVENT_CHANNEL,
source: 'web-form',
project: projectId,
event: eventName,
description: eventDescription,
icon: eventIcon || '👆',
notify: eventNotify === 'true' || false,
user_id: user_id || userId,
tags: { ...tags, ...(Object.keys(tags).length ? {} : filterTags(formValuesData)) }
};
// Send the eventPayload via sendEvents function
try {
const response = await sendEvents(eventPayload, 'event');
// eslint-disable-next-line no-undef
console.log('Event Tracking Successful', response);
} catch (error) {
// eslint-disable-next-line no-undef
console.error('Error sending form submission event', error);
}
// Optionally, allow form submission or further actions
form.submit();
});
});
};
const handleClicks = () => {
document.addEventListener(
'click',
async (e) => {
const isSpecialTag = tagName => ['BUTTON', 'A'].includes(tagName);
const trackElement = async el => {
const attr = el.getAttribute.bind(el);
const eventName = attr(eventNameAttribute);
// eslint-disable-next-line no-undef
if (eventName) {
const eventData = {};
el.getAttributeNames().forEach(name => {
const match = name.match(eventRegex);
if (match) {
eventData[match[1]] = attr(name);
}
});
return trackClickEvent(eventName, eventData);
} else {
return;
}
};
const findParentTag = (rootElem, maxSearchDepth) => {
let currentElement = rootElem;
for (let i = 0; i < maxSearchDepth; i++) {
if (isSpecialTag(currentElement.tagName)) {
return currentElement;
}
currentElement = currentElement.parentElement;
if (!currentElement) {
return null;
}
}
};
const el = e.target;
if(e.target.getAttribute(eventNameAttribute)) {
const parentElement = isSpecialTag(el.tagName) ? el : findParentTag(el, 10);
if (parentElement) {
const { href, target } = parentElement;
const eventName = parentElement.getAttribute(eventNameAttribute);
if (eventName) {
if (parentElement.tagName === 'A') {
// eslint-disable-next-line no-undef
console.log('Its tagname is A');
const external =
target === '_blank' ||
e.ctrlKey ||
e.shiftKey ||
e.metaKey ||
(e.button && e.button === 1);
if (eventName && href) {
if (!external) {
e.preventDefault();
}
return trackElement(parentElement).then(() => {
if (!external) location.href = href;
});
}
} else if (parentElement.tagName === 'BUTTON') {
// eslint-disable-next-line no-undef
console.log('Its tagname is BUTTON')
return trackElement(parentElement);
}
}
}
else {
// eslint-disable-next-line no-undef
console.log('completely off track');
return trackElement(el);
}
} else {
return;
}
},
true,
);
};
/* Tracking functions */
const trackingDisabled = () =>
(localStorage && localStorage.getItem('pt.disabled')) ||
(domain && !domains.includes(hostname));
const validateRequiredFields = (payload, requestFor) => {
const trackRequiredFields = ['project', 'token', 'title', 'url'];
const eventRequiredFields = ['project', 'event', 'channel'];
if(requestFor === 'event') {
for (const field of eventRequiredFields) {
if (!payload[field]) {
// eslint-disable-next-line no-undef
// console.log(`Validation Error: Missing required field - ${field}`);
return false; // Return false if any required field is missing
}
}
} else if (requestFor === 'track') {
for (const field of trackRequiredFields) {
if (!payload[field]) {
// eslint-disable-next-line no-undef
// console.log(`Validation Error: Missing required field - ${field}`);
return false; // Return false if any required field is missing
}
}
}
return true; // All required fields are present and valid
};
const send = async (payload, type = 'analytics') => {
if (trackingDisabled()) return;
// eslint-disable-next-line no-undef
const trackHeaders = new Headers();
trackHeaders.append('Content-Type', 'application/json');
trackHeaders.append('Authorization', 'Bearer ' + apiToken);
if (typeof cache !== 'undefined') {
trackHeaders.append('x-pt-m2cache', cache);
}
try {
const anonymousId = await getAnonymousId();
const analyticsPayload = {
project: projectId,
hostname: hostname,
token: apiToken,
query: query,
path,
utm_source: utm_source,
ua: ua,
anonymousId: anonymousId,
screen: payload.screen,
user_id: payload.userId,
vp_size: payload.vp_size,
title: payload.title,
url: encode(currentUrl) ?? payload.url,
language,
referrer: encode(currentRef) ?? pageReferrer,
tags: tags ? tags : undefined
};
if (validateRequiredFields(analyticsPayload, 'track')) {
const trackBodyValue = JSON.stringify(analyticsPayload);
trackHeaders.append('Content-Length', trackBodyValue.length.toString());
const routePath = hostUrl + 'track';
const res = await fetch(routePath, {
method: 'POST',
headers: trackHeaders,
body: trackBodyValue
});
const text = await res.text();
return (cache = text);
} else {
return;
}
} catch {
/* empty */
}
};
// Function to get value from persistence storage (Cookie, LocalStorage, Memory)
const getFromPersistence = async (key) => {
// Check if Cookie is available
if (isCookieSupported()) {
const cookieValue = getPTCookie(key);
if (cookieValue) {
return cookieValue;
}
}
// Check if LocalStorage is available
if (isLocalStorageSupported()) {
const localStorageValue = localStorage.getItem(key);
if (localStorageValue) {
return localStorageValue;
}
}
// If no persistence storage available, return null
return null;
};
// Function to persist value to persistence storage (Cookie, LocalStorage)
const persistToPersistence = async (key, value) => {
// Set Cookie
if (isCookieSupported()) {
setCookie(key, value);
}
// Set LocalStorage
if (isLocalStorageSupported()) {
localStorage.setItem(key, value);
}
};
// Function to generate a unique ID
const generateUniqueId = () => {
// Generate a UUID or any other unique ID algorithm
// For simplicity, let's use a random number here
return 'pt_' + Math.random().toString(36).substr(2, 9);
};
// Function to check if cookie is supported
const isCookieSupported = () => {
return typeof document !== 'undefined' && typeof document.cookie !== 'undefined';
};
// Function to check if local storage is supported
const isLocalStorageSupported = () => {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
};
// Function to set cookie
const setCookie = (key, value) => {
const expires = new Date();
expires.setTime(expires.getTime() + (30 * 24 * 60 * 60 * 1000)); // 30 days
let cookieString = key + '=' + value + '; path=/; expires=' + expires.toUTCString();
domains.forEach((domain) => {
cookieString += '; domain=' + domain;
});
document.cookie = cookieString;
};
// Function to get cookie value by name
const getPTCookie = (name) => {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Check if this cookie name is the one we're looking for
if (cookie.startsWith(name + '=')) {
return cookie.substring(name.length + 1);
}
}
return null;
};
// Function to get anonymous id
const getAnonymousId = async () => {
let anonymousId = '';
// Check if the anonymousId exists in persistence storage
const storageKey = 'pt.anonymousId';
const storedId = await getFromPersistence(storageKey);
if (storedId) {
anonymousId = storedId;
} else {
// Generate a new anonymousId
anonymousId = generateUniqueId();
// Persist the anonymousId
await persistToPersistence(storageKey, anonymousId);
}
return anonymousId;
};
const sendIdentity = async (payload, type = 'identify') => {
if (trackingDisabled()) return;
// eslint-disable-next-line no-undef
const trackHeaders = new Headers();
trackHeaders.append('Content-Type', 'application/json');
trackHeaders.append('Authorization', 'Bearer ' + apiToken);
if (typeof cache !== 'undefined') {
trackHeaders.append('x-pt-m2cache', cache);
}
try {
const identityPayload = payload;
const trackBodyValue = JSON.stringify(identityPayload);
trackHeaders.append('Content-Length', trackBodyValue.length.toString());
const routePath = hostUrl + 'identity';
const res = await fetch(routePath, {
method: 'POST',
headers: trackHeaders,
body: trackBodyValue
});
const text = await res.text();
return (cache = text);
} catch {
/* empty */
}
};
const trackClickEvent = async (eventName, eventData) => {
const tags = {};
for (const [key, value] of Object.entries(eventData)) {
if (key.startsWith('tags')) {
const tagKey = camelToKebab(key).replace('tags-', '');
tags[tagKey] = value;
}
}
const formValues = {};
// Assuming 'target' is a form object containing form data
for (const key of Object.keys(eventData)) {
const value = eventData[key];
if (value && !key.toLowerCase().includes('password')) {
formValues[camelToKebab(key)] = value.toString();
}
}
const eventPayload = {
channel: eventData.channel || EVENT_CHANNEL,
source: 'web',
project: projectId,
event: eventName,
description: eventData.description,
icon: eventData.icon || '👆',
notify: eventData.notify || false,
user_id: eventData.userid,
tags: { ...tags, ...(Object.keys(tags).length ? {} : filterTags(formValues)) }
};
return await sendEvents(eventPayload, 'event');
};
// Function to find the parent form element of the clicked element
const findParentForm = async (element) => {
while (element) {
if (element.tagName === 'FORM' && element.hasAttribute('data-pt-event') && element.hasAttribute('data-pt-event-channel')) {
return element;
}
element = element.parentElement;
}
return null;
};
// Function to extract input, select, and dropdown values from a form element
const extractFormTags = async (formElement) => {
const formTags = {};
const inputElements = formElement.querySelectorAll('input, select, textarea');
for (const inputElement of inputElements) {
const tagName = inputElement.getAttribute('data-pt-event-tags');
const tagValue = inputElement.value;
if (tagName && tagValue) {
formTags[tagName] = tagValue;
}
}
return formTags;
};
function filterTags(tags) {
return Object.fromEntries(
Object.entries(tags).filter(([key, value]) => value !== undefined)
);
}
const sendEvents = async (payload, type = 'event') => {
if (trackingDisabled()) return;
// eslint-disable-next-line no-undef
const eventHeaders = new Headers();
eventHeaders.append('Content-Type', 'application/json');
eventHeaders.append('Authorization', 'Bearer ' + apiToken);
if (typeof cache !== 'undefined') {
eventHeaders.append('x-pt-m1cache', cache);
}
try {
if (validateRequiredFields(payload, 'event')) {
const routePath = hostUrl + 'log';
const bodyValues = JSON.stringify(payload);
eventHeaders.append('Content-Length', bodyValues.length.toString());
const res = await fetch(routePath, {
method: 'POST',
headers: eventHeaders,
body: bodyValues
})
.then((response) => response.json())
.then((data) => {
if (data.message === 'Event published successfully.') {
// Handle success
// eslint-disable-next-line no-undef
console.log('Success MSG:');
// Redirect user or do any other actions
} else {
// Handle other cases
// eslint-disable-next-line no-undef
console.log('Failed MSG:');
// Redirect user or do any other actions
}
location.href = href;
})
.catch((error) => {
// eslint-disable-next-line no-undef
console.error('Fetch error: ', error);
location.href = href;
});
const text = await res.text();
return (cache = text);
} else {
return;
}
} catch {
/* empty */
}
};
function camelToKebab(str) {
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase();
}
const track = (obj, data) => {
if (typeof obj === 'string') {
return send({
...getPayload(),
name: obj,
tags: typeof data === 'object' ? data : undefined
});
} else if (typeof obj === 'object') {
return send(obj);
} else if (typeof obj === 'function') {
return send(obj(getPayload()));
}
return send(getPayload());
};
const analyticsEncoder = (o) => {
let p = [];
for (let k in o) {
if (o[k] !== "" && o[k] !== null && o[k] !== undefined && o[k] !== false) {
p.push(encodeURIComponent(k) + "=" + encodeURIComponent(o[k]));
}
}
return "?" + p.join("&");
}
const logAnalytics = (payload) => {
const encoded = analyticsEncoder(payload);
const routePath = hostUrl + 'track';
const img = document.createElement("img");
img.setAttribute("alt", "");
img.setAttribute("aria-hidden", "true");
img.setAttribute("style", "position:absolute");
img.src = routePath + encoded;
img.addEventListener("load", () => {
document.body.removeChild(img);
});
}
const identify = (data) => sendIdentity({ ...getPayload(), data }, 'identify');
/* Start */
if (!window.pt) {
window.pt = {
track,
identify
};
}
let currentUrl = `${pathname}${search}`;
let currentRef = referrer !== hostname ? referrer : '';
let title = document.title;
let cache;
let initialized;
if (autoTrack && !trackingDisabled()) {
handlePathChanges();
handleTitleChanges();
handleClicks();
handleFormSubmissions();
const init = async () => {
if (document.readyState === 'complete' && !initialized) {
// eslint-disable-next-line no-undef
if (dnt && navigator?.doNotTrack === "1") {
return;
}
const anonymousId = await getAnonymousId();
const payload = await getPayload();
const analyticsPayload = {
project: projectId,
hostname: hostname,
token: apiToken,
query: query,
path,
utm_source: utm_source,
ua: ua,
anonymousId: anonymousId,
screen: payload.screen,
user_id: payload.userId,
vp_size: payload.vp_size,
title: payload.title,
url: encode(currentUrl) ?? payload.url,
language,
referrer: encode(currentRef) ?? pageReferrer,
tags: tags ? tags : undefined
};
logAnalytics(analyticsPayload);
initialized = true;
}
};
document.addEventListener("hashchange", () => {
track();
});
document.addEventListener('readystatechange', init, true);
document.addEventListener("load", init);
document.addEventListener("popstate", init);
}
})(window);