UNPKG

palzintrack

Version:

Palzin Track Client

773 lines (657 loc) 22.8 kB
((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);