';
} else {
html += '';
}
if (f.airlines && f.airlines.length) {
html += '
';
f.airlines.forEach(function(a){
html += '';
});
html += '
';
}
html += '
';
return html;
}
function renderHotelCard(h){
var subParts = [];
if (h.avg_stars) {
var stars = Math.max(1, Math.min(5, h.avg_stars|0));
subParts.push(new Array(stars + 1).join('★'));
}
if (h.cheapest_name) {
var name = h.cheapest_name;
if (name.length > 24) name = name.slice(0, 24) + '…';
subParts.push(escapeAttr(name));
}
return '
';
}
function hydratePreview(data){
if (!data) return;
var cardOrderMap = {
'hotels': ['hotel', 'trip', 'flight'],
'trip': ['trip', 'hotel', 'flight'],
'flights': ['flight', 'trip', 'hotel']
};
var order = cardOrderMap[INTENT] || ['flight', 'trip', 'hotel'];
var cardCount = (data.flight ? 1 : 0) + (data.hotel ? 1 : 0) + (data.trip ? 1 : 0);
if (cardCount === 0) return;
// Modal cards
if (previewContainer) {
var html = '
';
for (var i = 0; i < order.length; i++) {
var k = order[i];
if (k === 'flight' && data.flight) html += renderFlightCard(data.flight);
else if (k === 'hotel' && data.hotel) html += renderHotelCard(data.hotel);
else if (k === 'trip' && data.trip) html += renderTripCard(data.trip);
}
html += '
';
previewContainer.innerHTML = html;
previewContainer.hidden = false;
previewContainer.removeAttribute('aria-hidden');
}
// Inline strip price
if (inlinePriceSlot) {
var inlinePrice = null;
if (INTENT === 'flights' && data.flight && data.flight.from) {
inlinePrice = formatINR(data.flight.from) + '+';
} else if (INTENT === 'hotels' && data.hotel && data.hotel.from_per_night) {
inlinePrice = formatINR(data.hotel.from_per_night) + '/night+';
} else if (INTENT === 'trip' && data.trip && data.trip.combined_from) {
inlinePrice = formatINR(data.trip.combined_from) + '+';
}
if (inlinePrice) {
inlinePriceSlot.textContent = ' · ' + inlinePrice;
inlinePriceSlot.style.color = '#e8734a';
inlinePriceSlot.hidden = false;
}
}
}
function fetchPreview(){
if (!PREVIEW_REQ) return;
// Defer until idle / after DOM ready to avoid competing with LCP resources
var run = function(){
fetch(BASE + '/api/fare-preview.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(PREVIEW_REQ)
}).then(function(r){ return r.ok ? r.json() : null; })
.then(function(j){
if (j && j.ok && j.data && j.data.has_data) hydratePreview(j.data);
}).catch(function(){ /* silent */ });
};
if (window.requestIdleCallback) {
window.requestIdleCallback(run, { timeout: 2000 });
} else {
setTimeout(run, 300);
}
}
if (!FECTA_IS_BOT) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fetchPreview);
} else {
fetchPreview();
}
}
// ============================================================
// Trigger strategy — multi-signal, anti-false-positive
// ============================================================
// DESKTOP triggers (first-of):
// 1. Exit-intent — cursor exits viewport upward, with X-bounds check
// to avoid false positives from cursor moving to tabs/bookmarks
// 2. Tab hidden (visibilitychange) — user switches away
// 3. 60% scroll + 4s idle — engaged reader pausing
// 4. 75s dwell timer — lingering visitor
//
// MOBILE triggers (first-of):
// 1. 50% scroll + 5s idle
// 2. 60s dwell
// 3. pagehide — iOS Safari tab-close equivalent
//
// All respect frequency cap. safeOpen() guarantees fires-once-per-page.
// beforeunload intentionally NOT used — browsers ignore DOM mutations there.
// ============================================================
// ============================================================
// Bot suppression — modal never opens for crawlers
// ============================================================
// Flag computed once near the top of this IIFE (FECTA_IS_BOT); see the
// note there. Impression logging above still fired for bots (intentional
// — analytics visibility into what bots crawl); fare-preview fetch and
// everything below (modal trigger registration) is skipped so crawlers
// never see an open modal that could be flagged as an intrusive
// interstitial by Google's renderer.
// ============================================================
if (FECTA_IS_BOT) {
return;
}
// ?fecta_test=1 — admin / QA preview: force-open immediately, bypass all caps
if (FECTA_TEST) {
setTimeout(function(){ safeOpen('fecta_test'); }, 300);
// Console breadcrumb so it's obvious this is a forced state
try { console.info('[fecta] ?fecta_test=1 active — bypassing frequency caps'); } catch(e){}
return;
}
if (isRecentlyDismissed() || isCaptured()) {
if (fab && !isCaptured()) fab.classList.add('show');
return;
}
var isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
// ============================================================
// Engagement gate for tab-hide / pagehide triggers
// ------------------------------------------------------------
// visibilitychange (desktop) and pagehide (mobile) both fire when a tab
// that was opened in the BACKGROUND becomes visible, or on very early
// app-switches. Without a gate, a link opened in a background tab pops the
// modal within seconds of the user first switching to it — before they've
// read anything. We require two things before those signals may fire:
// (a) the page was actually visible to the user at least once, and
// (b) at least MIN_ENGAGE_MS of genuine foreground time has elapsed.
// Exit-intent (mouseleave-up), scroll+idle, and the dwell timers are NOT
// gated by this — they already imply engagement on their own.
var MIN_ENGAGE_MS = 8000; // require ~8s of real viewing before tab-leave counts
var everVisible = (document.visibilityState === 'visible');
var engagedAt = everVisible ? Date.now() : 0;
document.addEventListener('visibilitychange', function(){
if (document.visibilityState === 'visible') {
everVisible = true;
if (!engagedAt) engagedAt = Date.now();
}
});
function isEngaged(){
return everVisible && engagedAt > 0 && (Date.now() - engagedAt) >= MIN_ENGAGE_MS;
}
if (!isMobile) {
// 1. Exit-intent — only fire when cursor genuinely exits the top of the viewport,
// not when it moves to URL bar / tab strip / bookmark bar (those are still
// within browser chrome but clientX can be valid while clientY hits 0).
// Require cursor to be well within horizontal bounds to count as "exit up".
document.addEventListener('mouseleave', function(e){
if (e.clientY > 0) return;
// Ignore corner-chrome movements (nav buttons, menu, etc. at screen edges)
var margin = 60;
if (e.clientX < margin || e.clientX > (window.innerWidth - margin)) return;
safeOpen('exit_intent');
});
// 2. Tab becomes hidden — "user is leaving" signal (Cmd+Tab, tab switch,
// minimize, Cmd+W before teardown). Gated by isEngaged() so a
// background-tab open can't fire it the instant the user switches in.
document.addEventListener('visibilitychange', function(){
if (document.visibilityState === 'hidden' && isEngaged()) safeOpen('visibility_hidden');
});
// 3. 60% scroll + 4s idle
var scrolledDesktop = false;
var scrollIdleTimer = null;
window.addEventListener('scroll', function(){
if (triggered || dismissed || scrolledDesktop) return;
var h = document.documentElement;
var denom = Math.max(h.scrollHeight, 1);
var pct = (h.scrollTop + window.innerHeight) / denom;
if (pct > 0.6) {
scrolledDesktop = true;
clearTimeout(scrollIdleTimer);
scrollIdleTimer = setTimeout(function(){ safeOpen('scroll_idle'); }, 4000);
}
}, { passive: true });
// 4. 75-second dwell
setTimeout(function(){ safeOpen('timer_75s'); }, 75000);
} else {
// MOBILE
var scrolled = false;
var idleTimer = null;
window.addEventListener('scroll', function(){
if (triggered || dismissed || scrolled) return;
var h = document.documentElement;
var denom = Math.max(h.scrollHeight, 1);
var pct = (h.scrollTop + window.innerHeight) / denom;
if (pct > 0.5) {
scrolled = true;
clearTimeout(idleTimer);
idleTimer = setTimeout(function(){ safeOpen('scroll_idle'); }, 5000);
}
}, { passive: true });
setTimeout(function(){ safeOpen('timer_60s'); }, 60000);
window.addEventListener('pagehide', function(){ if (isEngaged()) safeOpen('pagehide'); });
}
})();