';
appendWidget(bookingDiv, confirmCard);
sendLeadEmail(info.first || name.split(' ')[0], info.last || name.split(' ').slice(1).join(' '), info.company || '', info.phone, info.email, label + ' \u2014 ' + whenText, info.zip || '', false);
} else if (data.reason === 'slot_taken' && ++_slotTakenCount <= 2) {
addMessage('agent', 'That time was just taken \u2014 please pick another one.');
showInlineBooking(serviceKey);
} else {
sendBookingRequest(serviceKey, whenText, info);
}
})
.catch(function(err) {
console.error('Booking error:', err);
sendBookingRequest(serviceKey, whenText, info);
});
}
// ── MENU CARD HELPER ────────────────────────────────────────────────────
function createMenuCard(options) {
const card = document.createElement('div');
card.className = 'menu-card';
options.forEach(opt => {
if (opt.url) {
const link = document.createElement('a');
link.className = 'menu-btn samples';
link.textContent = opt.label;
link.href = opt.url;
link.target = '_blank';
card.appendChild(link);
} else if (opt.onclick) {
const btn = document.createElement('button');
btn.className = 'menu-btn';
btn.textContent = opt.label;
btn.onclick = opt.onclick;
card.appendChild(btn);
}
});
return card;
}
// ── MAIN MENU (shared) ───────────────────────────────────────────────────
function showMainMenu(parentDiv, options) {
var menuOptions = options || ['Products & Collections', 'Buy Samples', 'Showroom locations', 'Book an appointment', 'Talk to a Real Person'];
const qr = document.createElement('div');
qr.className = 'quick-replies';
menuOptions.forEach(label => {
const chip = document.createElement('button');
chip.className = 'qr-chip';
chip.textContent = label;
chip.onclick = () => {
qr.remove();
handleMenuChoice(label);
};
qr.appendChild(chip);
});
appendWidget(parentDiv, qr);
}
function handleMenuChoice(label) {
if (label === 'Products & Collections') {
addMessage('user', label);
showCategoryMenu();
} else if (label === 'Buy Samples') {
addMessage('user', label);
const replyDiv = addMessage('agent', 'You can order individual finish samples AND sample books directly from our shop at shop.lioher.com. Sample books are a great way to explore full collections \u2014 we carry Book Luxe ($70), Book Zenit ($60), and Book Syncron ($70). Here is the link:');
const samplesCard = createMenuCard([{ label: 'Visit shop.lioher.com', url: CONFIG.urls.shop }]);
appendWidget(replyDiv, samplesCard);
} else if (label === 'Loyalty Program') {
addMessage('user', label);
const lpDiv = addMessage('agent', "Our Loyalty Program rewards trade professionals with 5 tiers \u2014 Silver, Gold, Platinum, Partner, and Partner Pro \u2014 offering free samples, sample books, tower displays, social media features, and discounts on delivery and design fees based on your annual purchase volume.");
const qr2 = document.createElement('div');
qr2.className = 'quick-replies';
const chip = document.createElement('button');
chip.className = 'qr-chip';
chip.textContent = 'Learn more \u2014 Quick Call';
chip.onclick = () => {
qr2.remove();
addMessage('user', 'Learn more \u2014 Quick Call');
showAppointmentMenu();
};
qr2.appendChild(chip);
appendWidget(lpDiv, qr2);
} else if (label === 'Book an appointment') {
addMessage('user', label);
showAppointmentMenu();
} else if (label === 'Find a Professional') {
addMessage('user', label);
const replyDiv = addMessage('agent', 'You can find a certified Lioher professional near you on our directory:');
const profCard = createMenuCard([{ label: 'Find a Professional Near Me', url: CONFIG.urls.findPro }]);
appendWidget(replyDiv, profCard);
} else if (label === 'View Climacore Specs' || label === 'Ver Ficha Técnica Climacore') {
addMessage('user', label);
const isES = (label === 'Ver Ficha Técnica Climacore');
const replyDiv = addMessage('agent', isES
? 'Aquí tienes la ficha técnica completa de Climacore — paneles para exteriores con garantía de 50 años:'
: 'Here is the full Climacore technical spec sheet — outdoor panels with a 50-year warranty:');
const specCard = createMenuCard([{ label: isES ? 'Abrir Ficha Técnica Climacore (PDF)' : 'Open Climacore Spec Sheet (PDF)', url: CONFIG.urls.climacoreSpec }]);
appendWidget(replyDiv, specCard);
} else if (label === 'View Door Specs' || label === 'Ver Ficha Técnica Puertas') {
addMessage('user', label);
const isES = (label === 'Ver Ficha Técnica Puertas');
const replyDiv = addMessage('agent', isES
? 'Aquí tienes la ficha técnica de nuestras puertas — cubre los 5 estilos (Slab, Finger Pull, Mallorca, Cava y el nuevo Slim Cava 2026):'
: 'Here is the Lioher door spec sheet — covers all 5 styles (Slab, Finger Pull, Mallorca, Cava, and the new 2026 Slim Cava):');
const doorSpecCard = createMenuCard([{ label: isES ? 'Abrir Ficha Técnica Puertas (PDF)' : 'Open Door Spec Sheet (PDF)', url: CONFIG.urls.doorsSpec }]);
appendWidget(replyDiv, doorSpecCard);
} else if (label === 'Showroom locations') {
addMessage('user', label);
showShowroomFlow();
} else if (label === 'Open an Account') {
addMessage('user', label);
const rd = addMessage('agent', 'Open a new trade account here:');
const ac = createMenuCard([{ label: 'Open a Trade Account', url: CONFIG.urls.openAccount }]);
appendWidget(rd, ac);
} else if (label === 'Talk to a Real Person' || label === 'Hablar con una Persona') {
addMessage('user', label);
if (leadCollected) {
handoffToHuman('Customer requested human agent');
addMessage('agent', "I\u2019ve notified our inside sales team \u2014 they have your full conversation. " + callbackConfirmMsg(leadInfo.phone) + " You can keep chatting with me in the meantime!");
} else {
var handoffDiv = addMessage('agent', "I'll connect you with our team! Just need a couple details so they can reach you.");
var hoForm = createLeadForm('ho', [
{ id: 'first', placeholder: 'First Name' },
{ id: 'last', placeholder: 'Last Name' },
{ id: 'phone', placeholder: 'Phone Number', type: 'tel' },
{ id: 'email', placeholder: 'Email Address', type: 'email' }
], function(form, prefix) {
var first = document.getElementById(prefix + '-first').value.trim();
var last = document.getElementById(prefix + '-last').value.trim();
var phone = document.getElementById(prefix + '-phone').value.trim();
var email = document.getElementById(prefix + '-email').value.trim();
[prefix+'-first',prefix+'-phone',prefix+'-email'].forEach(function(id){ document.getElementById(id).classList.remove('error'); });
var valid = true;
if (!first) { document.getElementById(prefix+'-first').classList.add('error'); valid = false; }
if (!phone) { document.getElementById(prefix+'-phone').classList.add('error'); valid = false; }
if (!email) { document.getElementById(prefix+'-email').classList.add('error'); valid = false; }
if (!valid) return;
form.remove();
leadCollected = true;
leadInfo = { first: first, last: last || '', company: '', phone: phone, email: email };
handoffToHuman('Customer requested human agent');
logAllConversation();
chatLogged = true;
addMessage('agent', "Our inside sales team has been notified with your full conversation. " + callbackConfirmMsg(phone));
});
hoForm.buttonText = 'Connect Me';
appendWidget(handoffDiv, hoForm);
}
} else if (label === 'See Availability Chart' || label === 'Ver Carta de Disponibilidad') {
addMessage('user', label);
const avDiv = addMessage('agent', 'Here is our latest Availability Chart for 2026–2027. Click below to download:');
const avCard = document.createElement('div');
avCard.className = 'menu-card';
const avBtn = document.createElement('a');
avBtn.className = 'menu-btn samples';
avBtn.textContent = detectedLanguage === 'es' ? 'Descargar Carta de Disponibilidad' : 'Download Availability Chart';
avBtn.href = CONFIG.urls.availabilityChart;
avBtn.target = '_blank';
avBtn.setAttribute('download', '');
avCard.appendChild(avBtn);
appendWidget(avDiv, avCard);
} else if (label === 'See All Finishes') {
addMessage('user', label);
const finishesDiv = addMessage('agent', 'Explore all our finishes and collections here:');
const finishesCard = createMenuCard([{ label: 'See All Finishes', url: CONFIG.urls.designs }]);
appendWidget(finishesDiv, finishesCard);
} else {
sendUserMessage(label);
}
}
// ── GREET ──────────────────────────────────────────────────────────────────
function greet() {
const greetDiv = addMessage('agent', "Hey there! I'm Lio, and I'm here to make your Lioher experience as smooth as possible. Whether you're exploring our collections, have a quick question, or want to connect with our team \u2014 I've got you. What brings you here today?");
showMainMenu(greetDiv, ['Products & Collections', 'Buy Samples', 'Showroom locations', 'Book an appointment', 'Talk to a Real Person']);
lastWidgetType = 'greeting';
sessionDirty = true;
}
// ── CATEGORY MENU ──────────────────────────────────────────────────────────
function showCategoryMenu() {
const replyDiv = addMessage('agent', 'What category are you interested in?');
const card = document.createElement('div');
card.className = 'menu-card';
card.innerHTML = '
Our Products
';
Object.keys(PRODUCT_DATA).forEach(cat => {
const btn = document.createElement('button');
btn.className = 'menu-btn';
btn.textContent = cat;
btn.onclick = () => {
card.remove();
addMessage('user', cat);
showProductsInCategory(cat);
};
card.appendChild(btn);
});
// Buy samples button
const samplesBtn = document.createElement('a');
samplesBtn.className = 'menu-btn samples';
samplesBtn.textContent = 'Buy Samples';
samplesBtn.href = CONFIG.urls.shop;
samplesBtn.target = '_blank';
card.appendChild(samplesBtn);
appendWidget(replyDiv, card);
}
// ── PRODUCTS IN CATEGORY (multi-select) ───────────────────────────────────
function showProductsInCategory(category) {
const data = PRODUCT_DATA[category];
const replyDiv = addMessage('agent', data.intro);
const card = document.createElement('div');
card.className = 'menu-card';
// Checkbox items
const checkboxes = [];
data.items.forEach(item => {
const label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:7px;border:1px solid rgba(91,126,191,0.2);background:#fff;cursor:pointer;margin-bottom:5px;font-size:12.5px;font-family:DM Sans,sans-serif;color:#1a1a1a;transition:background 0.15s;';
label.onmouseenter = () => label.style.background = 'rgba(91,126,191,0.08)';
label.onmouseleave = () => { if (!cb.checked) label.style.background = '#fff'; };
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = item;
cb.style.cssText = 'width:15px;height:15px;accent-color:#5b7ebf;cursor:pointer;flex-shrink:0;';
cb.onchange = () => { label.style.background = cb.checked ? 'rgba(91,126,191,0.12)' : '#fff'; };
checkboxes.push(cb);
label.appendChild(cb);
label.appendChild(document.createTextNode(item));
card.appendChild(label);
});
// Confirm button
const confirmBtn = document.createElement('button');
confirmBtn.className = 'lead-form-submit';
confirmBtn.textContent = 'Continue';
confirmBtn.style.marginTop = '10px';
confirmBtn.onclick = () => {
const selected = checkboxes.filter(c => c.checked).map(c => c.value);
if (selected.length === 0) { confirmBtn.style.background = '#e88'; setTimeout(() => confirmBtn.style.background = '', 600); return; }
card.remove();
const userMsg = selected.join(', ');
addMessage('user', userMsg);
const hasClosets = userMsg.includes('Closets') && !userMsg.includes(',');
const hasEssentials = userMsg.includes('Essentials') && !userMsg.includes(',');
const followDiv = addMessage('agent', hasClosets
? 'Great! Explore our closet line or design your own:'
: (hasEssentials ? 'Essentials includes two products. Which one would you like to look at?' : 'What would you like to know?'));
if (hasClosets) {
const cc = document.createElement('div'); cc.className = 'menu-card';
const lb = document.createElement('a'); lb.className = 'menu-btn samples'; lb.textContent = 'Learn More About Closets'; lb.href = CONFIG.urls.closets; lb.target = '_blank';
const cb = document.createElement('a'); cb.className = 'menu-btn'; cb.textContent = 'Design Your Own Closet — 3D Configurator'; cb.href = CONFIG.urls.closetConfigurator; cb.target = '_blank'; cb.style.cssText = 'margin-top:5px;display:block;';
cc.appendChild(lb); cc.appendChild(cb); appendWidget(followDiv, cc);
}
if (hasEssentials) {
const ec = document.createElement('div'); ec.className = 'menu-card';
[['White Melamine TFL', 'Tell me about the White Melamine TFL'],
['Plywood — Real Wood Panels', 'Tell me about the plywood panels'],
['Both — TFL & Plywood', 'Tell me about both the White Melamine TFL and the plywood panels']].forEach((opt, oi) => {
const eb = document.createElement('button'); eb.className = 'menu-btn'; eb.textContent = opt[0];
if (oi > 0) eb.style.marginTop = '5px';
eb.onclick = () => { ec.remove(); sendUserMessage(opt[1]); };
ec.appendChild(eb);
});
appendWidget(followDiv, ec);
history.push({ role: 'user', content: 'I am interested in: ' + userMsg });
return;
}
const qr = document.createElement('div');
qr.className = 'quick-replies';
['Materials & finishes', 'Availability', 'Pricing', 'Quick Call'].forEach(q => {
const chip = document.createElement('button');
chip.className = 'qr-chip';
chip.textContent = q;
chip.onclick = () => {
qr.remove();
if (q === 'Quick Call') {
addMessage('user', 'Quick Call');
showCoastPicker('quickCall');
} else {
sendUserMessage('About ' + userMsg + ': ' + q);
}
};
qr.appendChild(chip);
});
appendWidget(followDiv, qr);
history.push({ role: 'user', content: 'I am interested in: ' + userMsg });
};
card.appendChild(confirmBtn);
appendWidget(replyDiv, card);
}
// ── LEAD FORM ──────────────────────────────────────────────────────────────
function showLeadForm(parentDiv) {
const fields = [
{ id: 'first', placeholder: 'First Name' },
{ id: 'last', placeholder: 'Last Name' },
{ id: 'company', placeholder: 'Company Name' },
{ id: 'phone', placeholder: 'Phone Number', type: 'tel' },
{ id: 'email', placeholder: 'Email Address', type: 'email' },
{ id: 'zip', placeholder: 'Zip Code' }
];
const onSubmit = function(form, prefix) {
const first = document.getElementById(prefix + '-first').value.trim();
const last = document.getElementById(prefix + '-last').value.trim();
const company = document.getElementById(prefix + '-company').value.trim();
const phone = document.getElementById(prefix + '-phone').value.trim();
const email = document.getElementById(prefix + '-email').value.trim();
[prefix+'-first',prefix+'-last',prefix+'-phone',prefix+'-email'].forEach(id => document.getElementById(id).classList.remove('error'));
let valid = true;
if (!first) { document.getElementById(prefix+'-first').classList.add('error'); valid = false; }
if (!last) { document.getElementById(prefix+'-last').classList.add('error'); valid = false; }
if (!phone) { document.getElementById(prefix+'-phone').classList.add('error'); valid = false; }
if (!email) { document.getElementById(prefix+'-email').classList.add('error'); valid = false; }
if (!valid) return;
form.remove();
const container = getChatMessages();
const div = document.createElement('div');
div.className = 'msg agent';
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
if (!selectedAppointmentUrl) {
bubble.textContent = isWeekend() ? 'Got it, ' + first + '! We\u2019re closed on weekends \u2014 our team will call you first thing Monday morning!' : 'Got it, ' + first + '! Our team will give you a call back shortly.';
} else {
bubble.textContent = 'Thanks, ' + first + '! Choose your appointment type:';
}
div.appendChild(bubble);
// Only show booking card if they picked an appointment type
if (selectedAppointmentUrl) {
const card = document.createElement('div');
card.className = 'booking-card';
card.innerHTML = '
Book an Appointment
' + selectedAppointmentType + '';
div.appendChild(card);
}
const time = document.createElement('div');
time.className = 'msg-time';
time.textContent = getTime();
div.appendChild(time);
container.appendChild(div);
scrollBottom();
history.push({ role: 'user', content: 'My details: ' + first + ' ' + last + ', company: ' + (company||'N/A') + ', phone: ' + phone + ', email: ' + email });
history.push({ role: 'assistant', content: 'Thanks ' + first + ', here are your booking options.' });
// Send lead to n8n — DEFERRED up to 90s so the timeline answer (if given) rides in the
// SAME row. Previously this sent twice (base row + "— Timeline: X" row) = duplicates in the sheet.
leadCollected = true;
leadInfo = { first, last, company, phone, email };
var pendingBaseType = selectedAppointmentType || 'Callback Request';
var pendingSendSMS = !selectedAppointmentUrl;
var pendingLeadSent = false;
var flushPendingLead = function(suffix) {
if (pendingLeadSent) return;
pendingLeadSent = true;
clearTimeout(pendingLeadTimer);
sendLeadEmail(first, last, company, phone, email, pendingBaseType + (suffix || ''), '', pendingSendSMS);
};
var pendingLeadTimer = setTimeout(function() { flushPendingLead(''); }, 90000);
window.addEventListener('pagehide', function() { flushPendingLead(''); });
// Lead qualification: ask about timeline (optional, helps sales team prioritize)
setTimeout(function() {
if (!leadInfo.timeline) {
var tDiv = addMessage('agent', detectedLanguage === 'es' ? '\u00bfCu\u00e1ndo espera comenzar su proyecto?' : 'When are you hoping to start your project?');
var tqr = document.createElement('div');
tqr.className = 'quick-replies';
var timelineOpts = detectedLanguage === 'es'
? ['En 30 d\u00edas', '1\u20133 meses', '3\u20136 meses', 'Solo explorando']
: ['Within 30 days', '1\u20133 months', '3\u20136 months', 'Just exploring'];
timelineOpts.forEach(function(opt) {
var c = document.createElement('button');
c.className = 'qr-chip';
c.textContent = opt;
c.onclick = function() {
tqr.remove();
addMessage('user', opt);
leadInfo.timeline = opt;
sessionDirty = true;
// Single send: the lead submit above is DEFERRED (flushPendingLead), so the
// timeline answer rides in the SAME row \u2014 one row per lead, no duplicates.
try { flushPendingLead(' \u2014 Timeline: ' + opt); } catch(e) {}
var thanks = opt.toLowerCase().indexOf('explor') > -1 || opt.toLowerCase().indexOf('6') > -1
? (detectedLanguage === 'es' ? '\u00a1Perfecto, no hay prisa! Estamos aqu\u00ed cuando est\u00e9 listo.' : 'Perfect, no rush! We\'re here whenever you\'re ready.')
: (detectedLanguage === 'es' ? '\u00a1Genial! Nuestro equipo priorizar\u00e1 su solicitud.' : 'Great! Our team will prioritize your request.');
addMessage('agent', thanks);
};
tqr.appendChild(c);
});
appendWidget(tDiv, tqr);
}
}, 1200);
};
onSubmit.buttonText = 'Continue to Booking';
const form = createLeadForm('lf', fields, onSubmit);
appendWidget(parentDiv, form);
}
// ── CONTEXTUAL QUICK REPLIES ────────────────────────────────────────────
function generateQuickReplies(responseText) {
var r = responseText.toLowerCase();
var quickRepliesES = {
kitchens: ['Ver Estilos de Cocina', 'Visitar Showroom', 'Solicitar Cotización', 'Hablar con una Persona'],
closets: ['Diseñar Mi Closet', 'Visitar Showroom', 'Ver Opciones de Closet', 'Hablar con una Persona'],
outdoor: ['Ver Cocinas Exteriores', 'Visitar Showroom', 'Hablar con una Persona'],
climacore: ['Ver Ficha Técnica Climacore', 'Ver Cocinas Exteriores', 'Hablar con una Persona'],
doors: ['Ver Ficha Técnica Puertas', 'Visitar Showroom', 'Hablar con una Persona'],
surfaces: ['Pedir Muestras', 'Ver Acabados', 'Visitar Showroom', 'Hablar con una Persona', 'Ver Carta de Disponibilidad'],
professional: ['Abrir Cuenta Trade', 'Encontrar Profesional', 'Agendar Llamada', 'Hablar con una Persona'],
appointment: ['Showroom Miami', 'Ver Ubicaciones', 'Hablar con una Persona'],
availability: ['Hablar con una Persona', 'Ver Carta de Disponibilidad'],
fallback: ['Ver Productos', 'Visitar Showroom', 'Hablar con Ventas', 'Hablar con una Persona']
};
if (detectedLanguage === 'es') {
if (/climacore/i.test(r)) return quickRepliesES.climacore;
if (/slim cava|mallorca|shaker|finger pull|five.piece|five piece|j.handle|slab door|puerta|\bdoors?\b/i.test(r)) return quickRepliesES.doors;
if (/kitchen|cabinet|vanity|cocina|gabinete/i.test(r)) return quickRepliesES.kitchens;
if (/closet/i.test(r)) return quickRepliesES.closets;
if (/outdoor|exterior/i.test(r)) return quickRepliesES.outdoor;
if (/panel|surface|luxe|zenit|syncron|acabado/i.test(r)) return quickRepliesES.surfaces;
if (/professional|trade|dealer|profesional/i.test(r)) return quickRepliesES.professional;
if (/appointment|showroom|visit|cita|visita/i.test(r)) return quickRepliesES.appointment;
if (/availab|lead time|in stock|out of stock|when.*get|how long|color|colour|design|finish|texture|pattern|matt|gloss|oak|walnut|disponib|tiempo de entrega|en stock|agotado|cu[aá]ndo.*llega|cu[aá]nto.*tarda|dise[nñ]o|acabado|textura|patr[oó]n/i.test(r)) return quickRepliesES.availability;
return quickRepliesES.fallback;
}
if (/climacore/i.test(r)) return ['View Climacore Specs', 'See Outdoor Kitchens', 'Talk to a Real Person'];
if (/slim cava|mallorca|shaker|finger pull|five.piece|five piece|j.handle|slab door|\bdoors?\b/i.test(r)) return ['View Door Specs', 'Book a Showroom Visit', 'Talk to a Real Person'];
if (/kitchen|cabinet|vanity/i.test(r)) return ['Book a Showroom Visit', 'Talk to a Real Person'];
if (/closet/i.test(r)) return ['Design My Closet', 'Book a Showroom Visit', 'Talk to a Real Person'];
if (/outdoor/i.test(r)) return ['View Climacore Specs', 'See Outdoor Kitchens', 'Talk to a Real Person'];
if (/panel|surface|luxe|zenit|syncron/i.test(r)) return ['Order Samples', 'Book a Showroom Visit', 'Talk to a Real Person'];
if (/loyalty|discount|reward|tier|silver|gold|platinum|partner/i.test(r)) return ['Open an Account', 'Talk to a Real Person'];
if (/professional|trade|dealer|installer|contractor|designer/i.test(r)) return ['Find a Professional', 'Open an Account', 'Talk to a Real Person'];
if (/account|register|sign up|open.*account|new.*account/i.test(r)) return ['Open an Account', 'Talk to a Real Person'];
if (/appointment|showroom|visit/i.test(r)) return ['Miami Showroom', 'View All Locations', 'Talk to a Real Person'];
if (/availab|lead time|in stock|out of stock|when.*get|how long|color|colour|design|finish|texture|pattern|matt|gloss|oak|walnut/i.test(r)) return ['Talk to a Real Person', 'See Availability Chart'];
return ['Browse Products', 'Book a Showroom Visit', 'Talk to a Real Person'];
}
function showQuickReplies(replies) {
var container = document.createElement('div');
container.className = 'quick-replies';
replies.forEach(function(text) {
var chip = document.createElement('button');
chip.className = 'qr-chip';
chip.textContent = text;
chip.addEventListener('click', function() {
container.remove();
handleMenuChoice(text);
});
container.appendChild(chip);
});
getChatMessages().appendChild(container);
scrollBottom();
}
// ── HANDOFF TO HUMAN ────────────────────────────────────────────────────
var _handoffFiredAt = 0;
var _phoneOfferGiven = false;
function handoffToHuman(reason) {
// Debounce: prevent duplicate handoffs within 10 minutes for the same session.
// Users often click "Talk to Real Person" multiple times or the frustration detector
// re-fires on their next message — either would create duplicate rows/leads.
var now = Date.now();
if (_handoffFiredAt && (now - _handoffFiredAt) < 10 * 60 * 1000) {
console.log('[Lio] handoffToHuman skipped — already fired ' + Math.round((now - _handoffFiredAt) / 1000) + 's ago');
return;
}
_handoffFiredAt = now;
var transcript = buildTranscript();
var payload = {
type: 'human_handoff',
reason: reason || 'Customer requested human agent',
name: leadCollected ? (leadInfo.first + ' ' + leadInfo.last) : 'Unknown',
phone: leadCollected ? leadInfo.phone : '',
email: leadCollected ? leadInfo.email : '',
company: leadCollected ? (leadInfo.company || '') : '',
transcript: transcript,
page: window.location.href,
timestamp: new Date().toISOString(),
sessionId: sessionId
};
fetch(CONFIG.webhooks.lead, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first: payload.name.split(' ')[0] || 'Unknown',
last: payload.name.split(' ').slice(1).join(' ') || '',
company: payload.company,
phone: payload.phone,
email: payload.email,
zip: '',
appointmentType: 'LIVE HANDOFF — ' + (reason || 'Customer requested human'),
isCallback: true,
sendSMS: true,
region: getRegion(),
transcript: transcript,
timestamp: payload.timestamp
})
}).then(function(r) { if (!r.ok) console.error('Handoff webhook failed:', r.status); }).catch(function(e) { console.error('Handoff error:', e); });
}
// ── BUYING SIGNAL DETECTION ─────────────────────────────────────────────
function detectBuyingSignals(text) {
var t = text.toLowerCase();
var score = 0;
var strongSignals = ['budget', 'timeline', 'when can', 'how soon', 'ready to', 'move forward', 'next step', 'place an order', 'want to buy', 'ready to order', "let's do it", "i'm interested", 'im interested', 'send me a quote', 'get a quote'];
strongSignals.forEach(function(s) { if (t.includes(s)) score += 3; });
var mediumSignals = ['how much', 'price', 'cost', 'square foot', 'sqft', 'per foot', 'delivery', 'lead time', 'install', 'dimensions', 'measurements', 'my project', 'my kitchen', 'my home', 'renovation', 'remodel'];
mediumSignals.forEach(function(s) { if (t.includes(s)) score += 2; });
var softSignals = ['which color', 'what finish', 'compare', 'difference between', 'warranty', 'durability', 'sample', 'catalog', 'specific', 'custom', 'nearest showroom', 'your location'];
softSignals.forEach(function(s) { if (t.includes(s)) score += 1; });
return score;
}
// ── LANGUAGE DETECTION ──────────────────────────────────────────────────
// Conservative Spanish detector. Site is US-English by default, so we only switch
// when the evidence is strong. Common bilingual words (closet, mueble, precio, cocina)
// were removed because they appear frequently in US English messages too
// (e.g. "my closets in my new build" is 100% English).
function detectLanguage(text) {
var raw = String(text || '');
var t = raw.toLowerCase();
// Padded string for whole-word matching
var padded = ' ' + t.replace(/[.,!?;:()\[\]"]/g, ' ').replace(/\s+/g, ' ') + ' ';
// Strong Spanish characters (don't exist in English) → immediate switch
if (/[áéíóúñ¿¡]/i.test(raw)) return 'es';
// Whole-word Spanish signals (removed 'closet' / 'mueble' / 'precio' / 'cocina' — bilingual)
var spanishWords = [' hola ', ' buenos ', ' buenas ', ' necesito ', ' quiero ', ' busco ',
' tienen ', ' puedo ', ' donde ', ' cuando ', ' gracias ', ' por favor ', ' ayuda ',
' informacion ', ' cotizacion ', ' presupuesto ', ' para ', ' con ', ' pero ', ' porque ',
' aqui ', ' esta ', ' este ', ' mucho ', ' muy ', ' tambien ', ' todo ', ' hacer ', ' hola,'];
var spanishHits = spanishWords.filter(function(w) { return padded.includes(w); }).length;
// Whole-word English signals — contractions + everyday English words
var englishWords = [" i'm ", " don't ", " you're ", " we're ", " they're ", " that's ",
" i've ", " i'll ", " can't ", " won't ", " didn't ", " doesn't ", " isn't ",
' the ', ' and ', ' have ', ' want ', ' need ', ' with ', ' for ', ' from ',
' about ', ' looking ', ' interested ', ' project ', ' new ', ' build ',
' show ', ' room ', ' homeowner ', ' area ', ' would ', ' should ', ' could ',
' really ', ' just ', ' when ', ' where ', ' what ', ' which ', ' does ',
' price ', ' cost ', ' order ', ' sample '];
var englishHits = englishWords.filter(function(w) { return padded.includes(w); }).length;
// Decision: only switch to Spanish with strong evidence
if (spanishHits >= 2 && spanishHits > englishHits) return 'es';
if (spanishHits >= 1 && englishHits === 0 && t.length < 40) return 'es';
return 'en';
}
// ── SEND ───────────────────────────────────────────────────────────────────
function sendMessage() {
const input = document.getElementById('lioher-input');
const text = input.value.trim();
if (!text || isTyping) return;
input.value = '';
input.style.height = 'auto';
sendUserMessage(text);
}
async function sendUserMessage(text) {
addMessage('user', text);
buyingSignalScore += detectBuyingSignals(text);
// Re-detect language on EVERY user message so Lio switches dynamically if the customer changes languages mid-chat
var newLang = detectLanguage(text);
if (newLang && newLang !== detectedLanguage) {
detectedLanguage = newLang;
sessionDirty = true;
}
lastWidgetType = 'none';
sessionDirty = true;
// Intercept short greetings → respond locally, no API call, no chips
var greetingRegex = /^(hi|hello|hey|hola|howdy|yo|hiya|sup|good (morning|afternoon|evening))[\s!.?]*$/i;
if (greetingRegex.test(text.trim())) {
setTimeout(function() {
addMessage('agent', 'Hello! How can I help you today?');
sessionDirty = true;
}, 400);
return;
}
// Frustration / sentiment detection → escalate directly to human handoff
var frustrationWords = ['frustrating', 'frustrated', 'ridiculous', 'useless', 'not helping', 'you\'re useless', 'you are useless', 'stupid bot', 'this is dumb', 'this sucks', 'asked twice', 'asked before', 'asked already', 'multiple times', 'i already said', 'i already told', 'can\'t get', 'cant get', 'pointless', 'waste of time', 'you don\'t understand', 'you dont understand', 'not listening'];
// ALL-CAPS alone is not enough — many customers just type in caps.
// Only treat CAPS as yelling when combined with an exclamation mark AND it's not a plain question
// (avoid escalating things like "WHAT IS THE OUTDOOR CABINET MADE OF?").
var isAllCapsText = text.length > 8 && text === text.toUpperCase() && /[A-Z]{4,}/.test(text);
var looksLikeQuestion = /\?/.test(text) || /^(WHAT|HOW|WHEN|WHERE|WHY|WHO|WHICH|DO YOU|DOES|IS IT|ARE YOU|CAN YOU|COULD YOU|DO I|DOES IT)\b/.test(text);
var isAllCapsYelling = isAllCapsText && /!/.test(text) && !looksLikeQuestion;
var isFrustrated = frustrationWords.some(function(w){ return text.toLowerCase().includes(w); }) || isAllCapsYelling;
if (isFrustrated) {
setTimeout(function() {
addMessage('agent', "I'm really sorry \u2014 let me connect you with a real person right away. Our team will take care of you.");
if (leadCollected) {
handoffToHuman('Customer frustration detected');
addMessage('agent', "I've notified our team with your full conversation. " + callbackConfirmMsg(leadInfo.phone));
} else {
var fDiv = addMessage('agent', "Just need a couple details so they can reach you:");
var fForm = createLeadForm('fr', [
{ id: 'first', placeholder: 'First Name' },
{ id: 'last', placeholder: 'Last Name' },
{ id: 'phone', placeholder: 'Phone Number', type: 'tel' },
{ id: 'email', placeholder: 'Email Address', type: 'email' }
], function(form, prefix) {
var first = document.getElementById(prefix + '-first').value.trim();
var last = document.getElementById(prefix + '-last').value.trim();
var phone = document.getElementById(prefix + '-phone').value.trim();
var email = document.getElementById(prefix + '-email').value.trim();
[prefix+'-first',prefix+'-phone',prefix+'-email'].forEach(function(id){ document.getElementById(id).classList.remove('error'); });
var valid = true;
if (!first) { document.getElementById(prefix+'-first').classList.add('error'); valid = false; }
if (!phone) { document.getElementById(prefix+'-phone').classList.add('error'); valid = false; }
if (!email) { document.getElementById(prefix+'-email').classList.add('error'); valid = false; }
if (!valid) return;
form.remove();
leadCollected = true;
leadInfo = { first: first, last: last || '', company: '', phone: phone, email: email };
handoffToHuman('Customer frustration detected');
logAllConversation();
chatLogged = true;
addMessage('agent', "Our team has been notified with priority. " + callbackConfirmMsg(phone));
});
fForm.buttonText = 'Connect Me Now';
appendWidget(fDiv, fForm);
}
sessionDirty = true;
}, 400);
return;
}
document.getElementById('lioher-send').disabled = true;
isTyping = true;
showTyping();
var typingStartedAt = Date.now();
try {
// Retry up to 2 times on failure
let response, lastError;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000); // 25 second timeout
response = await fetch(CONFIG.aiProxy, {
signal: controller.signal,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: (function() {
var systemNotes = [];
systemNotes.push('Customer is currently viewing this lioher.com page: ' + pageLabel(currentPage()) + '. Internal context only (see CURRENT PAGE rules).');
if (detectedLanguage === 'es') {
systemNotes.push('The customer is speaking Spanish. Respond entirely in Spanish from now on. Be warm and professional. Use "usted" form.');
}
// ALL CAPS heuristic: some customers just type in caps (not angry). We already skip the auto-handoff for these,
// so signal Claude to answer normally and add a soft offer at the end.
if (text.length > 8 && text === text.toUpperCase() && /[A-Z]{4,}/.test(text)) {
systemNotes.push('The customer wrote in ALL CAPS. Some people just type that way — do NOT assume they are angry. Answer their question fully and normally, then close with ONE short friendly line offering to connect them with a real person if they\'d prefer (e.g. "If you\'d rather talk to someone from our team, just say the word and I\'ll set it up."). Do NOT ask for their details unless they say yes.');
}
if (userRegion) {
var statePart = userState ? ' (state: ' + userState + ')' : '';
systemNotes.push(
'Customer location auto-detected: ' + userRegion + ' region' + statePart + '. ' +
'For White Melamine TFL pricing ONLY, quote ' + userRegion + ' prices: ' +
(userRegion === 'East'
? '19mm $33.00/sheet, 9.5mm $28.28/sheet (these are the East / Florida-region prices).'
: '19mm $38.00/sheet, 9.5mm $32.50/sheet (these are the West / California-region prices).') +
' Do NOT ask for the customer’s state on White TFL questions — you already have it. NEVER mention any discount or percentage. If the customer explicitly says they are in a different state, trust them and re-quote accordingly.'
);
}
// Customer asked for a human a while ago and is still typing — offer the direct line once.
if (_handoffFiredAt && !_phoneOfferGiven && (Date.now() - _handoffFiredAt) >= 3 * 60 * 1000) {
_phoneOfferGiven = true;
systemNotes.push('The customer asked to talk to a real person a few minutes ago and is writing again while they wait for the team. After answering their message, add ONE short line offering a faster route: they can call the Inside Sales team directly at 786-218-0063, or message that same number on WhatsApp if they prefer. Give the number exactly as 786-218-0063. Mention this only once - do not repeat it in later replies.');
}
var prefixMsg = systemNotes.length
? { role: 'user', content: '[SYSTEM: ' + systemNotes.join(' | ') + ']' }
: null;
// Strip custom fields (e.g. suggestedChips) — Anthropic API only accepts role + content
var cleanHistory = history.map(function(m){ return { role: m.role, content: m.content }; });
return prefixMsg ? [prefixMsg].concat(cleanHistory) : cleanHistory;
})(),
max_tokens: 350
})
});
clearTimeout(timeoutId);
break; // success - exit retry loop
} catch(retryErr) {
lastError = retryErr;
if (attempt < 1) await new Promise(r => setTimeout(r, 2000)); // wait 2s before retry
}
}
if (!response) throw lastError;
const data = await response.json();
const reply = data.content?.[0]?.text || "I'm having trouble connecting. Please try again.";
// Minimum typing time so fast responses don't feel robotic; scale slightly with reply length
const minTypingMs = Math.min(1400, Math.max(600, reply.length * 8));
const elapsedMs = Date.now() - typingStartedAt;
if (elapsedMs < minTypingMs) {
await new Promise(function(r){ setTimeout(r, minTypingMs - elapsedMs); });
}
removeTyping();
const t = text.toLowerCase();
const r = reply.toLowerCase();
// Word-boundary match — plain substring 'call' fired on words like "technically" and popped the appointment menu after unrelated replies
const bookingRe = /\b(book(ing)?|appointments?|schedul(e|es|ing)|calls?|meetings?|showrooms?|consult(ation)?s?)\b/;
const wantsBooking = bookingRe.test(t) || bookingRe.test(r);
const speakWords = ['speak with', 'talk to', 'talk to someone', 'connect me', 'real person', 'human', 'agent', 'representative', 'someone from', 'hablar con una persona', 'hablar con ventas', 'persona real'];
const wantsHuman = speakWords.some(kw => t.includes(kw));
// Detect homeowner mentions
const homeownerWords = ['homeowner', 'home owner', 'my house', 'my home', 'my kitchen', 'my closet', 'renovation', 'remodel', 'my bathroom', 'residential', 'personal project', 'my project', 'sell to home', 'work with home', 'for homeowner'];
const isHomeowner = homeownerWords.some(kw => t.includes(kw)) || r.includes('find-a-professional') || (r.includes('certified') && r.includes('professional'));
// Detect conversation winding down — offer follow-up email summary
// Only fire ending flow on CLEAR goodbye signals — avoid common mid-convo words like "perfect" / "great" / "got it"
const endSignals = ['thank you so much', 'thanks so much', 'thanks for your help', 'thanks for all', 'thanks for everything', 'that\'s all', 'thats all', 'that\'s it for now', 'thats it for now', 'that is all', 'bye', 'goodbye', 'see you later', 'talk later', 'gotta go', 'have to go', 'no more questions'];
const isEnding = endSignals.some(s => t.includes(s));
if (isEnding && !followUpEmailOffered) {
followUpEmailOffered = true;
setTimeout(() => {
const endDiv = addMessage('agent', 'Would you like a follow-up email summary of our conversation?');
const qr = document.createElement('div');
qr.className = 'quick-replies';
['Yes, send me a summary', 'No thanks'].forEach(label => {
const chip = document.createElement('button');
chip.className = 'qr-chip';
chip.textContent = label;
chip.onclick = () => {
qr.remove();
if (label.startsWith('Yes')) {
addMessage('user', label);
if (leadCollected) {
// Already have their info — send summary directly
sendFollowUpEmail(leadInfo.first, leadInfo.last, leadInfo.email, true);
addMessage('agent', 'A summary will be sent to ' + leadInfo.email + ' at the end of the day!');
} else {
// Collect their email
const emailDiv = addMessage('agent', 'What email should I send it to?');
showFollowUpForm(emailDiv);
}
} else {
addMessage('user', label);
addMessage('agent', 'No problem! Feel free to come back anytime.');
}
};
qr.appendChild(chip);
});
appendWidget(endDiv, qr);
}, 1000);
}
// Re-show main menu chips if AI replied to a greeting
const greetingTriggers = ['hi', 'hello', 'hey', 'hola', 'good morning', 'good afternoon', 'good evening', 'sup', 'what\'s up', 'whats up'];
const isGreeting = greetingTriggers.some(g => t.trim() === g || t.trim() === g + '!' || t.trim() === g + '.');
// Detect pricing / loyalty program questions — check both user text AND specific price patterns in Lio's reply
const pricingWords = ['price', 'pricing', 'cost', 'how much', 'rate', 'discount', 'loyalty', 'bracket', 'promo', 'deal', 'offer', 'sqft', 'per foot', 'per sheet'];
const pricePatternInReply = /\$\s?\d+|\/sqft|\/linear foot|\/sheet|per linear foot|per sqft|per sheet/i.test(reply);
const wantsPricing = pricingWords.some(kw => t.includes(kw)) || pricePatternInReply;
// COLLECT_LEAD: AI decided user said yes to a callback
if (reply.trim() === 'COLLECT_LEAD') {
if (!leadCollected) {
const rd = addMessage('agent', callbackLeadMsg());
showLeadForm(rd);
} else {
addMessage('agent', callbackConfirmMsg(leadInfo.phone));
}
} else {
const msgDiv = addMessage('agent', reply);
// Determine if a SPECIFIC chip flow will show — if so, skip the generic contextual chips
// to avoid multiple stacked chip rows
const willShowPricingChips = wantsPricing && !isGreeting;
const willShowSignalChips = buyingSignalScore >= 5 && !leadCollected && !leadFormShownBySignal && !wantsPricing;
const willShowGreetingMenu = isGreeting;
const willShowHomeownerCard = isHomeowner;
const willShowEndingChips = isEnding;
const willShowHumanHandoff = wantsHuman;
const willShowBookingMenu = wantsBooking;
const skipContextualChips = willShowPricingChips || willShowSignalChips || willShowGreetingMenu || willShowHomeownerCard || willShowEndingChips || willShowHumanHandoff || willShowBookingMenu;
if (!skipContextualChips) {
showQuickReplies(generateQuickReplies(reply));
}
// Smart lead form: show when buying signals are strong enough — but NOT if pricing flow is also firing (avoid stacked lead-capture offers)
if (buyingSignalScore >= 5 && !leadCollected && !leadFormShownBySignal && !wantsPricing) {
leadFormShownBySignal = true;
setTimeout(function() {
var signalDiv = addMessage('agent', detectedLanguage === 'es' ? "Parece que su proyecto va en serio. ¿Le gustaría que uno de nuestros especialistas se comunique con usted?" : "It sounds like you're getting serious about your project! Want me to have one of our specialists reach out to help?");
var sqr = document.createElement('div');
sqr.className = 'quick-replies';
var signalLabels = detectedLanguage === 'es' ? ['Sí, que me llamen', 'Todavía no, solo estoy explorando'] : ['Yes, have someone call me', 'Not yet, just exploring'];
signalLabels.forEach(function(label) {
var chip = document.createElement('button');
chip.className = 'qr-chip';
chip.textContent = label;
chip.onclick = function() {
sqr.remove();
addMessage('user', label);
if (label.startsWith('Yes') || label.startsWith('Sí')) {
var rd = addMessage('agent', detectedLanguage === 'es' ? "¡Excelente! Déjeme tomar sus datos." : "Great! Let me grab your details.");
showLeadForm(rd);
} else {
addMessage('agent', detectedLanguage === 'es' ? "¡No hay problema! Estoy aquí cuando lo necesite." : "No problem! I'm here whenever you're ready.");
}
};
sqr.appendChild(chip);
});
appendWidget(signalDiv, sqr);
}, 1500);
}
// After pricing/loyalty reply — show Yes/No callback chips (always show form regardless)
if (wantsPricing && !isGreeting) {
const qr = document.createElement('div');
qr.className = 'quick-replies';
['Yes, call me back', 'No thanks'].forEach(label => {
const chip = document.createElement('button');
chip.className = 'qr-chip';
chip.textContent = label;
chip.onclick = () => {
qr.remove();
addMessage('user', label);
if (label.startsWith('Yes')) {
if (!leadCollected) {
const rd = addMessage('agent', callbackLeadMsg());
showLeadForm(rd);
} else {
sendLeadEmail(leadInfo.first, leadInfo.last, leadInfo.company, leadInfo.phone, leadInfo.email, 'Pricing Callback Request', leadInfo.zip || '', true);
addMessage('agent', callbackConfirmMsg(leadInfo.phone));
}
} else {
addMessage('agent', "No problem! Let me know if you need anything else.");
}
};
qr.appendChild(chip);
});
appendWidget(msgDiv, qr);
}
// Homeowner → show Find a Pro card + callback option
if (isHomeowner) {
// Show homeowner-specific options
const profCard = createMenuCard([
{ label: 'Find a Lioher Professional Near Me', url: CONFIG.urls.findPro },
{ label: 'Have someone call me back', onclick: () => {
profCard.remove();
addMessage('user', 'Have someone call me back');
const rd = addMessage('agent', 'Perfect! Leave your details and an inside sales rep will call you within minutes.');
showLeadForm(rd);
}
}
]);
appendWidget(msgDiv, profCard);
} else if (wantsHuman) {
if (leadCollected) {
handoffToHuman('Customer requested human agent');
addMessage('agent', "I\u2019ve notified our inside sales team \u2014 they have your full conversation. " + callbackConfirmMsg(leadInfo.phone) + " You can keep chatting with me in the meantime!");
} else {
var handoffDiv2 = addMessage('agent', "I'll connect you with our team! Just need a couple details so they can reach you.");
var hoForm2 = createLeadForm('ho2', [
{ id: 'first', placeholder: 'First Name' },
{ id: 'last', placeholder: 'Last Name' },
{ id: 'phone', placeholder: 'Phone Number', type: 'tel' },
{ id: 'email', placeholder: 'Email Address', type: 'email' }
], function(form, prefix) {
var first = document.getElementById(prefix + '-first').value.trim();
var last = document.getElementById(prefix + '-last').value.trim();
var phone = document.getElementById(prefix + '-phone').value.trim();
var email = document.getElementById(prefix + '-email').value.trim();
[prefix+'-first',prefix+'-phone',prefix+'-email'].forEach(function(id){ document.getElementById(id).classList.remove('error'); });
var valid = true;
if (!first) { document.getElementById(prefix+'-first').classList.add('error'); valid = false; }
if (!phone) { document.getElementById(prefix+'-phone').classList.add('error'); valid = false; }
if (!email) { document.getElementById(prefix+'-email').classList.add('error'); valid = false; }
if (!valid) return;
form.remove();
leadCollected = true;
leadInfo = { first: first, last: last || '', company: '', phone: phone, email: email };
handoffToHuman('Customer requested human agent');
logAllConversation();
chatLogged = true;
addMessage('agent', "Our inside sales team has been notified with your full conversation. " + callbackConfirmMsg(phone));
});
hoForm2.buttonText = 'Connect Me';
appendWidget(handoffDiv2, hoForm2);
}
} else if (wantsBooking) {
showAppointmentMenu();
}
}
} catch (err) {
removeTyping();
const errDiv = addMessage('agent', "I'm having a little trouble right now. You can visit lioher.com or call us at 305-685-0005 — or I can have someone call you back!");
const errQr = document.createElement('div');
errQr.className = 'quick-replies';
['Yes, call me back', 'No thanks'].forEach(function(label) {
const chip = document.createElement('button');
chip.className = 'qr-chip';
chip.textContent = label;
chip.onclick = function() {
errQr.remove();
addMessage('user', label);
if (label.startsWith('Yes')) {
const rd = addMessage('agent', callbackLeadMsg());
showLeadForm(rd);
} else {
addMessage('agent', "No problem! Feel free to reach out anytime at 305-685-0005 or lioher.com.");
}
};
errQr.appendChild(chip);
});
appendWidget(errDiv, errQr);
console.error(err);
}
isTyping = false;
document.getElementById('lioher-send').disabled = false;
}
// ── AUTO POPUP BUBBLE ──────────────────────────────────────────────────────
let bubbleShown = false;
function showPopupBubble() {
if (bubbleShown || isOpen) return;
bubbleShown = true;
const bubble = document.createElement('div');
bubble.id = 'lio-popup-bubble';
bubble.style.cssText = 'position:fixed;bottom:104px;right:28px;background:#ffffff;border:1px solid rgba(91,126,191,0.25);border-radius:16px 16px 4px 16px;padding:12px 16px;font-family:Montserrat,Arial,sans-serif;font-size:13px;font-weight:400;color:#1a1a1a;max-width:220px;box-shadow:0 8px 32px rgba(0,0,0,0.14);z-index:9997;cursor:pointer;line-height:1.5;opacity:0;transform:translateY(12px) scale(0.9);transition:opacity 0.35s ease,transform 0.35s cubic-bezier(0.34,1.56,0.64,1);';
bubble.innerHTML = '
Any questions? I\'m here to help! 👋✕
';
document.body.appendChild(bubble);
// Animate in
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bubble.style.opacity = '1';
bubble.style.transform = 'translateY(0) scale(1)';
});
});
function closeBubble() {
bubble.style.opacity = '0';
bubble.style.transform = 'translateY(8px) scale(0.95)';
setTimeout(() => { if (bubble.parentNode) bubble.remove(); }, 300);
}
document.getElementById('lio-bubble-x').addEventListener('click', function(e) {
e.stopPropagation();
closeBubble();
});
bubble.addEventListener('click', function() {
closeBubble();
if (!isOpen) toggleChat();
});
// Auto-hide after 8 seconds
setTimeout(closeBubble, 8000);
}
// Show bubble after 3 seconds if user hasn't opened chat
setTimeout(showPopupBubble, 3000);
// ── PROACTIVE CHAT TRIGGERS ─────────────────────────────────────────────
function setupProactiveTriggers() {
var path = window.location.pathname.toLowerCase();
var triggerDelay = null;
var triggerMessage = '';
// Product pages — 20 seconds
if (path.includes('/products/kitchens') || path.includes('/outdoor-kitchen') || path.includes('/closets')) {
triggerDelay = 20000;
triggerMessage = 'Need help choosing the right cabinets for your project?';
}
// Surface collections — 25 seconds
else if (/\/panels-edge(-2)?\/(luxe|zenit|syncron|climacore)\b/.test(path)) {
triggerDelay = 25000;
triggerMessage = 'Want to see how these finishes look in person? We can send you samples.';
}
// Doors page
else if (path.includes('/doors')) {
triggerDelay = 20000;
triggerMessage = 'Looking for the perfect cabinet doors? I can help you find the right style.';
}
// Design/Inspiration pages — 30 seconds
else if (path.includes('/designs') || path.includes('/inspiration') || path.includes('/gallery')) {
triggerDelay = 30000;
triggerMessage = 'Love what you see? I can help you bring this design to life.';
}
// Booking pages — 15 seconds
else if (path.includes('/design-locations') || path.includes('/conference-room') || path.includes('/discovery-selection')) {
triggerDelay = 15000;
triggerMessage = 'Need help picking the right appointment type? I can guide you.';
}
// Homepage — 35 seconds
else if (path === '/' || path === '') {
triggerDelay = 35000;
triggerMessage = 'Welcome to Lioher! Are you a homeowner or a trade professional?';
}
// Exit intent on any page
document.addEventListener('mouseleave', function(e) {
if (e.clientY < 0 && !isOpen && !sessionStorage.getItem('lioher_popup_dismissed') && !sessionStorage.getItem('lioher_chat_interacted')) {
showProactivePopup('Before you go — want us to help you find what you need?');
}
}, { once: true });
if (triggerDelay && !sessionStorage.getItem('lioher_popup_dismissed') && !sessionStorage.getItem('lioher_chat_interacted')) {
setTimeout(function() {
if (!isOpen) {
showProactivePopup(triggerMessage);
}
}, triggerDelay);
}
}
function showProactivePopup(message) {
// Remove any existing popup
var existing = document.getElementById('lioher-proactive');
if (existing) existing.remove();
var popup = document.createElement('div');
popup.id = 'lioher-proactive';
popup.style.cssText = 'position:fixed;bottom:100px;right:28px;max-width:280px;background:#fff;border-radius:16px;padding:16px 20px;box-shadow:0 8px 32px rgba(0,0,0,0.15);z-index:9997;font-family:Montserrat,sans-serif;animation:si-fadeIn 0.3s ease;border:1px solid rgba(91,126,191,0.2);';
popup.innerHTML = '
'
+ ''
+ '
'
+ '
Lio from Lioher
'
+ '
' + message + '
'
+ '
'
+ ''
+ '
'
+ '
'
+ ''
+ ''
+ '
';
document.body.appendChild(popup);
document.getElementById('lioher-proactive-yes').addEventListener('click', function() {
popup.remove();
sessionStorage.setItem('lioher_chat_interacted', 'true');
toggleChat();
});
document.getElementById('lioher-proactive-no').addEventListener('click', function() {
popup.remove();
sessionStorage.setItem('lioher_popup_dismissed', 'true');
});
document.getElementById('lioher-proactive-close').addEventListener('click', function() {
popup.remove();
sessionStorage.setItem('lioher_popup_dismissed', 'true');
});
// Auto-dismiss after 15 seconds
setTimeout(function() {
if (document.getElementById('lioher-proactive')) {
popup.remove();
}
}, 15000);
}
// Expose globals needed by HTML onclick attributes
window.toggleChat = toggleChat;
window.handleKey = handleKey;
window.sendMessage = sendMessage;
window.autoResize = autoResize;
document.getElementById('lioher-launcher').addEventListener('click', function() { toggleChat(); });
// Initialize proactive triggers
recordPageVisit();
setupProactiveTriggers();
// ── GEO-IP REGIONAL DETECTION (for White TFL pricing) ────────────────────
// Quietly resolves the visitor's US state -> East/West region, cached in
// sessionStorage. Used to pre-fill the right White TFL price without asking.
// East = FL pricing ($33 / $28.28). West = CA pricing ($38 / $32.50).
// Other countries / failures: userRegion stays null and Lio will simply ask.
detectUserRegion();
})();