(function(){
'use strict';
const CONFIG={
MAX_BATCH_SIZE: 20,
ENGAGEMENT_FLUSH_INTERVAL: 30000,
INACTIVITY_TIMEOUT: 30000,
MAX_RETRIES: 2,
RETRY_BACKOFF_MS: 1000,
SCROLL_THRESHOLDS: [25, 50, 75, 100],
TRACKED_ELEMENTS: ['a', 'button', '[role="button"]', '[data-xnwa-track]'],
VERSION: '1.0.0'
};
const state={
collectorToken: null,
tokenExpiresAt: null,
endpoint: null,
enabled: false,
initialized: false,
pageviewSent: false,
queue: [],
engagement: {
active: false,
accumulated: 0,
lastActivity: 0,
lastFlushTime: 0,
flushTimer: null,
flushReason: null
},
scrollDepth: {
fired: new Set()
},
retryState: {
attempts: 0,
bootstrapped: false
},
quality: {
accepted: 0,
duplicate: 0,
rejected: 0,
rateLimited: 0,
bootstrapFailed: 0
}};
function generateUUID(){
if(typeof crypto!=='undefined'&&crypto.randomUUID){
return crypto.randomUUID();
}
if(typeof crypto!=='undefined'&&crypto.getRandomValues){
const bytes=new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6]=(bytes[6] & 0x0f) | 0x40;
bytes[8]=(bytes[8] & 0x3f) | 0x80;
const hex=Array.from(bytes).map(b=> b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-4${hex.slice(13,16)}-a${hex.slice(17,20)}-${hex.slice(20)}`;
}
throw new Error('No UUID generation available');
}
function getTimestamp(){
return new Date().toISOString();
}
function parseUrl(url){
try {
return new URL(url);
} catch (e){
return null;
}}
function normalizeHostname(hostname){
if(!hostname) return null;
let normalized=hostname.toLowerCase();
if(normalized.startsWith('www.')){
normalized=normalized.slice(4);
}
return normalized;
}
function extractAcquisitionEvidence(){
const evidence={
pathname: window.location.pathname
};
const params=new URLSearchParams(window.location.search);
const allowedParams=[
'utm_source', 'utm_medium', 'utm_campaign',
'utm_content', 'utm_term', 'gclid', 'wbraid',
'gbraid', 'xnwa_source', 'xnwa_campaign'
];
for (const param of allowedParams){
const value=params.get(param);
if(value){
evidence[param]=value;
}}
return evidence;
}
function extractReferrerEvidence(){
if(!document.referrer) return null;
const url=parseUrl(document.referrer);
if(!url) return null;
return {
referrer_host: normalizeHostname(url.hostname),
referrer_path: url.pathname
};}
function getViewportClass(){
const width=window.innerWidth;
if(width < 768) return 'mobile';
if(width < 1024) return 'tablet';
return 'desktop';
}
function getDocumentLanguage(){
return document.documentElement.lang||'unknown';
}
const EventSchema={
registered: {
'page_view': {
required: [],
optional: ['document_language', 'viewport_class'],
disallowed: ['title', 'url', 'query', 'referrer']
},
'engagement': {
required: ['active_ms'],
optional: ['flush_reason'],
disallowed: ['*']
},
'scroll_depth': {
required: ['depth_percent'],
optional: [],
disallowed: ['*'],
allowedValues: {
'depth_percent': [25, 50, 75, 100]
}},
'click': {
required: [],
optional: ['element_kind', 'target_kind', 'is_outbound'],
disallowed: ['text', 'value', 'coords', 'selector', 'id', 'path']
},
'outbound_click': {
required: ['target_host'],
optional: ['target_kind'],
disallowed: ['url', 'path', 'query', 'fragment']
},
'menu_view': {
required: ['menu_key'],
optional: ['presentation', 'entry_point'],
disallowed: ['menu_text', 'item_text', 'item_name', 'description', 'price', 'image_url']
},
'menu_category_view': {
required: ['menu_key', 'category_key'],
optional: ['entry_point'],
disallowed: ['category_text', 'item_text', 'description', 'price']
},
'menu_item_view': {
required: ['menu_key', 'item_key'],
optional: ['category_key'],
disallowed: ['item_text', 'item_name', 'description', 'price', 'image_url']
},
'gallery_view': {
required: ['gallery_key'],
optional: ['media_index'],
disallowed: ['image_url', 'image_src', 'alt_text', 'caption', 'filename', 'media_title']
},
'phone_click': {
required: [],
optional: ['target_kind', 'entry_point'],
disallowed: ['phone_number', 'tel_url', 'phone_text', 'link_text']
},
'map_click': {
required: ['provider'],
optional: ['entry_point'],
disallowed: ['coordinates', 'lat', 'lng', 'address', 'destination', 'map_url']
},
'reservation_view': {
required: [],
optional: ['entry_point'],
disallowed: ['page_url', 'page_title', 'form_data', 'user_input']
},
'reservation_start': {
required: ['reservation_flow_id'],
optional: ['entry_point'],
disallowed: ['customer_name', 'customer_email', 'customer_phone', 'booking_data', 'form_values']
},
'reservation_step': {
required: ['reservation_flow_id', 'step_key'],
optional: ['step_index'],
disallowed: ['selected_value', 'form_data', 'user_input', 'field_value', 'time_selected', 'date_selected']
},
'reservation_complete': {
required: ['reservation_flow_id'],
optional: [],
disallowed: ['confirmation_number', 'booking_reference', 'customer_name', 'customer_email', 'customer_phone', 'party_size', 'booking_date', 'booking_time', 'special_requests', 'form_data', 'api_response']
},
'reservation_error': {
required: ['reservation_flow_id', 'error_code'],
optional: ['step_key'],
disallowed: ['error_message', 'stack_trace', 'exception', 'raw_error', 'field_value', 'form_data', 'api_request', 'api_response', 'validation_details']
}},
validate(eventName, properties){
const schema=this.registered[eventName];
if(!schema){
return { valid: false, error: 'unknown_event_type' };}
for (const required of schema.required){
if(!(required in properties)){
return { valid: false, error: `missing_required_${required}` };}}
for (const key of Object.keys(properties)){
if(schema.disallowed.includes('*')){
const allowed=[...schema.required, ...schema.optional];
if(!allowed.includes(key)){
return { valid: false, error: `unknown_property_${key}` };}}else if(schema.disallowed.includes(key)){
return { valid: false, error: `disallowed_property_${key}` };}}
if(schema.allowedValues){
for (const [prop, allowed] of Object.entries(schema.allowedValues)){
if(prop in properties&&!allowed.includes(properties[prop])){
return { valid: false, error: `invalid_value_${prop}` };}}
}
return { valid: true };}};
function createEvent(eventName, properties){
const validation=EventSchema.validate(eventName, properties||{});
if(!validation.valid){
console.warn('[XNWA] Event validation failed:', validation.error);
return null;
}
const event={
event_id: generateUUID(),
event_name: eventName,
event_time: getTimestamp(),
page_path: window.location.pathname,
properties: properties||{}};
return event;
}
function enqueue(event){
if(!state.enabled||!event) return;
state.queue.push(event);
if(state.queue.length >=CONFIG.MAX_BATCH_SIZE){
flush();
}}
function flush(){
if(state.queue.length===0) return;
if(!state.collectorToken){
console.warn('[XNWA] No collector token, cannot flush');
return;
}
const events=state.queue.splice(0, CONFIG.MAX_BATCH_SIZE);
submitEvents(events).catch(err=> {
console.warn('[XNWA] Flush failed:', err.message);
if(state.retryState.attempts < CONFIG.MAX_RETRIES){
state.queue.unshift(...events);
}});
}
async function submitEvents(events){
const payload={
collector_token: state.collectorToken,
events: events
};
const response=await fetch(state.endpoint + '/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
credentials: 'same-origin',
keepalive: true
});
if(!response.ok){
if(response.status===429){
state.quality.rateLimited +=events.length;
await sleep(5000);
}else if(response.status===401||response.status===403){
await bootstrap();
return submitEvents(events);
}
throw new Error(`HTTP ${response.status}`);
}
const result=await response.json();
state.quality.accepted +=result.accepted||0;
state.quality.duplicate +=result.duplicate||0;
state.quality.rejected +=result.rejected||0;
}
async function bootstrap(){
if(state.retryState.bootstrapped&&state.collectorToken){
return;
}
try {
const acquisition=extractAcquisitionEvidence();
const referrer=extractReferrerEvidence();
const payload={
acquisition: { ...acquisition, ...referrer }};
const response=await fetch(state.endpoint + '/bootstrap', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
if(!response.ok){
throw new Error(`HTTP ${response.status}`);
}
const data=await response.json();
if(data.ok){
state.collectorToken=data.collector_token;
state.tokenExpiresAt=Date.now() + (data.token_validity * 1000);
state.enabled=true;
state.initialized=true;
state.retryState.bootstrapped=true;
state.retryState.attempts=0;
}else{
throw new Error('Bootstrap failed');
}} catch (error){
state.quality.bootstrapFailed++;
state.retryState.attempts++;
if(state.retryState.attempts < CONFIG.MAX_RETRIES){
await sleep(CONFIG.RETRY_BACKOFF_MS * state.retryState.attempts);
return bootstrap();
}
console.warn('[XNWA] Bootstrap failed after retries, disabling tracker');
state.enabled=false;
}}
let pageViewFired=false;
function sendPageView(){
if(pageViewFired) return;
pageViewFired=true;
const event=createEvent('page_view', {
document_language: getDocumentLanguage(),
viewport_class: getViewportClass()
});
if(event){
enqueue(event);
}}
function initEngagement(){
state.engagement.lastActivity=Date.now();
state.engagement.lastFlushTime=Date.now();
state.engagement.flushTimer=setInterval(()=> {
if(state.engagement.accumulated > 0){
flushEngagement('interval');
}}, CONFIG.ENGAGEMENT_FLUSH_INTERVAL);
const activityEvents=['pointerdown', 'pointermove', 'keydown', 'scroll'];
for (const eventType of activityEvents){
document.addEventListener(eventType, handleActivity, { passive: true });
}
document.addEventListener('visibilitychange', handleVisibilityChange, { passive: true });
window.addEventListener('pagehide', handlePageHide);
window.addEventListener('beforeunload', handleBeforeUnload);
}
function handleActivity(){
if(!state.enabled) return;
if(document.visibilityState!=='visible') return;
const now=Date.now();
const timeSinceLastActivity=now - state.engagement.lastActivity;
if(timeSinceLastActivity > CONFIG.INACTIVITY_TIMEOUT){
if(state.engagement.accumulated > 0){
flushEngagement('inactivity');
}
state.engagement.active=false;
state.engagement.accumulated=0;
}
if(!state.engagement.active){
state.engagement.active=true;
state.engagement.lastActivity=now;
state.engagement.lastFlushTime=now;
}else{
state.engagement.accumulated +=Math.min(timeSinceLastActivity,
CONFIG.INACTIVITY_TIMEOUT
);
state.engagement.lastActivity=now;
}}
function handleVisibilityChange(){
if(document.visibilityState==='hidden'){
if(state.engagement.accumulated > 0){
flushEngagement('hidden');
}
state.engagement.active=false;
}else if(document.visibilityState==='visible'){
if(state.engagement.active){
state.engagement.lastActivity=Date.now();
state.engagement.lastFlushTime=Date.now();
}}
}
function handlePageHide(){
if(state.engagement.accumulated > 0){
flushEngagement('pagehide');
}
flush();
}
function handleBeforeUnload(){
if(state.engagement.accumulated > 0){
flushEngagement('pagehide');
}
flush();
}
function flushEngagement(reason){
if(state.engagement.accumulated <=0) return;
if(state.engagement.flushReason===reason&&reason!=='interval'){
return;
}
const activeMs=Math.floor(state.engagement.accumulated);
const event=createEvent('engagement', {
active_ms: activeMs,
flush_reason: reason
});
if(event){
enqueue(event);
}
state.engagement.accumulated=0;
state.engagement.lastFlushTime=Date.now();
state.engagement.flushReason=reason;
}
function initScrollDepth(){
window.addEventListener('scroll', handleScroll, { passive: true });
}
function handleScroll(){
if(!state.enabled) return;
const scrollHeight=document.documentElement.scrollHeight;
const clientHeight=document.documentElement.clientHeight;
const scrollTop=window.scrollY||document.documentElement.scrollTop;
const maxScroll=scrollHeight - clientHeight;
if(maxScroll <=0) return;
const scrollPercent=Math.round((scrollTop / maxScroll) * 100);
for (const threshold of CONFIG.SCROLL_THRESHOLDS){
if(scrollPercent >=threshold&&!state.scrollDepth.fired.has(threshold)){
state.scrollDepth.fired.add(threshold);
sendScrollDepth(threshold);
}}
}
function sendScrollDepth(depthPercent){
const event=createEvent('scroll_depth', {
depth_percent: depthPercent
});
if(event){
enqueue(event);
}}
function initClickTracking(){
document.addEventListener('click', handleClick, { passive: true });
}
function handleClick(event){
if(!state.enabled) return;
const target=findMeaningfulTarget(event.target);
if(!target) return;
const href=target.getAttribute('href');
if(href){
const url=parseUrl(href);
if(url&&isOutboundUrl(url)){
sendOutboundClick(target, url);
return;
}}
sendClick(target);
}
function findMeaningfulTarget(element){
if(!element) return null;
for (const selector of CONFIG.TRACKED_ELEMENTS){
const found=element.closest(selector);
if(found) return found;
}
return null;
}
function isOutboundUrl(url){
if(!url) return false;
if(!['http:', 'https:'].includes(url.protocol)) return false;
const targetHost=normalizeHostname(url.hostname);
const currentHost=normalizeHostname(window.location.hostname);
return targetHost!==currentHost;
}
function sendClick(target){
const properties={
element_kind: target.tagName.toLowerCase()
};
const trackLabel=target.getAttribute('data-xnwa-track');
if(trackLabel){
const sanitized=sanitizeTrackLabel(trackLabel);
if(sanitized){
properties.track_label=sanitized;
}}
const href=target.getAttribute('href');
if(href){
const url=parseUrl(href);
if(url){
if(url.protocol==='mailto:'){
properties.target_kind='mailto';
}else if(url.protocol==='tel:'){
properties.target_kind='tel';
}else if(url.protocol==='javascript:'){
properties.target_kind='javascript';
}else if(isOutboundUrl(url)){
properties.target_kind='internal_link';
}else{
properties.target_kind='internal_link';
}}
}
const event=createEvent('click', properties);
if(event){
enqueue(event);
}}
function sendOutboundClick(target, url){
const properties={
target_host: normalizeHostname(url.hostname),
target_kind: 'external_link'
};
const trackLabel=target.getAttribute('data-xnwa-track');
if(trackLabel){
const sanitized=sanitizeTrackLabel(trackLabel);
if(sanitized){
properties.track_label=sanitized;
}}
const event=createEvent('outbound_click', properties);
if(event){
enqueue(event);
}}
function sanitizeTrackLabel(label){
if(typeof label!=='string') return null;
const maxLength=50;
let sanitized=label.trim().slice(0, maxLength);
sanitized=sanitized.replace(/[^a-zA-Z0-9_-]/g, '');
return sanitized.length > 0 ? sanitized:null;
}
function initBFCacheHandling(){
window.addEventListener('pageshow', (event)=> {
if(event.persisted){
pageViewFired=true;
}});
}
function sleep(ms){
return new Promise(resolve=> setTimeout(resolve, ms));
}
function init(endpoint){
if(state.initialized) return;
state.endpoint=endpoint;
sendFirstPageview();
bootstrap().then(()=> {
if(state.enabled){
initEngagement();
initScrollDepth();
initClickTracking();
initBFCacheHandling();
}});
}
function sendFirstPageview(){
if(state.pageviewSent) return;
state.pageviewSent=true;
const event=createEvent('page_view', {
document_language: getDocumentLanguage(),
viewport_class: getViewportClass()
});
if(!event) return;
const payload=JSON.stringify({
event_id: event.event_id,
event_time: event.event_time,
page_path: event.page_path,
properties: event.properties
});
if(navigator.sendBeacon){
const endpoint=state.endpoint + '/first-pageview';
const sent=navigator.sendBeacon(endpoint, payload);
if(sent){
state.quality.accepted++;
return;
}}
fetch(state.endpoint + '/first-pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true
}).catch(()=> {
});
}
function getQualityCounters(){
return { ...state.quality };}
function isEnabled(){
return state.enabled;
}
function looksLikePII(value){
if(typeof value!=='string') return false;
const emailPattern=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phonePattern=/^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}$/;
return emailPattern.test(value)||phonePattern.test(value);
}
function checkPropertiesForPII(eventName, properties){
if(!properties||typeof properties!=='object') return [];
const piiFields=[];
for (const [key, value] of Object.entries(properties)){
if(looksLikePII(value)){
piiFields.push(key);
}}
return piiFields;
}
function trackBusiness(eventName, properties){
if(!state.enabled) return false;
if(!eventName||typeof eventName!=='string') return false;
const piiFields=checkPropertiesForPII(eventName, properties);
if(piiFields.length > 0){
console.warn('[XNWA] PII detected in business event properties:', piiFields.join(', '));
return false;
}
const event=createEvent(eventName, properties);
if(event){
enqueue(event);
return true;
}
return false;
}
function trackReservationStart(properties){
if(!state.enabled) return null;
properties=properties||{};
if(!properties.reservation_flow_id){
if(typeof crypto!=='undefined'&&crypto.randomUUID){
properties.reservation_flow_id=crypto.randomUUID();
}else{
properties.reservation_flow_id='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c){
const r=Math.random() * 16 | 0;
const v=c==='x' ? r:(r & 0x3 | 0x8);
return v.toString(16);
});
}}
const result=trackBusiness('reservation_start', properties);
if(result){
return { reservationFlowId: properties.reservation_flow_id };}
return null;
}
function trackMenuView(properties){
if(!state.enabled) return false;
return trackBusiness('menu_view', properties);
}
window.XNWA={
init: init,
getQualityCounters: getQualityCounters,
isEnabled: isEnabled,
VERSION: CONFIG.VERSION,
trackBusiness: trackBusiness,
trackReservationStart: trackReservationStart,
trackMenuView: trackMenuView
};})();